diff options
| author | Adam <[email protected]> | 2026-04-07 11:06:23 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-07 11:06:23 -0500 |
| commit | ec8b9810b4231cd6a5c69ccd930b6c50999fc997 (patch) | |
| tree | 562313d6dd3eda9891f3a4a3a2ef6ce3d36acd05 | |
| parent | 65318a80f7a3320ba77b749241f8de997dc65c82 (diff) | |
| download | opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip | |
feat(app): better subagent experience (#20708)
| -rw-r--r-- | packages/app/e2e/session/session-child-navigation.spec.ts | 29 | ||||
| -rw-r--r-- | packages/app/src/i18n/en.ts | 2 | ||||
| -rw-r--r-- | packages/app/src/index.css | 40 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 17 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.test.ts | 14 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/helpers.ts | 21 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-items.tsx | 296 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-project.tsx | 27 | ||||
| -rw-r--r-- | packages/app/src/pages/layout/sidebar-workspace.tsx | 26 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 14 | ||||
| -rw-r--r-- | packages/app/src/pages/session/composer/session-composer-region.tsx | 61 | ||||
| -rw-r--r-- | packages/app/src/pages/session/message-timeline.tsx | 419 | ||||
| -rw-r--r-- | packages/app/src/utils/agent.ts | 23 | ||||
| -rw-r--r-- | packages/ui/src/components/basic-tool.css | 92 | ||||
| -rw-r--r-- | packages/ui/src/components/basic-tool.tsx | 142 | ||||
| -rw-r--r-- | packages/ui/src/components/collapsible.css | 5 | ||||
| -rw-r--r-- | packages/ui/src/components/message-part.tsx | 150 | ||||
| -rw-r--r-- | packages/ui/src/context/data.tsx | 4 |
18 files changed, 831 insertions, 551 deletions
diff --git a/packages/app/e2e/session/session-child-navigation.spec.ts b/packages/app/e2e/session/session-child-navigation.spec.ts index 34a1a9e2e..c9fad1af8 100644 --- a/packages/app/e2e/session/session-child-navigation.spec.ts +++ b/packages/app/e2e/session/session-child-navigation.spec.ts @@ -1,7 +1,6 @@ import { seedSessionTask, withSession } from "../actions" import { test, expect } from "../fixtures" import { inputMatch } from "../prompt/mock" -import { promptSelector } from "../selectors" test("task tool child-session link does not trigger stale show errors", async ({ page, llm, project }) => { test.setTimeout(120_000) @@ -30,15 +29,33 @@ test("task tool child-session link does not trigger stale show errors", async ({ await project.gotoSession(session.id) - const link = page - .locator("a.subagent-link") + const header = page.locator("[data-session-title]") + await expect(header.getByRole("button", { name: "More options" })).toBeVisible({ timeout: 30_000 }) + + const card = page + .locator('[data-component="task-tool-card"]') .filter({ hasText: /open child session/i }) .first() - await expect(link).toBeVisible({ timeout: 30_000 }) - await link.click() + await expect(card).toBeVisible({ timeout: 30_000 }) + await card.click() await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) - await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 }) + await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(session.title) + await expect(header.locator('[data-slot="session-title-child"]')).toHaveText(taskInput.description) + await expect(header.locator('[data-slot="session-title-separator"]')).toHaveText("/") + await expect + .poll( + () => + header.locator('[data-slot="session-title-separator"]').evaluate((el) => ({ + left: getComputedStyle(el).paddingLeft, + right: getComputedStyle(el).paddingRight, + })), + { timeout: 30_000 }, + ) + .toEqual({ left: "8px", right: "8px" }) + await expect(header.getByRole("button", { name: "More options" })).toHaveCount(0) + await expect(page.getByText("Subagent sessions cannot be prompted.")).toBeVisible({ timeout: 30_000 }) + await expect(page.getByRole("button", { name: "Back to main session." })).toBeVisible({ timeout: 30_000 }) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([]) }) } finally { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index ace0efeb8..c6bcc37b1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -238,6 +238,8 @@ export const dict = { "prompt.mode.shell": "Shell", "prompt.mode.normal": "Prompt", "prompt.mode.shell.exit": "esc to exit", + "session.child.promptDisabled": "Subagent sessions cannot be prompted.", + "session.child.backToParent": "Back to main session.", "prompt.example.1": "Fix a TODO in the codebase", "prompt.example.2": "What is the tech stack of this project?", diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 9e231e2d2..629ac80a8 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -1,6 +1,46 @@ @import "@opencode-ai/ui/styles/tailwind"; @layer components { + @keyframes session-progress-whip { + 0% { + clip-path: inset(0 100% 0 0 round 999px); + animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1); + } + + 48% { + clip-path: inset(0 0 0 0 round 999px); + animation-timing-function: cubic-bezier(0.65, 0, 0.35, 1); + } + + 100% { + clip-path: inset(0 0 0 100% round 999px); + } + } + + [data-component="session-progress"] { + position: absolute; + inset: 0 0 auto; + height: 2px; + overflow: hidden; + pointer-events: none; + opacity: 1; + transition: opacity 220ms ease-out; + } + + [data-component="session-progress"][data-state="hiding"] { + opacity: 0; + } + + [data-component="session-progress-bar"] { + width: 100%; + height: 100%; + border-radius: 999px; + background: var(--session-progress-color); + clip-path: inset(0 100% 0 0 round 999px); + animation: session-progress-whip var(--session-progress-ms, 1800ms) infinite; + will-change: clip-path; + } + [data-component="getting-started"] { container-type: inline-size; container-name: getting-started; 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> diff --git a/packages/app/src/utils/agent.ts b/packages/app/src/utils/agent.ts index 390932a13..59da53af1 100644 --- a/packages/app/src/utils/agent.ts +++ b/packages/app/src/utils/agent.ts @@ -5,9 +5,30 @@ const defaults: Record<string, string> = { plan: "var(--icon-agent-plan-base)", } +const palette = [ + "var(--icon-agent-ask-base)", + "var(--icon-agent-build-base)", + "var(--icon-agent-docs-base)", + "var(--icon-agent-plan-base)", + "var(--syntax-info)", + "var(--syntax-success)", + "var(--syntax-warning)", + "var(--syntax-property)", + "var(--syntax-constant)", + "var(--text-diff-add-base)", + "var(--text-diff-delete-base)", + "var(--icon-warning-base)", +] + +function tone(name: string) { + let hash = 0 + for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + return palette[hash % palette.length] +} + export function agentColor(name: string, custom?: string) { if (custom) return custom - return defaults[name] ?? defaults[name.toLowerCase()] + return defaults[name] ?? defaults[name.toLowerCase()] ?? tone(name.toLowerCase()) } export function messageAgentColor( diff --git a/packages/ui/src/components/basic-tool.css b/packages/ui/src/components/basic-tool.css index f52a5e576..198412dcb 100644 --- a/packages/ui/src/components/basic-tool.css +++ b/packages/ui/src/components/basic-tool.css @@ -7,6 +7,21 @@ gap: 0px; justify-content: flex-start; + &[data-clickable="true"] { + cursor: pointer; + } + + &[data-hide-details="true"] { + [data-slot="basic-tool-tool-trigger-content"] { + flex: 1 1 auto; + max-width: 100%; + } + + [data-slot="basic-tool-tool-info"] { + flex: 1 1 auto; + } + } + [data-slot="basic-tool-tool-trigger-content"] { flex: 0 1 auto; width: auto; @@ -165,3 +180,80 @@ flex-shrink: 0; } } + +[data-component="task-tool-card"] { + width: 100%; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-base) 92%, transparent); + transition: + border-color 0.15s ease, + background-color 0.15s ease, + color 0.15s ease; + + [data-slot="basic-tool-tool-info-structured"] { + flex: 1 1 auto; + min-width: 0; + } + + [data-slot="basic-tool-tool-info-main"] { + flex: 1 1 auto; + min-width: 0; + align-items: center; + } + + [data-component="task-tool-spinner"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + [data-component="spinner"] { + width: 16px; + height: 16px; + } + } + + [data-component="task-tool-action"] { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--icon-weak); + margin-left: auto; + opacity: 0; + transition: + opacity 0.15s ease, + color 0.15s ease; + } + + [data-component="task-tool-title"] { + flex-shrink: 0; + font-family: var(--font-family-sans); + font-size: 14px; + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + text-transform: capitalize; + } + + [data-slot="basic-tool-tool-subtitle"] { + color: var(--text-strong); + } + + &:hover, + &:focus-visible { + border-color: var(--border-weak-base, rgba(255, 255, 255, 0.08)); + background: color-mix(in srgb, var(--background-stronger) 88%, transparent); + + [data-component="task-tool-action"] { + opacity: 1; + } + } +} diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index a02fe941b..7d18dfacd 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -34,6 +34,9 @@ export interface BasicToolProps { locked?: boolean animated?: boolean onSubtitleClick?: () => void + onTriggerClick?: JSX.EventHandlerUnion<HTMLElement, MouseEvent> + triggerHref?: string + clickable?: boolean } const SPRING = { type: "spring" as const, visualDuration: 0.35, bounce: 0 } @@ -121,74 +124,101 @@ export function BasicTool(props: BasicToolProps) { setState("open", value) } - return ( - <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible"> - <Collapsible.Trigger> - <div data-component="tool-trigger"> - <div data-slot="basic-tool-tool-trigger-content"> - <div data-slot="basic-tool-tool-info"> - <Switch> - <Match when={isTriggerTitle(props.trigger) && props.trigger}> - {(trigger) => ( - <div data-slot="basic-tool-tool-info-structured"> - <div data-slot="basic-tool-tool-info-main"> + const trigger = () => ( + <div + data-component="tool-trigger" + data-clickable={props.clickable ? "true" : undefined} + data-hide-details={props.hideDetails ? "true" : undefined} + > + <div data-slot="basic-tool-tool-trigger-content"> + <div data-slot="basic-tool-tool-info"> + <Switch> + <Match when={isTriggerTitle(props.trigger) && props.trigger}> + {(title) => ( + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <span + data-slot="basic-tool-tool-title" + classList={{ + [title().titleClass ?? ""]: !!title().titleClass, + }} + > + <TextShimmer text={title().title} active={pending()} /> + </span> + <Show when={!pending()}> + <Show when={title().subtitle}> <span - data-slot="basic-tool-tool-title" + data-slot="basic-tool-tool-subtitle" classList={{ - [trigger().titleClass ?? ""]: !!trigger().titleClass, + [title().subtitleClass ?? ""]: !!title().subtitleClass, + clickable: !!props.onSubtitleClick, + }} + onClick={(e) => { + if (props.onSubtitleClick) { + e.stopPropagation() + props.onSubtitleClick() + } }} > - <TextShimmer text={trigger().title} active={pending()} /> + {title().subtitle} </span> - <Show when={!pending()}> - <Show when={trigger().subtitle}> + </Show> + <Show when={title().args?.length}> + <For each={title().args}> + {(arg) => ( <span - data-slot="basic-tool-tool-subtitle" + data-slot="basic-tool-tool-arg" classList={{ - [trigger().subtitleClass ?? ""]: !!trigger().subtitleClass, - clickable: !!props.onSubtitleClick, - }} - onClick={(e) => { - if (props.onSubtitleClick) { - e.stopPropagation() - props.onSubtitleClick() - } + [title().argsClass ?? ""]: !!title().argsClass, }} > - {trigger().subtitle} + {arg} </span> - </Show> - <Show when={trigger().args?.length}> - <For each={trigger().args}> - {(arg) => ( - <span - data-slot="basic-tool-tool-arg" - classList={{ - [trigger().argsClass ?? ""]: !!trigger().argsClass, - }} - > - {arg} - </span> - )} - </For> - </Show> - </Show> - </div> - <Show when={!pending() && trigger().action}> - <span data-slot="basic-tool-tool-action">{trigger().action}</span> + )} + </For> </Show> - </div> - )} - </Match> - <Match when={true}>{props.trigger as JSX.Element}</Match> - </Switch> - </div> - </div> - <Show when={props.children && !props.hideDetails && !props.locked && !pending()}> - <Collapsible.Arrow /> - </Show> + </Show> + </div> + <Show when={!pending() && title().action}> + <span data-slot="basic-tool-tool-action">{title().action}</span> + </Show> + </div> + )} + </Match> + <Match when={true}>{props.trigger as JSX.Element}</Match> + </Switch> </div> - </Collapsible.Trigger> + </div> + <Show when={props.children && !props.hideDetails && !props.locked && !pending()}> + <Collapsible.Arrow /> + </Show> + </div> + ) + + return ( + <Collapsible open={open()} onOpenChange={handleOpenChange} class="tool-collapsible"> + <Show + when={props.triggerHref} + fallback={ + <Collapsible.Trigger + data-hide-details={props.hideDetails ? "true" : undefined} + onClick={props.onTriggerClick} + > + {trigger()} + </Collapsible.Trigger> + } + > + {(href) => ( + <Collapsible.Trigger + as="a" + href={href()} + data-hide-details={props.hideDetails ? "true" : undefined} + onClick={props.onTriggerClick} + > + {trigger()} + </Collapsible.Trigger> + )} + </Show> <Show when={props.animated && props.children && !props.hideDetails}> <div ref={contentRef} diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index a999f6298..608ca6e0e 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -62,6 +62,11 @@ cursor: not-allowed; } + &[data-hide-details="true"] { + height: auto; + align-items: stretch; + } + [data-slot="collapsible-arrow"] { flex-shrink: 0; width: 24px; diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 03477e5a7..3627eca40 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -22,6 +22,7 @@ import { Message as MessageType, Part as PartType, ReasoningPart, + Session, TextPart, ToolPart, UserMessage, @@ -49,6 +50,7 @@ import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/pa import { checksum } from "@opencode-ai/util/encode" import { Tooltip } from "./tooltip" import { IconButton } from "./icon-button" +import { Spinner } from "./spinner" import { TextShimmer } from "./text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" @@ -274,6 +276,47 @@ function agentTitle(i18n: UiI18n, type?: string) { return i18n.t("ui.tool.agent", { type }) } +const agentTones: Record<string, string> = { + ask: "var(--icon-agent-ask-base)", + build: "var(--icon-agent-build-base)", + docs: "var(--icon-agent-docs-base)", + plan: "var(--icon-agent-plan-base)", +} + +const agentPalette = [ + "var(--icon-agent-ask-base)", + "var(--icon-agent-build-base)", + "var(--icon-agent-docs-base)", + "var(--icon-agent-plan-base)", + "var(--syntax-info)", + "var(--syntax-success)", + "var(--syntax-warning)", + "var(--syntax-property)", + "var(--syntax-constant)", + "var(--text-diff-add-base)", + "var(--text-diff-delete-base)", + "var(--icon-warning-base)", +] + +function tone(name: string) { + let hash = 0 + for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) >>> 0 + return agentPalette[hash % agentPalette.length] +} + +function taskAgent( + raw: unknown, + list?: readonly { name: string; color?: string }[], +): { name?: string; color?: string } { + if (typeof raw !== "string" || !raw) return {} + const key = raw.toLowerCase() + const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key) + return { + name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`, + color: item?.color ?? agentTones[key] ?? tone(key), + } +} + export function getToolInfo(tool: string, input: any = {}): ToolInfo { const i18n = useI18n() switch (tool) { @@ -402,6 +445,27 @@ function sessionLink(id: string | undefined, path: string, href?: (id: string) = return `${path.slice(0, idx)}/session/${id}` } +function currentSession(path: string) { + return path.match(/\/session\/([^/?#]+)/)?.[1] +} + +function taskSession( + input: Record<string, any>, + path: string, + sessions: Session[] | undefined, + agents?: readonly { name: string; color?: string }[], +) { + const parentID = currentSession(path) + if (!parentID) return + const description = typeof input.description === "string" ? input.description : "" + const agent = taskAgent(input.subagent_type, agents).name + return (sessions ?? []) + .filter((session) => session.parentID === parentID && !session.time?.archived) + .filter((session) => (description ? session.title.startsWith(description) : true)) + .filter((session) => (agent ? session.title.includes(`@${agent}`) : true)) + .sort((a, b) => (b.time.created ?? 0) - (a.time.created ?? 0))[0]?.id +} + const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"]) const HIDDEN_TOOLS = new Set(["todowrite"]) @@ -1678,13 +1742,14 @@ ToolRegistry.register({ const data = useData() const i18n = useI18n() const location = useLocation() - const childSessionId = () => props.metadata.sessionId as string | undefined - const type = createMemo(() => { - const raw = props.input.subagent_type - if (typeof raw !== "string" || !raw) return undefined - return raw[0]!.toUpperCase() + raw.slice(1) + const childSessionId = createMemo(() => { + const value = props.metadata.sessionId + if (typeof value === "string" && value) return value + return taskSession(props.input, location.pathname, data.store.session, data.store.agent) }) - const title = createMemo(() => agentTitle(i18n, type())) + const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) + const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) + const tone = createMemo(() => agent().color) const subtitle = createMemo(() => { const value = props.input.description if (typeof value === "string" && value) return value @@ -1693,37 +1758,62 @@ ToolRegistry.register({ const running = createMemo(() => props.status === "pending" || props.status === "running") const href = createMemo(() => sessionLink(childSessionId(), location.pathname, data.sessionHref)) + const clickable = createMemo(() => !!(childSessionId() && (data.navigateToSession || href()))) + + const open = () => { + const id = childSessionId() + if (!id) return + if (data.navigateToSession) { + data.navigateToSession(id) + return + } + const value = href() + if (value) window.location.assign(value) + } - const titleContent = () => <TextShimmer text={title()} active={running()} /> + const navigate = (event: MouseEvent) => { + if (!data.navigateToSession) return + if (event.button !== 0 || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return + event.preventDefault() + open() + } const trigger = () => ( - <div data-slot="basic-tool-tool-info-structured"> - <div data-slot="basic-tool-tool-info-main"> - <span data-slot="basic-tool-tool-title" class="capitalize agent-title"> - {titleContent()} - </span> - <Show when={subtitle()}> - <Switch> - <Match when={href()}> - <a - data-slot="basic-tool-tool-subtitle" - class="clickable subagent-link" - href={href()!} - onClick={(e) => e.stopPropagation()} - > - {subtitle()} - </a> - </Match> - <Match when={true}> - <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span> - </Match> - </Switch> - </Show> + <div data-component="task-tool-card"> + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <Show when={running()}> + <span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}> + <Spinner /> + </span> + </Show> + <span data-component="task-tool-title" style={{ color: tone() ?? "var(--text-strong)" }}> + {title()} + </span> + <Show when={subtitle()}> + <span data-slot="basic-tool-tool-subtitle">{subtitle()}</span> + </Show> + </div> </div> + <Show when={clickable()}> + <div data-component="task-tool-action"> + <Icon name="square-arrow-top-right" size="small" /> + </div> + </Show> </div> ) - return <BasicTool icon="task" status={props.status} trigger={trigger()} hideDetails /> + return ( + <BasicTool + icon="task" + status={props.status} + trigger={trigger()} + hideDetails + triggerHref={href()} + clickable={clickable()} + onTriggerClick={navigate} + /> + ) }, }) diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index e116199eb..93368c2a0 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -3,6 +3,10 @@ import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" type Data = { + agent?: { + name: string + color?: string + }[] provider?: ProviderListResponse session: Session[] session_status: { |
