summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/pages
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-04-07 11:06:23 -0500
committerGitHub <[email protected]>2026-04-07 11:06:23 -0500
commitec8b9810b4231cd6a5c69ccd930b6c50999fc997 (patch)
tree562313d6dd3eda9891f3a4a3a2ef6ce3d36acd05 /packages/app/src/pages
parent65318a80f7a3320ba77b749241f8de997dc65c82 (diff)
downloadopencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz
opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip
feat(app): better subagent experience (#20708)
Diffstat (limited to 'packages/app/src/pages')
-rw-r--r--packages/app/src/pages/layout.tsx17
-rw-r--r--packages/app/src/pages/layout/helpers.test.ts14
-rw-r--r--packages/app/src/pages/layout/helpers.ts21
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx296
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx27
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx26
-rw-r--r--packages/app/src/pages/session.tsx14
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx61
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx419
9 files changed, 437 insertions, 458 deletions
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 79b9abd33..f402f4bc0 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -150,7 +150,6 @@ export default function Layout(props: ParentProps) {
const [state, setState] = createStore({
autoselect: !initialDirectory,
busyWorkspaces: {} as Record<string, boolean>,
- hoverSession: undefined as string | undefined,
hoverProject: undefined as string | undefined,
scrollSessionKey: undefined as string | undefined,
nav: undefined as HTMLElement | undefined,
@@ -194,7 +193,6 @@ export default function Layout(props: ParentProps) {
onActivate: (directory) => {
globalSync.child(directory)
setState("hoverProject", directory)
- setState("hoverSession", undefined)
},
})
@@ -231,7 +229,6 @@ export default function Layout(props: ParentProps) {
aim.reset()
}
const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
- const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
const disarm = () => {
if (navLeave.current === undefined) return
@@ -241,7 +238,6 @@ export default function Layout(props: ParentProps) {
const reset = () => {
disarm()
- setState("hoverSession", undefined)
setHoverProject(undefined)
}
@@ -252,7 +248,6 @@ export default function Layout(props: ParentProps) {
navLeave.current = window.setTimeout(() => {
navLeave.current = undefined
setHoverProject(undefined)
- setState("hoverSession", undefined)
}, 300)
}
@@ -1972,9 +1967,6 @@ export default function Layout(props: ParentProps) {
navList: currentSessions,
sidebarExpanded,
sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
@@ -2003,7 +1995,6 @@ export default function Layout(props: ParentProps) {
sidebarOpened: () => layout.sidebar.opened(),
sidebarHovering,
hoverProject: () => state.hoverProject,
- nav: () => state.nav,
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree),
@@ -2022,15 +2013,10 @@ export default function Layout(props: ParentProps) {
sessionProps: {
navList: currentSessions,
sidebarExpanded,
- sidebarHovering,
- nav: () => state.nav,
- hoverSession: () => state.hoverSession,
- setHoverSession,
clearHoverProjectSoon,
prefetchSession,
archiveSession,
},
- setHoverSession,
}
const SidebarPanel = (panelProps: {
@@ -2041,7 +2027,6 @@ export default function Layout(props: ParentProps) {
const project = panelProps.project
const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
- const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
const projectName = createMemo(() => {
const item = project()
@@ -2243,7 +2228,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
- popover={popover()}
/>
</div>
</>
@@ -2288,7 +2272,6 @@ export default function Layout(props: ParentProps) {
project={project()!}
sortNow={sortNow}
mobile={panelProps.mobile}
- popover={popover()}
/>
)}
</For>
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index 1fe52d47a..988332ab7 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -8,6 +8,7 @@ import {
} from "./deep-links"
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
+ childSessionOnPath,
displayName,
effectiveWorkspaceOrder,
errorMessage,
@@ -198,6 +199,19 @@ describe("layout workspace helpers", () => {
expect(result?.id).toBe("root")
})
+ test("finds the direct child on the active session path", () => {
+ const list = [
+ session({ id: "root", directory: "/workspace" }),
+ session({ id: "child", directory: "/workspace", parentID: "root" }),
+ session({ id: "leaf", directory: "/workspace", parentID: "child" }),
+ ]
+
+ expect(childSessionOnPath(list, "root", "leaf")?.id).toBe("child")
+ expect(childSessionOnPath(list, "child", "leaf")?.id).toBe("leaf")
+ expect(childSessionOnPath(list, "root", "root")).toBeUndefined()
+ expect(childSessionOnPath(list, "root", "other")).toBeUndefined()
+ })
+
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 226098c1c..48158debb 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -46,18 +46,17 @@ export function hasProjectPermissions<T>(
return Object.values(request ?? {}).some((list) => list?.some(include))
}
-export const childMapByParent = (sessions: Session[] | undefined) => {
- const map = new Map<string, string[]>()
- for (const session of sessions ?? []) {
- if (!session.parentID) continue
- const existing = map.get(session.parentID)
- if (existing) {
- existing.push(session.id)
- continue
- }
- map.set(session.parentID, [session.id])
+export const childSessionOnPath = (sessions: Session[] | undefined, rootID: string, activeID?: string) => {
+ if (!activeID || activeID === rootID) return
+ const map = new Map((sessions ?? []).map((session) => [session.id, session]))
+ let id = activeID
+
+ while (id) {
+ const session = map.get(id)
+ if (!session?.parentID) return
+ if (session.parentID === rootID) return session
+ id = session.parentID
}
- return map
}
export const displayName = (project: { name?: string; worktree: string }) =>
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 058bb5a0d..e56accfc8 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -1,15 +1,12 @@
-import type { Message, Session, TextPart, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { Session } from "@opencode-ai/sdk/v2/client"
import { Avatar } from "@opencode-ai/ui/avatar"
-import { HoverCard } from "@opencode-ai/ui/hover-card"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { MessageNav } from "@opencode-ai/ui/message-nav"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
-import { A, useNavigate, useParams } from "@solidjs/router"
-import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js"
+import { A, useParams } from "@solidjs/router"
+import { type Accessor, createMemo, For, type JSX, Match, Show, Switch } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout"
@@ -18,7 +15,7 @@ import { usePermission } from "@/context/permission"
import { messageAgentColor } from "@/utils/agent"
import { sessionTitle } from "@/utils/session-title"
import { sessionPermissionRequest } from "../session/composer/session-request-tree"
-import { hasProjectPermissions } from "./helpers"
+import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -39,6 +36,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
)
const notify = createMemo(() => props.notify && (hasPermissions() || unseenCount() > 0))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
+
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
@@ -73,13 +71,10 @@ export type SessionItemProps = {
slug: string
mobile?: boolean
dense?: boolean
- popover?: boolean
- children: Map<string, string[]>
+ showTooltip?: boolean
+ showChild?: boolean
+ level?: number
sidebarExpanded: Accessor<boolean>
- sidebarHovering: Accessor<boolean>
- nav: Accessor<HTMLElement | undefined>
- hoverSession: Accessor<string | undefined>
- setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
@@ -95,116 +90,52 @@ const SessionRow = (props: {
hasPermissions: Accessor<boolean>
hasError: Accessor<boolean>
unseenCount: Accessor<number>
- setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
sidebarOpened: Accessor<boolean>
- warmHover: () => void
warmPress: () => void
warmFocus: () => void
- cancelHoverPrefetch: () => void
-}) => {
+}): JSX.Element => {
const title = () => sessionTitle(props.session.title)
return (
<A
href={`/${props.slug}/session/${props.session.id}`}
- class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
+ class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress}
- onPointerEnter={props.warmHover}
- onPointerLeave={props.cancelHoverPrefetch}
onFocus={props.warmFocus}
onClick={() => {
- props.setHoverSession(undefined)
if (props.sidebarOpened()) return
props.clearHoverProjectSoon()
}}
>
- <div
- class="shrink-0 size-6 flex items-center justify-center"
- style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
- >
- <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
- <Match when={props.isWorking()}>
- <Spinner class="size-[15px]" />
- </Match>
- <Match when={props.hasPermissions()}>
- <div class="size-1.5 rounded-full bg-surface-warning-strong" />
- </Match>
- <Match when={props.hasError()}>
- <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
- </Match>
- <Match when={props.unseenCount() > 0}>
- <div class="size-1.5 rounded-full bg-text-interactive-base" />
- </Match>
- </Switch>
- </div>
- <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
- </A>
- )
-}
-
-const SessionHoverPreview = (props: {
- mobile?: boolean
- nav: Accessor<HTMLElement | undefined>
- hoverSession: Accessor<string | undefined>
- session: Session
- sidebarHovering: Accessor<boolean>
- hoverReady: Accessor<boolean>
- hoverMessages: Accessor<UserMessage[] | undefined>
- language: ReturnType<typeof useLanguage>
- isActive: Accessor<boolean>
- slug: string
- setHoverSession: (id: string | undefined) => void
- messageLabel: (message: Message) => string | undefined
- onMessageSelect: (message: Message) => void
- trigger: JSX.Element
-}): JSX.Element => {
- let ref: HTMLDivElement | undefined
-
- return (
- <HoverCard
- openDelay={1000}
- closeDelay={props.sidebarHovering() ? 600 : 0}
- placement="right-start"
- gutter={16}
- shift={-2}
- trigger={
- <div ref={ref} class="min-w-0 w-full">
- {props.trigger}
- </div>
- }
- open={props.hoverSession() === props.session.id}
- onOpenChange={(open) => {
- if (!open) {
- props.setHoverSession(undefined)
- return
- }
- if (!ref?.matches(":hover")) return
- props.setHoverSession(props.session.id)
- }}
- >
- <Show
- when={props.hoverReady()}
- fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
- >
- <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
- <MessageNav
- messages={props.hoverMessages() ?? []}
- current={undefined}
- getLabel={props.messageLabel}
- onMessageSelect={props.onMessageSelect}
- size="normal"
- class="w-60"
- />
+ <Show when={props.isWorking() || props.hasPermissions() || props.hasError() || props.unseenCount() > 0}>
+ <div
+ class="shrink-0 size-6 flex items-center justify-center"
+ style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
+ >
+ <Switch>
+ <Match when={props.isWorking()}>
+ <Spinner class="size-[15px]" />
+ </Match>
+ <Match when={props.hasPermissions()}>
+ <div class="size-1.5 rounded-full bg-surface-warning-strong" />
+ </Match>
+ <Match when={props.hasError()}>
+ <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
+ </Match>
+ <Match when={props.unseenCount() > 0}>
+ <div class="size-1.5 rounded-full bg-text-interactive-base" />
+ </Match>
+ </Switch>
</div>
</Show>
- </HoverCard>
+ <span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{title()}</span>
+ </A>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams()
- const navigate = useNavigate()
const layout = useLayout()
const language = useLanguage()
const notification = useNotification()
@@ -234,18 +165,13 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
)
})
- const tint = createMemo(() => {
- return messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent)
+ const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
+ const tooltip = createMemo(() => props.showTooltip ?? (props.mobile || !props.sidebarExpanded()))
+ const currentChild = createMemo(() => {
+ if (!props.showChild) return
+ return childSessionOnPath(sessionStore.session, props.session.id, params.id)
})
- const hoverMessages = createMemo(() =>
- sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"),
- )
- const hoverReady = createMemo(() => hoverMessages() !== undefined)
- const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded())
- const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
- const isActive = createMemo(() => props.session.id === params.id)
-
const warm = (span: number, priority: "high" | "low") => {
const nav = props.navList?.()
const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory)
@@ -266,30 +192,6 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
}
}
- const hoverPrefetch = {
- current: undefined as ReturnType<typeof setTimeout> | undefined,
- }
- const cancelHoverPrefetch = () => {
- if (hoverPrefetch.current === undefined) return
- clearTimeout(hoverPrefetch.current)
- hoverPrefetch.current = undefined
- }
- const scheduleHoverPrefetch = () => {
- warm(1, "high")
- if (hoverPrefetch.current !== undefined) return
- hoverPrefetch.current = setTimeout(() => {
- hoverPrefetch.current = undefined
- warm(2, "low")
- }, 80)
- }
-
- onCleanup(cancelHoverPrefetch)
-
- const messageLabel = (message: Message) => {
- const parts = sessionStore.part[message.id] ?? []
- const text = parts.find((part): part is TextPart => part?.type === "text" && !part.synthetic && !part.ignored)
- return text?.text
- }
const item = (
<SessionRow
session={props.session}
@@ -301,86 +203,74 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
hasPermissions={hasPermissions}
hasError={hasError}
unseenCount={unseenCount}
- setHoverSession={props.setHoverSession}
clearHoverProjectSoon={props.clearHoverProjectSoon}
sidebarOpened={layout.sidebar.opened}
- warmHover={scheduleHoverPrefetch}
warmPress={() => warm(2, "high")}
warmFocus={() => warm(2, "high")}
- cancelHoverPrefetch={cancelHoverPrefetch}
/>
)
return (
- <div
- data-session-id={props.session.id}
- class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
- hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
- >
- <div class="flex min-w-0 items-center gap-1">
- <div class="min-w-0 flex-1">
- <Show
- when={hoverEnabled()}
- fallback={
- <Tooltip
- placement={props.mobile ? "bottom" : "right"}
- value={sessionTitle(props.session.title)}
- gutter={10}
- class="min-w-0 w-full"
- >
- {item}
- </Tooltip>
- }
- >
- <SessionHoverPreview
- mobile={props.mobile}
- nav={props.nav}
- hoverSession={props.hoverSession}
- session={props.session}
- sidebarHovering={props.sidebarHovering}
- hoverReady={hoverReady}
- hoverMessages={hoverMessages}
- language={language}
- isActive={isActive}
- slug={props.slug}
- setHoverSession={props.setHoverSession}
- messageLabel={messageLabel}
- onMessageSelect={(message) => {
- if (!isActive())
- layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
+ <>
+ <div
+ data-session-id={props.session.id}
+ class="group/session relative w-full min-w-0 rounded-md cursor-default pr-3 transition-colors hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
+ style={{ "padding-left": `${8 + (props.level ?? 0) * 16}px` }}
+ >
+ <div class="flex min-w-0 items-center gap-1">
+ <div class="min-w-0 flex-1">
+ <Show
+ when={!tooltip()}
+ fallback={
+ <Tooltip
+ placement={props.mobile ? "bottom" : "right"}
+ value={sessionTitle(props.session.title)}
+ gutter={10}
+ class="min-w-0 w-full"
+ >
+ {item}
+ </Tooltip>
+ }
+ >
+ {item}
+ </Show>
+ </div>
- navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
+ <Show when={!props.level}>
+ <div
+ class="shrink-0 overflow-hidden transition-[width,opacity]"
+ classList={{
+ "w-6 opacity-100 pointer-events-auto": !!props.mobile,
+ "w-0 opacity-0 pointer-events-none": !props.mobile,
+ "group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
+ "group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
- trigger={item}
- />
+ >
+ <Tooltip value={language.t("common.archive")} placement="top">
+ <IconButton
+ icon="archive"
+ variant="ghost"
+ class="size-6 rounded-md"
+ aria-label={language.t("common.archive")}
+ onClick={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ void props.archiveSession(props.session)
+ }}
+ />
+ </Tooltip>
+ </div>
</Show>
</div>
-
- <div
- class="shrink-0 overflow-hidden transition-[width,opacity]"
- classList={{
- "w-6 opacity-100 pointer-events-auto": !!props.mobile,
- "w-0 opacity-0 pointer-events-none": !props.mobile,
- "group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
- "group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
- }}
- >
- <Tooltip value={language.t("common.archive")} placement="top">
- <IconButton
- icon="archive"
- variant="ghost"
- class="size-6 rounded-md"
- aria-label={language.t("common.archive")}
- onClick={(event) => {
- event.preventDefault()
- event.stopPropagation()
- void props.archiveSession(props.session)
- }}
- />
- </Tooltip>
- </div>
</div>
- </div>
+ <Show when={currentChild()}>
+ {(child) => (
+ <div class="w-full">
+ <SessionItem {...props} session={child()} level={(props.level ?? 0) + 1} />
+ </div>
+ )}
+ </Show>
+ </>
)
}
@@ -390,7 +280,6 @@ export const NewSessionItem = (props: {
dense?: boolean
sidebarExpanded: Accessor<boolean>
clearHoverProjectSoon: () => void
- setHoverSession: (id: string | undefined) => void
}): JSX.Element => {
const layout = useLayout()
const language = useLanguage()
@@ -400,9 +289,8 @@ export const NewSessionItem = (props: {
<A
href={`/${props.slug}/session`}
end
- class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
+ class={`flex items-center gap-2 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => {
- props.setHoverSession(undefined)
if (layout.sidebar.opened()) return
props.clearHoverProjectSoon()
}}
diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx
index aff0645dd..7c9ae1aaf 100644
--- a/packages/app/src/pages/layout/sidebar-project.tsx
+++ b/packages/app/src/pages/layout/sidebar-project.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js"
+import { createMemo, For, Show, type Accessor, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { base64Encode } from "@opencode-ai/util/encode"
import { Button } from "@opencode-ai/ui/button"
@@ -11,7 +11,7 @@ import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { ProjectIcon, SessionItem, type SessionItemProps } from "./sidebar-items"
-import { childMapByParent, displayName, sortedRootSessions } from "./helpers"
+import { displayName, sortedRootSessions } from "./helpers"
export type ProjectSidebarContext = {
currentDir: Accessor<string>
@@ -19,7 +19,6 @@ export type ProjectSidebarContext = {
sidebarOpened: Accessor<boolean>
sidebarHovering: Accessor<boolean>
hoverProject: Accessor<string | undefined>
- nav: Accessor<HTMLElement | undefined>
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void
@@ -32,8 +31,7 @@ export type ProjectSidebarContext = {
workspacesEnabled: (project: LocalProject) => boolean
workspaceIds: (project: LocalProject) => string[]
workspaceLabel: (directory: string, branch?: string, projectId?: string) => string
- sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "children" | "mobile" | "dense" | "popover">
- setHoverSession: (id: string | undefined) => void
+ sessionProps: Omit<SessionItemProps, "session" | "list" | "slug" | "mobile" | "dense">
}
export const ProjectDragOverlay = (props: {
@@ -55,7 +53,6 @@ export const ProjectDragOverlay = (props: {
const ProjectTile = (props: {
project: LocalProject
mobile?: boolean
- nav: Accessor<HTMLElement | undefined>
sidebarHovering: Accessor<boolean>
selected: Accessor<boolean>
active: Accessor<boolean>
@@ -195,9 +192,7 @@ const ProjectPreviewPanel = (props: {
workspaces: Accessor<string[]>
label: (directory: string) => string
projectSessions: Accessor<ReturnType<typeof sortedRootSessions>>
- projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
- workspaceChildren: (directory: string) => Map<string, string[]>
ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage>
}): JSX.Element => (
@@ -218,9 +213,8 @@ const ProjectPreviewPanel = (props: {
list={props.projectSessions()}
slug={base64Encode(props.project.worktree)}
dense
+ showTooltip
mobile={props.mobile}
- popover={false}
- children={props.projectChildren()}
/>
)}
</For>
@@ -229,7 +223,6 @@ const ProjectPreviewPanel = (props: {
<For each={props.workspaces()}>
{(directory) => {
const sessions = createMemo(() => props.workspaceSessions(directory))
- const children = createMemo(() => props.workspaceChildren(directory))
return (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
@@ -246,9 +239,8 @@ const ProjectPreviewPanel = (props: {
list={sessions()}
slug={base64Encode(directory)}
dense
+ showTooltip
mobile={props.mobile}
- popover={false}
- children={children()}
/>
)}
</For>
@@ -310,20 +302,14 @@ export const SortableProject = (props: {
const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0])
const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()))
- const projectChildren = createMemo(() => childMapByParent(projectStore().session))
const workspaceSessions = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false })
return sortedRootSessions(data, props.sortNow())
}
- const workspaceChildren = (directory: string) => {
- const [data] = globalSync.child(directory, { bootstrap: false })
- return childMapByParent(data.session)
- }
const tile = () => (
<ProjectTile
project={props.project}
mobile={props.mobile}
- nav={props.ctx.nav}
sidebarHovering={props.ctx.sidebarHovering}
selected={selected}
active={active}
@@ -360,7 +346,6 @@ export const SortableProject = (props: {
if (state.menu) return
if (value && state.suppressHover) return
props.ctx.onHoverOpenChanged(props.project.worktree, value)
- if (value) props.ctx.setHoverSession(undefined)
}}
>
<ProjectPreviewPanel
@@ -371,9 +356,7 @@ export const SortableProject = (props: {
workspaces={workspaces}
label={label}
projectSessions={projectSessions}
- projectChildren={projectChildren}
workspaceSessions={workspaceSessions}
- workspaceChildren={workspaceChildren}
ctx={props.ctx}
language={language}
/>
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 3bf00ea42..68e36ff77 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -17,7 +17,7 @@ import { type LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
-import { childMapByParent, sortedRootSessions, workspaceKey } from "./helpers"
+import { sortedRootSessions, workspaceKey } from "./helpers"
type InlineEditorComponent = (props: {
id: string
@@ -35,9 +35,6 @@ export type WorkspaceSidebarContext = {
navList: Accessor<Session[]>
sidebarExpanded: Accessor<boolean>
sidebarHovering: Accessor<boolean>
- nav: Accessor<HTMLElement | undefined>
- hoverSession: Accessor<string | undefined>
- setHoverSession: (id: string | undefined) => void
clearHoverProjectSoon: () => void
prefetchSession: (session: Session, priority?: "high" | "low") => void
archiveSession: (session: Session) => Promise<void>
@@ -152,7 +149,6 @@ const WorkspaceActions = (props: {
showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"]
showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"]
root: string
- setHoverSession: WorkspaceSidebarContext["setHoverSession"]
clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"]
navigateToNewSession: () => void
}): JSX.Element => (
@@ -226,7 +222,6 @@ const WorkspaceActions = (props: {
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
- props.setHoverSession(undefined)
props.clearHoverProjectSoon()
props.navigateToNewSession()
}}
@@ -239,12 +234,10 @@ const WorkspaceActions = (props: {
const WorkspaceSessionList = (props: {
slug: Accessor<string>
mobile?: boolean
- popover?: boolean
ctx: WorkspaceSidebarContext
showNew: Accessor<boolean>
loading: Accessor<boolean>
sessions: Accessor<Session[]>
- children: Accessor<Map<string, string[]>>
hasMore: Accessor<boolean>
loadMore: () => Promise<void>
language: ReturnType<typeof useLanguage>
@@ -256,7 +249,6 @@ const WorkspaceSessionList = (props: {
mobile={props.mobile}
sidebarExpanded={props.ctx.sidebarExpanded}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
- setHoverSession={props.ctx.setHoverSession}
/>
</Show>
<Show when={props.loading()}>
@@ -270,13 +262,8 @@ const WorkspaceSessionList = (props: {
navList={props.ctx.navList}
slug={props.slug()}
mobile={props.mobile}
- popover={props.popover}
- children={props.children()}
+ showChild
sidebarExpanded={props.ctx.sidebarExpanded}
- sidebarHovering={props.ctx.sidebarHovering}
- nav={props.ctx.nav}
- hoverSession={props.ctx.hoverSession}
- setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
prefetchSession={props.ctx.prefetchSession}
archiveSession={props.ctx.archiveSession}
@@ -307,7 +294,6 @@ export const SortableWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
- popover?: boolean
}): JSX.Element => {
const navigate = useNavigate()
const params = useParams()
@@ -321,7 +307,6 @@ export const SortableWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
- const children = createMemo(() => childMapByParent(workspaceStore.session))
const local = createMemo(() => props.directory === props.project.worktree)
const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory))
const workspaceValue = createMemo(() => {
@@ -428,7 +413,6 @@ export const SortableWorkspace = (props: {
showResetWorkspaceDialog={props.ctx.showResetWorkspaceDialog}
showDeleteWorkspaceDialog={props.ctx.showDeleteWorkspaceDialog}
root={props.project.worktree}
- setHoverSession={props.ctx.setHoverSession}
clearHoverProjectSoon={props.ctx.clearHoverProjectSoon}
navigateToNewSession={() => navigate(`/${slug()}/session`)}
/>
@@ -440,12 +424,10 @@ export const SortableWorkspace = (props: {
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
- popover={props.popover}
ctx={props.ctx}
showNew={showNew}
loading={loading}
sessions={sessions}
- children={children}
hasMore={hasMore}
loadMore={loadMore}
language={language}
@@ -461,7 +443,6 @@ export const LocalWorkspace = (props: {
project: LocalProject
sortNow: Accessor<number>
mobile?: boolean
- popover?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync()
const language = useLanguage()
@@ -471,7 +452,6 @@ export const LocalWorkspace = (props: {
})
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
- const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
const count = createMemo(() => sessions()?.length ?? 0)
const loading = createMemo(() => !booted() && count() === 0)
@@ -489,12 +469,10 @@ export const LocalWorkspace = (props: {
<WorkspaceSessionList
slug={slug}
mobile={props.mobile}
- popover={props.popover}
ctx={props.ctx}
showNew={() => false}
loading={loading}
sessions={sessions}
- children={children}
hasMore={hasMore}
loadMore={loadMore}
language={language}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index a81df9dd2..0c6764726 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -429,6 +429,7 @@ export default function Page() {
}
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+ const isChildSession = createMemo(() => !!info()?.parentID)
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const sessionCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length))
const hasSessionReview = createMemo(() => sessionCount() > 0)
@@ -1058,7 +1059,7 @@ export default function Page() {
}
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
- if (composer.blocked()) return
+ if (composer.blocked() || isChildSession()) return
inputRef?.focus()
}
}
@@ -1127,7 +1128,10 @@ export default function Page() {
setFileTreeTab("all")
}
- const focusInput = () => inputRef?.focus()
+ const focusInput = () => {
+ if (isChildSession()) return
+ inputRef?.focus()
+ }
useSessionCommands({
navigateMessageByOffset,
@@ -1658,7 +1662,7 @@ export default function Page() {
const queueEnabled = createMemo(() => {
const id = params.id
if (!id) return false
- return settings.general.followup() === "queue" && busy(id) && !composer.blocked()
+ return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
})
const followupText = (item: FollowupDraft) => {
@@ -1690,6 +1694,7 @@ export default function Page() {
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
+ if (sync.session.get(sessionID)?.parentID) return Promise.resolve()
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
if (!item) return Promise.resolve()
if (followupBusy(sessionID)) return Promise.resolve()
@@ -1820,6 +1825,7 @@ export default function Page() {
if (followupBusy(sessionID)) return
if (followup.failed[sessionID] === item.id) return
if (followup.paused[sessionID]) return
+ if (isChildSession()) return
if (composer.blocked()) return
if (busy(sessionID)) return
@@ -2001,7 +2007,7 @@ export default function Page() {
}}
onResponseSubmit={resumeScroll}
followup={
- params.id
+ params.id && !isChildSession()
? {
queue: queueEnabled,
items: followupDock(),
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx
index 372adef96..60447566e 100644
--- a/packages/app/src/pages/session/composer/session-composer-region.tsx
+++ b/packages/app/src/pages/session/composer/session-composer-region.tsx
@@ -1,9 +1,11 @@
import { Show, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
+import { useNavigate } from "@solidjs/router"
import { useSpring } from "@opencode-ai/ui/motion-spring"
import { PromptInput } from "@/components/prompt-input"
import { useLanguage } from "@/context/language"
import { usePrompt } from "@/context/prompt"
+import { useSync } from "@/context/sync"
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
import { useSessionKey } from "@/pages/session/session-layout"
import { SessionPermissionDock } from "@/pages/session/composer/session-permission-dock"
@@ -43,11 +45,17 @@ export function SessionComposerRegion(props: {
}
setPromptDockRef: (el: HTMLDivElement) => void
}) {
+ const navigate = useNavigate()
const prompt = usePrompt()
const language = useLanguage()
const route = useSessionKey()
+ const sync = useSync()
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
+ const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
+ const parentID = createMemo(() => info()?.parentID)
+ const child = createMemo(() => !!parentID())
+ const showComposer = createMemo(() => !props.state.blocked() || child())
const previewPrompt = () =>
prompt
@@ -113,6 +121,12 @@ export function SessionComposerRegion(props: {
const lift = createMemo(() => (rolled() ? 18 : 36 * value()))
const full = createMemo(() => Math.max(78, store.height))
+ const openParent = () => {
+ const id = parentID()
+ if (!id) return
+ navigate(`/${route.params.dir}/session/${id}`)
+ }
+
createEffect(() => {
const el = store.body
if (!el) return
@@ -156,7 +170,7 @@ export function SessionComposerRegion(props: {
)}
</Show>
- <Show when={!props.state.blocked()}>
+ <Show when={showComposer()}>
<Show
when={prompt.ready()}
fallback={
@@ -232,17 +246,40 @@ export function SessionComposerRegion(props: {
onEdit={props.followup!.onEdit}
/>
</Show>
- <PromptInput
- ref={props.inputRef}
- newSessionWorktree={props.newSessionWorktree}
- onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
- edit={props.followup?.edit}
- onEditLoaded={props.followup?.onEditLoaded}
- shouldQueue={props.followup?.queue}
- onQueue={props.followup?.onQueue}
- onAbort={props.followup?.onAbort}
- onSubmit={props.onSubmit}
- />
+ <Show
+ when={child()}
+ fallback={
+ <Show when={!props.state.blocked()}>
+ <PromptInput
+ ref={props.inputRef}
+ newSessionWorktree={props.newSessionWorktree}
+ onNewSessionWorktreeReset={props.onNewSessionWorktreeReset}
+ edit={props.followup?.edit}
+ onEditLoaded={props.followup?.onEditLoaded}
+ shouldQueue={props.followup?.queue}
+ onQueue={props.followup?.onQueue}
+ onAbort={props.followup?.onAbort}
+ onSubmit={props.onSubmit}
+ />
+ </Show>
+ }
+ >
+ <div
+ ref={props.inputRef}
+ class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
+ >
+ <span>{language.t("session.child.promptDisabled")} </span>
+ <Show when={parentID()}>
+ <button
+ type="button"
+ class="text-text-base transition-colors hover:text-text-strong"
+ onClick={openParent}
+ >
+ {language.t("session.child.backToParent")}
+ </button>
+ </Show>
+ </div>
+ </Show>
</div>
</Show>
</Show>
diff --git a/packages/app/src/pages/session/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx
index bc211303a..fe6447c2e 100644
--- a/packages/app/src/pages/session/message-timeline.tsx
+++ b/packages/app/src/pages/session/message-timeline.tsx
@@ -21,6 +21,7 @@ import { Popover as KobaltePopover } from "@kobalte/core/popover"
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
import { SessionContextUsage } from "@/components/session-context-usage"
import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLanguage } from "@/context/language"
import { useSessionKey } from "@/pages/session/session-layout"
import { useGlobalSDK } from "@/context/global-sdk"
@@ -68,6 +69,16 @@ const messageComments = (parts: Part[]): MessageComment[] =>
]
})
+const taskDescription = (part: Part, sessionID: string) => {
+ if (part.type !== "tool" || part.tool !== "task") return
+ const metadata = "metadata" in part.state ? part.state.metadata : undefined
+ if (metadata?.sessionId !== sessionID) return
+ const value = part.state.input?.description
+ if (typeof value === "string" && value) return value
+}
+
+const pace = (width: number) => Math.round(Math.max(1200, Math.min(3200, (Math.max(width, 360) * 2000) / 900)))
+
const boundaryTarget = (root: HTMLElement, target: EventTarget | null) => {
const current = target instanceof Element ? target : undefined
const nested = current?.closest("[data-scrollable]")
@@ -295,6 +306,32 @@ export function MessageTimeline(props: {
const shareUrl = createMemo(() => info()?.share?.url)
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const parentID = createMemo(() => info()?.parentID)
+ const parent = createMemo(() => {
+ const id = parentID()
+ if (!id) return
+ return sync.session.get(id)
+ })
+ const parentMessages = createMemo(() => {
+ const id = parentID()
+ if (!id) return emptyMessages
+ return sync.data.message[id] ?? emptyMessages
+ })
+ const parentTitle = createMemo(() => sessionTitle(parent()?.title) ?? language.t("command.session.new"))
+ const childTaskDescription = createMemo(() => {
+ const id = sessionID()
+ if (!id) return
+ return parentMessages()
+ .flatMap((message) => sync.data.part[message.id] ?? [])
+ .map((part) => taskDescription(part, id))
+ .findLast((value): value is string => !!value)
+ })
+ const childTitle = createMemo(() => {
+ if (!parentID()) return titleLabel() ?? ""
+ if (childTaskDescription()) return childTaskDescription()
+ const value = titleLabel()?.replace(/\s+\(@[^)]+ subagent\)$/, "")
+ if (value) return value
+ return language.t("command.session.new")
+ })
const showHeader = createMemo(() => !!(titleValue() || parentID()))
const stageCfg = { init: 1, batch: 3 }
const staging = createTimelineStaging({
@@ -317,8 +354,20 @@ export function MessageTimeline(props: {
open: false,
dismiss: null as "escape" | "outside" | null,
})
+ const [bar, setBar] = createStore({
+ ms: pace(640),
+ })
let more: HTMLButtonElement | undefined
+ let head: HTMLDivElement | undefined
+
+ createResizeObserver(
+ () => head,
+ () => {
+ if (!head || head.clientWidth <= 0) return
+ setBar("ms", pace(head.clientWidth))
+ },
+ )
const viewShare = () => {
const url = shareUrl()
@@ -398,8 +447,20 @@ export function MessageTimeline(props: {
),
)
+ createEffect(
+ on(
+ () => [parentID(), childTaskDescription()] as const,
+ ([id, description]) => {
+ if (!id || description) return
+ if (sync.data.message[id] !== undefined) return
+ void sync.session.sync(id)
+ },
+ { defer: true },
+ ),
+ )
+
const openTitleEditor = () => {
- if (!sessionID()) return
+ if (!sessionID() || parentID()) return
setTitle({ editing: true, draft: titleLabel() ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
@@ -646,27 +707,53 @@ export function MessageTimeline(props: {
<div ref={props.setContentRef} class="min-w-0 w-full">
<Show when={showHeader()}>
<div
+ ref={(el) => {
+ head = el
+ setBar("ms", pace(el.clientWidth))
+ }}
data-session-title
classList={{
"sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--background-stronger)_48px,transparent)]": true,
+ relative: true,
"w-full": true,
"pb-4": true,
"pl-2 pr-3 md:pl-4 md:pr-3": true,
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered,
}}
>
+ <Show when={workingStatus() !== "hidden"}>
+ <div
+ data-component="session-progress"
+ data-state={workingStatus()}
+ aria-hidden="true"
+ style={{
+ "--session-progress-color": tint() ?? "var(--icon-interactive-base)",
+ "--session-progress-ms": `${bar.ms}ms`,
+ }}
+ >
+ <div data-component="session-progress-bar" />
+ </div>
+ </Show>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1 pr-3">
- <Show when={parentID()}>
- <IconButton
- tabIndex={-1}
- icon="arrow-left"
- variant="ghost"
- onClick={navigateParent}
- aria-label={language.t("common.goBack")}
- />
- </Show>
<div class="flex items-center min-w-0 grow-1">
+ <Show when={parentID()}>
+ <button
+ type="button"
+ data-slot="session-title-parent"
+ class="min-w-0 max-w-[40%] truncate text-14-medium text-text-weak transition-colors hover:text-text-base"
+ onClick={navigateParent}
+ >
+ {parentTitle()}
+ </button>
+ <span
+ data-slot="session-title-separator"
+ class="px-2 text-14-medium text-text-weak"
+ aria-hidden="true"
+ >
+ /
+ </span>
+ </Show>
<div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{
@@ -684,15 +771,16 @@ export function MessageTimeline(props: {
</div>
</Show>
</div>
- <Show when={titleLabel() || title.editing}>
+ <Show when={childTitle() || title.editing}>
<Show
when={title.editing}
fallback={
<h1
+ data-slot="session-title-child"
class="text-14-medium text-text-strong truncate grow-1 min-w-0"
onDblClick={openTitleEditor}
>
- {titleLabel()}
+ {childTitle()}
</h1>
}
>
@@ -700,6 +788,7 @@ export function MessageTimeline(props: {
ref={(el) => {
titleRef = el
}}
+ data-slot="session-title-child"
value={title.draft}
disabled={titleMutation.isPending}
class="text-14-medium text-text-strong grow-1 min-w-0 rounded-[6px]"
@@ -727,177 +816,179 @@ export function MessageTimeline(props: {
{(id) => (
<div class="shrink-0 flex items-center gap-3">
<SessionContextUsage placement="bottom" />
- <DropdownMenu
- gutter={4}
- placement="bottom-end"
- open={title.menuOpen}
- onOpenChange={(open) => {
- setTitle("menuOpen", open)
- if (open) return
- }}
- >
- <DropdownMenu.Trigger
- as={IconButton}
- icon="dot-grid"
- variant="ghost"
- class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
- classList={{
- "bg-surface-base-active": share.open || title.pendingShare,
+ <Show when={!parentID()}>
+ <DropdownMenu
+ gutter={4}
+ placement="bottom-end"
+ open={title.menuOpen}
+ onOpenChange={(open) => {
+ setTitle("menuOpen", open)
+ if (open) return
}}
- aria-label={language.t("common.moreOptions")}
- aria-expanded={title.menuOpen || share.open || title.pendingShare}
- ref={(el: HTMLButtonElement) => {
- more = el
- }}
- />
- <DropdownMenu.Portal>
- <DropdownMenu.Content
- style={{ "min-width": "104px" }}
- onCloseAutoFocus={(event) => {
- if (title.pendingRename) {
- event.preventDefault()
- setTitle("pendingRename", false)
- openTitleEditor()
- return
- }
- if (title.pendingShare) {
- event.preventDefault()
- requestAnimationFrame(() => {
- setShare({ open: true, dismiss: null })
- setTitle("pendingShare", false)
- })
- }
+ >
+ <DropdownMenu.Trigger
+ as={IconButton}
+ icon="dot-grid"
+ variant="ghost"
+ class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
+ classList={{
+ "bg-surface-base-active": share.open || title.pendingShare,
}}
- >
- <DropdownMenu.Item
- onSelect={() => {
- setTitle("pendingRename", true)
- setTitle("menuOpen", false)
+ aria-label={language.t("common.moreOptions")}
+ aria-expanded={title.menuOpen || share.open || title.pendingShare}
+ ref={(el: HTMLButtonElement) => {
+ more = el
+ }}
+ />
+ <DropdownMenu.Portal>
+ <DropdownMenu.Content
+ style={{ "min-width": "104px" }}
+ onCloseAutoFocus={(event) => {
+ if (title.pendingRename) {
+ event.preventDefault()
+ setTitle("pendingRename", false)
+ openTitleEditor()
+ return
+ }
+ if (title.pendingShare) {
+ event.preventDefault()
+ requestAnimationFrame(() => {
+ setShare({ open: true, dismiss: null })
+ setTitle("pendingShare", false)
+ })
+ }
}}
>
- <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <Show when={shareEnabled()}>
<DropdownMenu.Item
onSelect={() => {
- setTitle({ pendingShare: true, menuOpen: false })
+ setTitle("pendingRename", true)
+ setTitle("menuOpen", false)
}}
>
- <DropdownMenu.ItemLabel>
- {language.t("session.share.action.share")}
- </DropdownMenu.ItemLabel>
+ <DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <Show when={shareEnabled()}>
+ <DropdownMenu.Item
+ onSelect={() => {
+ setTitle({ pendingShare: true, menuOpen: false })
+ }}
+ >
+ <DropdownMenu.ItemLabel>
+ {language.t("session.share.action.share")}
+ </DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ </Show>
+ <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
+ <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
+ </DropdownMenu.Item>
+ <DropdownMenu.Separator />
+ <DropdownMenu.Item
+ onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
+ >
+ <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
- </Show>
- <DropdownMenu.Item onSelect={() => void archiveSession(id())}>
- <DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- <DropdownMenu.Separator />
- <DropdownMenu.Item
- onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
+ </DropdownMenu.Content>
+ </DropdownMenu.Portal>
+ </DropdownMenu>
+
+ <KobaltePopover
+ open={share.open}
+ anchorRef={() => more}
+ placement="bottom-end"
+ gutter={4}
+ modal={false}
+ onOpenChange={(open) => {
+ if (open) setShare("dismiss", null)
+ setShare("open", open)
+ }}
+ >
+ <KobaltePopover.Portal>
+ <KobaltePopover.Content
+ data-component="popover-content"
+ style={{ "min-width": "320px" }}
+ onEscapeKeyDown={(event) => {
+ setShare({ dismiss: "escape", open: false })
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ onPointerDownOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onFocusOutside={() => {
+ setShare({ dismiss: "outside", open: false })
+ }}
+ onCloseAutoFocus={(event) => {
+ if (share.dismiss === "outside") event.preventDefault()
+ setShare("dismiss", null)
+ }}
>
- <DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
- </DropdownMenu.Item>
- </DropdownMenu.Content>
- </DropdownMenu.Portal>
- </DropdownMenu>
-
- <KobaltePopover
- open={share.open}
- anchorRef={() => more}
- placement="bottom-end"
- gutter={4}
- modal={false}
- onOpenChange={(open) => {
- if (open) setShare("dismiss", null)
- setShare("open", open)
- }}
- >
- <KobaltePopover.Portal>
- <KobaltePopover.Content
- data-component="popover-content"
- style={{ "min-width": "320px" }}
- onEscapeKeyDown={(event) => {
- setShare({ dismiss: "escape", open: false })
- event.preventDefault()
- event.stopPropagation()
- }}
- onPointerDownOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onFocusOutside={() => {
- setShare({ dismiss: "outside", open: false })
- }}
- onCloseAutoFocus={(event) => {
- if (share.dismiss === "outside") event.preventDefault()
- setShare("dismiss", null)
- }}
- >
- <div class="flex flex-col p-3">
- <div class="flex flex-col gap-1">
- <div class="text-13-medium text-text-strong">
- {language.t("session.share.popover.title")}
- </div>
- <div class="text-12-regular text-text-weak">
- {shareUrl()
- ? language.t("session.share.popover.description.shared")
- : language.t("session.share.popover.description.unshared")}
+ <div class="flex flex-col p-3">
+ <div class="flex flex-col gap-1">
+ <div class="text-13-medium text-text-strong">
+ {language.t("session.share.popover.title")}
+ </div>
+ <div class="text-12-regular text-text-weak">
+ {shareUrl()
+ ? language.t("session.share.popover.description.shared")
+ : language.t("session.share.popover.description.unshared")}
+ </div>
</div>
- </div>
- <div class="mt-3 flex flex-col gap-2">
- <Show
- when={shareUrl()}
- fallback={
- <Button
- size="large"
- variant="primary"
- class="w-full"
- onClick={shareSession}
- disabled={shareMutation.isPending}
- >
- {shareMutation.isPending
- ? language.t("session.share.action.publishing")
- : language.t("session.share.action.publish")}
- </Button>
- }
- >
- <div class="flex flex-col gap-2">
- <TextField
- value={shareUrl() ?? ""}
- readOnly
- copyable
- copyKind="link"
- tabIndex={-1}
- class="w-full"
- />
- <div class="grid grid-cols-2 gap-2">
- <Button
- size="large"
- variant="secondary"
- class="w-full shadow-none border border-border-weak-base"
- onClick={unshareSession}
- disabled={unshareMutation.isPending}
- >
- {unshareMutation.isPending
- ? language.t("session.share.action.unpublishing")
- : language.t("session.share.action.unpublish")}
- </Button>
+ <div class="mt-3 flex flex-col gap-2">
+ <Show
+ when={shareUrl()}
+ fallback={
<Button
size="large"
variant="primary"
class="w-full"
- onClick={viewShare}
- disabled={unshareMutation.isPending}
+ onClick={shareSession}
+ disabled={shareMutation.isPending}
>
- {language.t("session.share.action.view")}
+ {shareMutation.isPending
+ ? language.t("session.share.action.publishing")
+ : language.t("session.share.action.publish")}
</Button>
+ }
+ >
+ <div class="flex flex-col gap-2">
+ <TextField
+ value={shareUrl() ?? ""}
+ readOnly
+ copyable
+ copyKind="link"
+ tabIndex={-1}
+ class="w-full"
+ />
+ <div class="grid grid-cols-2 gap-2">
+ <Button
+ size="large"
+ variant="secondary"
+ class="w-full shadow-none border border-border-weak-base"
+ onClick={unshareSession}
+ disabled={unshareMutation.isPending}
+ >
+ {unshareMutation.isPending
+ ? language.t("session.share.action.unpublishing")
+ : language.t("session.share.action.unpublish")}
+ </Button>
+ <Button
+ size="large"
+ variant="primary"
+ class="w-full"
+ onClick={viewShare}
+ disabled={unshareMutation.isPending}
+ >
+ {language.t("session.share.action.view")}
+ </Button>
+ </div>
</div>
- </div>
- </Show>
+ </Show>
+ </div>
</div>
- </div>
- </KobaltePopover.Content>
- </KobaltePopover.Portal>
- </KobaltePopover>
+ </KobaltePopover.Content>
+ </KobaltePopover.Portal>
+ </KobaltePopover>
+ </Show>
</div>
)}
</Show>