summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-04-07 11:06:23 -0500
committerGitHub <[email protected]>2026-04-07 11:06:23 -0500
commitec8b9810b4231cd6a5c69ccd930b6c50999fc997 (patch)
tree562313d6dd3eda9891f3a4a3a2ef6ce3d36acd05
parent65318a80f7a3320ba77b749241f8de997dc65c82 (diff)
downloadopencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.tar.gz
opencode-ec8b9810b4231cd6a5c69ccd930b6c50999fc997.zip
feat(app): better subagent experience (#20708)
-rw-r--r--packages/app/e2e/session/session-child-navigation.spec.ts29
-rw-r--r--packages/app/src/i18n/en.ts2
-rw-r--r--packages/app/src/index.css40
-rw-r--r--packages/app/src/pages/layout.tsx17
-rw-r--r--packages/app/src/pages/layout/helpers.test.ts14
-rw-r--r--packages/app/src/pages/layout/helpers.ts21
-rw-r--r--packages/app/src/pages/layout/sidebar-items.tsx296
-rw-r--r--packages/app/src/pages/layout/sidebar-project.tsx27
-rw-r--r--packages/app/src/pages/layout/sidebar-workspace.tsx26
-rw-r--r--packages/app/src/pages/session.tsx14
-rw-r--r--packages/app/src/pages/session/composer/session-composer-region.tsx61
-rw-r--r--packages/app/src/pages/session/message-timeline.tsx419
-rw-r--r--packages/app/src/utils/agent.ts23
-rw-r--r--packages/ui/src/components/basic-tool.css92
-rw-r--r--packages/ui/src/components/basic-tool.tsx142
-rw-r--r--packages/ui/src/components/collapsible.css5
-rw-r--r--packages/ui/src/components/message-part.tsx150
-rw-r--r--packages/ui/src/context/data.tsx4
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: {