Procházet zdrojové kódy

feat(app): better subagent experience (#20708)

Adam před 1 týdnem
rodič
revize
ec8b9810b4

+ 23 - 6
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 {

+ 2 - 0
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?",

+ 40 - 0
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;

+ 0 - 17
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>

+ 14 - 0
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")

+ 10 - 11
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 }) =>

+ 92 - 204
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()
       }}

+ 5 - 22
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}
           />

+ 2 - 24
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}

+ 10 - 4
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(),

+ 49 - 12
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>

+ 255 - 164
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>

+ 22 - 1
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(

+ 92 - 0
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;
+    }
+  }
+}

+ 86 - 56
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}

+ 5 - 0
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;

+ 120 - 30
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}
+      />
+    )
   },
 })
 

+ 4 - 0
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: {