diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 513cc37867..024e5edd59 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -2,7 +2,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { Event } from "@opencode-ai/schema/event" import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event" import { SessionV1 } from "@opencode-ai/schema/session-v1" -import type { SessionInfo, SessionStatus } from "@opencode-ai/client/promise" +import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types" import { expect, type Page } from "@playwright/test" import { Schema } from "effect" @@ -90,6 +90,8 @@ export async function setupTimeline( page: Page, input: { messages?: TimelineMessage[] + currentMessages?: SessionMessageInfo[] + sessionStatus?: Record settings?: Record sessions?: Session[] cpuRate?: number @@ -102,13 +104,22 @@ export async function setupTimeline( } = {}, ) { const sessions = input.sessions ?? [session()] - const messages = validateTimelineMessages([ - ...(input.seedHistory ? historyMessages(18) : []), - ...(input.messages ?? [userMessage(), assistantMessage()]), - ]) - const active = messages.findLast((message) => message.info.role === "assistant") + const messages = + input.currentMessages ?? + validateTimelineMessages([ + ...(input.seedHistory ? historyMessages(18) : []), + ...(input.messages ?? [userMessage(), assistantMessage()]), + ]) + const active = messages.findLast((message) => + "info" in message ? message.info.role === "assistant" : message.type === "assistant", + ) const initialStatus = decodeStatus( - active?.info.role === "assistant" && active.info.time.completed === undefined ? { type: "busy" } : { type: "idle" }, + active && + ("info" in active + ? active.info.role === "assistant" && active.info.time.completed === undefined + : active.type === "assistant" && active.time.completed === undefined) + ? { type: "busy" } + : { type: "idle" }, decodeOptions, ) const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` @@ -122,7 +133,7 @@ export async function setupTimeline( project: project(), provider: provider(), sessions, - sessionStatus: { [sessionID]: initialStatus }, + sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus }, pageMessages: () => ({ items: messages, }), diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts new file mode 100644 index 0000000000..7dc138d0ba --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -0,0 +1,182 @@ +import { expect, test } from "@playwright/test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { session, sessionID, setupTimeline } from "../performance/timeline-stability/fixture" + +const user = { id: "msg_user", type: "user", text: "Run it", time: { created: 1 } } satisfies SessionMessageInfo + +const assistant = (completed: boolean, tool = false, childID?: string) => + ({ + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: tool + ? [ + { + type: "tool", + id: "call_subagent", + name: "subagent", + state: { status: "running", input: {}, metadata: childID ? { sessionID: childID } : {} }, + time: { created: 2 }, + }, + ] + : [{ type: "text", text: "Working" }], + time: { created: 2, ...(completed ? { completed: 3 } : {}) }, + }) satisfies SessionMessageInfo + +test("renders current protocol notices in CLI order", async ({ page }) => { + await setupTimeline(page, { + currentMessages: [ + user, + { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, + assistant(true), + { + id: "msg_subagent", + type: "synthetic", + text: "done", + description: "Search code", + metadata: { source: "subagent", agent: "explore", state: "completed" }, + time: { created: 4 }, + }, + { + id: "msg_restart", + type: "synthetic", + text: "continue", + description: "Continuing after restart", + time: { created: 5 }, + }, + { id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 6 } }, + ], + }) + + const notices = page.locator('[data-slot="session-timeline-notice"]') + await expect(notices).toHaveCount(4) + await expect(notices.nth(0)).toContainText("Agent · explore") + await expect(notices.nth(1)).toContainText("explore finished · Search code") + await expect(notices.nth(2)).toContainText("Continuing after restart") + await expect(notices.nth(3)).toContainText("Skill · Review") +}) + +test("moves blocking work to the background with Ctrl+B", async ({ page }) => { + await setupTimeline(page, { currentMessages: [user, assistant(false, true)] }) + await expect(page.locator('[data-component="task-tool-card"]')).toBeVisible() + await expect(page.getByText("Called `subagent`", { exact: false })).toHaveCount(0) + await expect(page.locator('[data-component="background-tool-control"]')).toHaveCount(0) + await expect(page.locator('[data-action="session-background-toggle"]')).toContainText( + "Move 1 subagent to background", + ) + + const request = page.waitForRequest( + (request) => + request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/background`, + ) + await page.keyboard.press("Control+b") + await request +}) + +test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => { + const childID = "ses_running_child" + await setupTimeline(page, { + currentMessages: [user, assistant(false, true, childID)], + sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })], + sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } }, + }) + + await expect(page.locator('[data-action="session-background-toggle"]')).toContainText( + "Move 1 subagent to background", + ) + await page.locator('[data-component="task-tool-card"]').click() + await expect(page).toHaveURL(new RegExp(`/session/${childID}$`)) + await expect(page.locator('[data-component="session-background-dock"]')).toHaveCount(0) +}) + +test("shows a badge for active background work", async ({ page }) => { + const childID = "ses_background_child" + await setupTimeline(page, { + currentMessages: [user, assistant(true)], + sessions: [session(), session({ id: childID, parentID: sessionID })], + sessionStatus: { [childID]: { type: "busy" } }, + }) + + await expect(page.locator('[data-component="session-background-dock"]')).toContainText("1 subagent in background") +}) + +test("separates blocking and already-backgrounded work into two rows", async ({ page }) => { + const backgroundID = "ses_background_existing" + const blockingID = "ses_background_blocking" + await setupTimeline(page, { + currentMessages: [ + user, + { + id: "msg_backgrounded", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_backgrounded", + name: "subagent", + state: { + status: "completed", + input: { description: "Background task" }, + content: [{ type: "text", text: "working" }], + metadata: { sessionID: backgroundID, status: "running" }, + }, + time: { created: 2, completed: 3 }, + }, + { + type: "tool", + id: "call_shell_backgrounded", + name: "shell", + state: { + status: "completed", + input: { command: "sleep 120" }, + content: [{ type: "text", text: "working" }], + metadata: { shellID: "shell_backgrounded", status: "running" }, + }, + time: { created: 2, completed: 3 }, + }, + ], + time: { created: 2, completed: 3 }, + }, + { + id: "msg_blocking", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_blocking", + name: "subagent", + state: { + status: "running", + input: { description: "Foreground task" }, + metadata: { sessionID: blockingID }, + }, + time: { created: 4 }, + }, + ], + time: { created: 4 }, + }, + ], + sessions: [ + session(), + session({ id: backgroundID, parentID: sessionID, title: "Background task" }), + session({ id: blockingID, parentID: sessionID, title: "Foreground task" }), + ], + sessionStatus: { + [sessionID]: { type: "busy" }, + [backgroundID]: { type: "busy" }, + [blockingID]: { type: "busy" }, + }, + }) + + const dock = page.locator('[data-component="session-background-dock"]') + await expect(dock).toContainText("Move 1 subagent to background") + await expect(dock.getByText("Running 1 shell and 1 subagent in background", { exact: true })).toBeVisible() + await expect( + page.locator('[data-timeline-part-id="call_shell_backgrounded"] [data-component="text-shimmer"]'), + ).toHaveAttribute("data-active", "true") +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index f9330f36de..534767d472 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -391,6 +391,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (/^\/api\/session\/[^/]+\/background$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) if (/^\/api\/session\/[^/]+\/inbox$/.test(path) && route.request().method() === "GET") return json(route, { data: [] }) if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index e387391f68..380c4808d7 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -33,6 +33,8 @@ type PluralKey = | "session.question.pending" | "session.followupDock.summary" | "session.revertDock.summary" + | "session.background.shell" + | "session.background.subagent" type Source = { dict: Record } function cookie(locale: Locale) { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 62d4dc1ae6..43a27f32e5 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -673,6 +673,17 @@ export const dict = { "session.error.notFound": "This session cannot be found", "session.error.notFound.description": "This tab points to a session that no longer exists on this server.", "session.error.notFound.closeTab": "Close Tab", + "session.background.moveTasks": "Move {{tasks}} to background", + "session.background.inBackground": "Running {{tasks}} in background", + "session.background.combine": "{{first}} and {{second}}", + "session.background.shell.one": "{{count}} shell", + "session.background.shell.other": "{{count}} shells", + "session.background.subagent.one": "{{count}} subagent", + "session.background.subagent.other": "{{count}} subagents", + "command.session.background": "Move to background", + "session.timeline.notice.finished": "{{actor}} finished", + "session.timeline.notice.failed": "{{actor}} failed", + "session.timeline.notice.cancelled": "{{actor}} cancelled", "session.error.serverConnection": "Can't connect to this server", "session.review.filesChanged": "Files Changed {{count}}", "session.review.change.one": "Change", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index a8a02bd22d..b050e687f0 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -25,6 +25,7 @@ export function DirectoryDataProvider( const params = useParams() const sync = useSync() const serverSync = useServerSync() + const language = useLanguage() const directory = () => props.directory const slug = createMemo(() => base64Encode(directory())) const href = (sessionID: string) => { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index b70fb6fb5b..f0dcbc293e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1118,6 +1118,10 @@ export default function Page() { useComposerCommands() useSessionCommands({ session: controller, + background: { + blocking: () => composer.background.blocking().length > 0, + move: composer.background.move, + }, navigateMessageByOffset, setActiveMessage, focusInput, diff --git a/packages/app/src/pages/session/composer/session-background-dock.tsx b/packages/app/src/pages/session/composer/session-background-dock.tsx new file mode 100644 index 0000000000..976e21cf9d --- /dev/null +++ b/packages/app/src/pages/session/composer/session-background-dock.tsx @@ -0,0 +1,86 @@ +import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { For, createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { SessionComposerPullout } from "./session-composer-pullout" + +export function SessionBackgroundDock(props: { + blocking: { type: "shell" | "subagent"; id?: string; label?: string }[] + tasks: { id: string; type: "shell" | "subagent"; label: string }[] + onBackground: () => void +}) { + const language = useLanguage() + const command = useCommand() + const [store, setStore] = createStore({ collapsed: true }) + const describe = (shells: number, subagents: number) => { + const shell = shells ? language.plural("session.background.shell", shells, { count: shells }) : undefined + const subagent = subagents + ? language.plural("session.background.subagent", subagents, { count: subagents }) + : undefined + if (shell && subagent) return language.t("session.background.combine", { first: shell, second: subagent }) + return shell ?? subagent ?? "" + } + const summary = createMemo(() => { + const shells = props.tasks.filter((task) => task.type === "shell").length + return describe(shells, props.tasks.length - shells) + }) + const moving = createMemo(() => { + const shells = props.blocking.filter((task) => task.type === "shell").length + const subagents = props.blocking.length - shells + const tasks = describe(shells, subagents) + return tasks ? language.t("session.background.moveTasks", { tasks }) : "" + }) + const background = createMemo(() => + summary() ? language.t("session.background.inBackground", { tasks: summary() }) : "", + ) + const blocking = () => props.blocking.length > 0 + const toggle = () => { + if (blocking()) { + props.onBackground() + return + } + setStore("collapsed", (value) => !value) + } + + return ( + + {blocking() && ( + + {moving()} + + + + + )} + {!!props.tasks.length && {background()}} + + } + ariaLabel={[moving(), background()].filter(Boolean).join(". ")} + multiline={blocking() && props.tasks.length > 0} + collapsed={blocking() || store.collapsed} + collapsible={!blocking()} + onToggle={toggle} + collapseLabel={language.t("session.todo.collapse")} + expandLabel={language.t("session.todo.expand")} + > +
+ + {(task) => ( +
+ + {language.t( + task.type === "shell" ? "ui.tool.shell" : "ui.tool.agent.default", + )} + + {task.label} +
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/pages/session/composer/session-composer-pullout.tsx b/packages/app/src/pages/session/composer/session-composer-pullout.tsx new file mode 100644 index 0000000000..d17aaa7d87 --- /dev/null +++ b/packages/app/src/pages/session/composer/session-composer-pullout.tsx @@ -0,0 +1,157 @@ +import { DockTray } from "@opencode-ai/ui/dock-surface" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { useSpring } from "@opencode-ai/ui/motion-spring" +import { TextReveal } from "@opencode-ai/ui/text-reveal" +import { useSettings } from "@/context/settings" +import { createResizeObserver } from "@solid-primitives/resize-observer" +import { createEffect, createMemo, type JSX } from "solid-js" +import { createStore } from "solid-js/store" +import { Dynamic } from "solid-js/web" + +export function SessionComposerPullout(props: { + name: "todo" | "background" + label: JSX.Element + ariaLabel: string + preview?: string + multiline?: boolean + collapsed: boolean + collapsible?: boolean + onToggle: () => void + collapseLabel: string + expandLabel: string + dockProgress?: number + children: JSX.Element +}) { + const settings = useSettings() + const [store, setStore] = createStore({ height: 78, header: 42 }) + const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress ?? 1))) + const shut = createMemo(() => 1 - dock()) + const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) + const hide = createMemo(() => Math.max(value(), shut())) + const off = createMemo(() => hide() > 0.98) + const base = createMemo(() => Math.max(78, store.header + 36)) + const full = createMemo(() => Math.max(base(), store.height)) + let contentRef: HTMLDivElement | undefined + let headerRef: HTMLDivElement | undefined + + createEffect(() => { + const element = contentRef + const header = headerRef + if (!element || !header) return + const update = () => { + setStore("height", (height) => Math.max(height, element.scrollHeight)) + setStore("header", header.getBoundingClientRect().height) + } + update() + createResizeObserver([element, header], update) + }) + + return ( + +
+
{ + if (event.key !== "Enter" && event.key !== " ") return + event.preventDefault() + props.onToggle() + }} + > + + {props.label} + +
+ +
+ {props.collapsible !== false && ( +
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.stopPropagation() + props.onToggle() + }} + aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} + /> +
+ )} +
+
0.1 }} + style={{ visibility: off() ? "hidden" : "visible", opacity: `${Math.max(0, 1 - hide())}` }} + > + {props.children} +
+
+
+ ) +} 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 600ff41e3d..90ed32acda 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -6,6 +6,7 @@ import { SessionQuestionDock } from "@/pages/session/composer/session-question-d import { SessionFollowupDock } from "@/pages/session/composer/session-followup-dock" import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" +import { SessionBackgroundDock } from "@/pages/session/composer/session-background-dock" import type { SessionComposerRegionController } from "./session-composer-region-controller" export function SessionComposerRegion(props: { @@ -15,6 +16,8 @@ export function SessionComposerRegion(props: { const language = useLanguage() const controller = props.controller const settings = useSettings() + const background = () => + controller.state.background.blocking().length > 0 || controller.state.background.tasks().length > 0 const rolled = () => { const revert = controller.revert() return revert?.items.length ? revert : undefined @@ -123,12 +126,21 @@ export function SessionComposerRegion(props: { )} + +
+ void controller.state.background.move()} + /> +
+
diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 690b39d40a..c98fb313f1 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -5,11 +5,13 @@ import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise" import { useParams } from "@solidjs/router" import { showToast } from "@/utils/toast" import { useServerSync } from "@/context/server-sync" +import { useServerSDK } from "@/context/server-sdk" import { useLanguage } from "@/context/language" import { usePermission } from "@/context/permission" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree" +import { createQuery, useQueryClient } from "@tanstack/solid-query" export const todoState = (input: { count: number @@ -31,8 +33,30 @@ export function createSessionComposerController(options?: { closeMs?: number | ( const sdk = useSDK() const sync = useSync() const serverSync = useServerSync() + const serverSDK = useServerSDK() + const queryClient = useQueryClient() const language = useLanguage() const permission = usePermission() + const shellKey = () => [serverSDK().scope, sdk().directory, "shell"] as const + const shells = createQuery(() => ({ + queryKey: shellKey(), + enabled: !!params.id && serverSDK().connection.status() === "connected", + queryFn: () => + sdk() + .api.shell.list({ location: { directory: sdk().directory } }) + .then((result) => result.data), + })) + onCleanup( + sdk().event.listen((event) => { + if ( + event.details.type !== "shell.created" && + event.details.type !== "shell.exited" && + event.details.type !== "shell.deleted" + ) + return + void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true }) + }), + ) const questionRequest = createMemo((): FormInfo | undefined => { return sessionQuestionForm(sync().data.session, serverSync().session.data.form, params.id) @@ -61,6 +85,113 @@ export function createSessionComposerController(options?: { closeMs?: number | ( ) const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked()) + const primary = () => { + const id = params.id + return !!id && !serverSync().session.get(id)?.parentID + } + const backgroundBlocking = createMemo(() => { + if (!primary()) return [] + const id = params.id + if (!id) return [] + const assistant = (serverSync().session.data.session_message[id] ?? []).findLast( + (message) => message.type === "assistant" && message.time.completed === undefined, + ) + if (assistant?.type !== "assistant") return [] + return assistant.content.flatMap((part) => { + if (part.type !== "tool" || part.state.status !== "running") return [] + if (part.name !== "shell" && part.name !== "subagent") return [] + const value = part.name === "shell" ? part.state.metadata.shellID : part.state.metadata.sessionID + const label = part.name === "shell" ? part.state.input.command : part.state.input.description + return [ + { + type: part.name as "shell" | "subagent", + id: typeof value === "string" ? value : undefined, + label: typeof label === "string" ? label : undefined, + }, + ] + }) + }) + const backgroundTasks = createMemo(() => { + if (!primary()) return [] + const id = params.id + if (!id) return [] + const blocking = backgroundBlocking() + const messages = serverSync().session.data.session_message[id] ?? [] + const completed = new Set( + messages.flatMap((message) => { + if (message.type !== "synthetic") return [] + if (message.metadata?.source === "subagent" && typeof message.metadata.childID === "string") + return [message.metadata.childID] + if (message.metadata?.source === "shell" && typeof message.metadata.jobID === "string") + return [message.metadata.jobID] + return [] + }), + ) + const backgrounded = messages.flatMap((message) => { + if (message.type !== "assistant") return [] + return message.content.flatMap((part) => { + if (part.type !== "tool" || part.name !== "subagent") return [] + if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return [] + const sessionID = part.state.metadata.sessionID + if (typeof sessionID !== "string" || completed.has(sessionID)) return [] + const description = part.state.input.description + return [{ id: sessionID, type: "subagent" as const, label: typeof description === "string" ? description : sessionID }] + }) + }) + const active = Object.values(serverSync().session.data.info).flatMap((info) => { + if (info?.parentID !== id) return [] + if ((serverSync().session.data.session_status[info.id]?.type ?? "idle") === "idle") return [] + if ( + blocking.some( + (item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)), + ) + ) + return [] + return [{ id: info.id, type: "subagent" as const, label: info.title ?? info.id }] + }) + const backgroundShells = messages.flatMap((message) => { + if (message.type !== "assistant") return [] + return message.content.flatMap((part) => { + if (part.type !== "tool" || part.name !== "shell" || completed.has(part.id)) return [] + if (part.state.status !== "completed" || part.state.metadata?.status !== "running") return [] + const shellID = part.state.metadata.shellID + const command = part.state.input.command + return [ + { + id: typeof shellID === "string" ? shellID : part.id, + type: "shell" as const, + label: typeof command === "string" ? command : part.id, + }, + ] + }) + }) + const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => { + if (shell.status !== "running" || shell.metadata.sessionID !== id) return [] + if ( + blocking.some( + (item) => item.type === "shell" && (item.id === shell.id || (!!item.label && shell.command === item.label)), + ) + ) + return [] + return [{ id: shell.id, type: "shell" as const, label: shell.command }] + }) + return [ + ...new Map([...backgrounded, ...active, ...backgroundShells, ...running].map((task) => [task.id, task])).values(), + ] + }) + const moveToBackground = async () => { + if (!primary()) return + const sessionID = params.id + if (!sessionID) return + await sdk() + .api.session.background({ sessionID }) + .catch((error) => { + showToast({ + title: language.t("common.requestFailed"), + description: error instanceof Error ? error.message : String(error), + }) + }) + } const [store, setStore] = createStore({ sessionID: params.id, @@ -191,6 +322,11 @@ export function createSessionComposerController(options?: { closeMs?: number | ( questionRequest, permissionRequest, permissionResponding, + background: { + blocking: backgroundBlocking, + tasks: backgroundTasks, + move: moveToBackground, + }, decide, todos, dock: () => diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 8245b829ef..dfa2e1db98 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -1,17 +1,11 @@ import type { Todo } from "@/types" import { AnimatedNumber } from "@opencode-ai/ui/animated-number" import { Checkbox } from "@opencode-ai/ui/checkbox" -import { DockTray } from "@opencode-ai/ui/dock-surface" -import { IconButton } from "@opencode-ai/ui/icon-button" -import { useSpring } from "@opencode-ai/ui/motion-spring" -import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" -import { createResizeObserver } from "@solid-primitives/resize-observer" -import { Index, Match, Switch, createEffect, createMemo } from "solid-js" -import { Dynamic } from "solid-js/web" +import { Index, Match, Switch, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" +import { SessionComposerPullout } from "./session-composer-pullout" const doneToken = "\u0000done\u0000" const totalToken = "\u0000total\u0000" @@ -50,10 +44,6 @@ export function SessionTodoDock(props: { dockProgress: number }) { const language = useLanguage() - const settings = useSettings() - const [store, setStore] = createStore({ - height: 78, - }) const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) @@ -73,147 +63,33 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) - const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) - const shut = createMemo(() => 1 - dock()) - const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) - const hide = createMemo(() => Math.max(value(), shut())) - const off = createMemo(() => hide() > 0.98) - const turn = createMemo(() => Math.max(0, Math.min(1, value()))) - const full = createMemo(() => Math.max(78, store.height)) - let contentRef: HTMLDivElement | undefined - - createEffect(() => { - const el = contentRef - if (!el) return - const update = () => { - setStore("height", (height) => Math.max(height, el.scrollHeight)) - } - update() - createResizeObserver(el, update) - }) - return ( - + {(item) => ( + {item()}}> + + + + + + + + )} + + } + ariaLabel={label()} + preview={props.collapsed ? preview() : undefined} + collapsed={props.collapsed} + onToggle={props.onToggle} + collapseLabel={props.collapseLabel} + expandLabel={props.expandLabel} + dockProgress={props.dockProgress} > -
-
{ - if (event.key !== "Enter" && event.key !== " ") return - event.preventDefault() - props.onToggle() - }} - > - - - {(item) => ( - {item()}}> - - - - - - - - )} - - -
- -
-
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={(event) => { - event.stopPropagation() - props.onToggle() - }} - aria-label={props.collapsed ? props.expandLabel : props.collapseLabel} - /> -
-
- -
0.1, - }} - style={{ - visibility: off() ? "hidden" : "visible", - opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, - }} - > - -
-
-
+ + ) } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index bc3298380f..b9c5c0ffa8 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -54,6 +54,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset" import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows" import { filterVirtualIndexes } from "./virtual-items" import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" const emptyTools: ToolPart[] = [] const emptyAssistantMessages: AssistantMessage[] = [] @@ -262,10 +263,48 @@ function MessageTimelineView( const assistantMessagesByParent = projection.assistantMessagesByParent const lastAssistantGroupKey = projection.lastAssistantGroupKey const messageByID = projection.messageByID + const sessionMessageByID = projection.sessionMessageByID const messageLastRowIndex = projection.messageLastRowIndex const messageRowIndex = projection.messageRowIndex const timelineRowByKey = projection.rowByKey const timelineRows = projection.rows + const noticeContent = (message: SessionMessageInfo) => { + if (message.type === "agent-switched") + return { + label: language.t("ui.tool.agent.default"), + data: message.previous ? `${message.previous} → ${message.agent}` : message.agent, + } + if (message.type === "model-switched") + return { + label: language.t("command.category.model"), + data: `${message.model.providerID}/${message.model.id}`, + } + if (message.type === "location-switched") + return { label: language.t("ui.patch.action.moved"), data: message.location.directory } + if (message.type === "skill") return { label: language.t("ui.tool.skill"), data: message.name } + if (message.type === "system") return { label: message.description ?? message.text } + if (message.type === "compaction") + return { label: language.t("ui.messagePart.compaction"), data: message.status } + if (message.type !== "synthetic") return + if (message.description === "Continuing after restart") return { label: message.description } + const source = typeof message.metadata?.source === "string" ? message.metadata.source : undefined + const state = typeof message.metadata?.state === "string" ? message.metadata.state : undefined + if (source === "subagent" || source === "shell") { + const agent = typeof message.metadata?.agent === "string" ? message.metadata.agent : undefined + const actor = + source === "shell" ? language.t("ui.tool.shell") : (agent ?? language.t("ui.tool.agent.default")) + const label = language.t( + state === "error" + ? "session.timeline.notice.failed" + : state === "cancelled" + ? "session.timeline.notice.cancelled" + : "session.timeline.notice.finished", + { actor }, + ) + return { label, data: message.description } + } + return { label: message.description ?? message.text } + } let prependAnchor: { key: string; offset: number } | undefined let prependAnchorFrame: number | undefined @@ -816,6 +855,25 @@ function MessageTimelineView( ) } + case "Notice": { + const noticeRow = row as Accessor> + const content = createMemo(() => { + const message = sessionMessageByID().get(noticeRow().messageID) + return message ? noticeContent(message) : undefined + }) + return ( + + + {(content) => ( +
+ {content().label} + {(data) => · {data()}} +
+ )} +
+
+ ) + } case "TurnDivider": { const turnDividerRow = row as Accessor> return ( diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index 2e3b29854c..d91b782a9a 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -16,6 +16,9 @@ export function createTimelineProjection(input: { inlineComments: Accessor }) { const messageByID = createMemo(() => new Map(input.messages().map((message) => [message.id, message] as const))) + const sessionMessageByID = createMemo( + () => new Map(input.sessionMessages().map((message) => [message.id, message] as const)), + ) const assistantMessagesByParent = createMemo(() => { const result = new Map() input.messages().forEach((message) => { @@ -77,5 +80,6 @@ export function createTimelineProjection(input: { messageLastRowIndex, rowByKey, rows, + sessionMessageByID, } } diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index 158897ba5e..4db97287d1 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -92,6 +92,84 @@ describe("current session timeline rows", () => { ]) }) + test("keeps CLI notice messages between the assistant steps they surround", () => { + const source = [ + { id: "msg_user", type: "user", text: "run", time: { created: 1 } }, + { id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } }, + { + id: "msg_assistant_1", + type: "assistant", + agent: "explore", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "started" }], + time: { created: 3, completed: 4 }, + }, + { + id: "msg_background", + type: "synthetic", + text: "result", + description: "Search code", + metadata: { source: "subagent", agent: "explore", state: "completed" }, + time: { created: 5 }, + }, + { + id: "msg_model", + type: "model-switched", + model: { id: "next", providerID: "provider" }, + time: { created: 6 }, + }, + { + id: "msg_assistant_2", + type: "assistant", + agent: "explore", + model: { id: "next", providerID: "provider" }, + content: [{ type: "text", text: "finished" }], + time: { created: 7, completed: 8 }, + }, + { + id: "msg_restart", + type: "synthetic", + text: "continue", + description: "Continuing after restart", + time: { created: 9 }, + }, + { id: "msg_skill", type: "skill", skill: "review", name: "Review", text: "instructions", time: { created: 10 } }, + { + id: "msg_compaction", + type: "compaction", + status: "completed", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 11 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + normalized.messages.filter((message) => message.role === "user"), + ) + + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_user", + "notice:msg_agent", + "assistant-part:msg_user:msg_assistant_1:text:0", + "notice:msg_background", + "notice:msg_model", + "assistant-part:msg_user:msg_assistant_2:text:0", + "notice:msg_restart", + "notice:msg_skill", + "notice:msg_compaction", + ]) + }) + test("keeps a projected parent missing from the source page before newer turns", () => { const source = [ { id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } }, diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 65db7a3fcf..ec6a0c9068 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -17,6 +17,7 @@ export type TimelineRowMap = { userMessageID: string anchor: boolean } + Notice: { userMessageID: string; messageID: string } TurnDivider: { userMessageID: string label: "compaction" | "interrupted" @@ -42,39 +43,57 @@ export namespace Timeline { inlineComments: boolean, projectedUserMessages: UserMessage[], ) { - const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] + type Notice = Exclude + type Entry = { type: "assistant"; message: AssistantMessage } | { type: "notice"; message: Notice } + const turns: { user: UserMessage; entries: Entry[] }[] = [] const turnByUserID = new Map() + const leading: Notice[] = [] + let current: (typeof turns)[number] | undefined messages.forEach((message) => { + if (isNotice(message)) { + if (current) current.entries.push({ type: "notice", message }) + if (!current) leading.push(message) + return + } const projected = getMessage(message.id) if (message.type === "shell" && projected?.role === "user") { const assistant = getMessage(`${message.id}:assistant`) - const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] } + const turn = { + user: projected, + entries: assistant?.role === "assistant" ? [{ type: "assistant" as const, message: assistant }] : [], + } turns.push(turn) turnByUserID.set(projected.id, turn) + current = turn return } if (projected?.role === "user") { if (turnByUserID.has(projected.id)) return - const turn = { user: projected, assistants: [] } + const turn = { user: projected, entries: [] } turns.push(turn) turnByUserID.set(projected.id, turn) + current = turn return } if (projected?.role !== "assistant") return - const existing = turnByUserID.get(projected.parentID) + const existing = current ?? turnByUserID.get(projected.parentID) if (existing) { - existing.assistants.push(projected) + existing.entries.push({ type: "assistant", message: projected }) + current = existing return } const user = getMessage(projected.parentID) if (user?.role !== "user") return - const turn = { user, assistants: [projected] } + const turn = { user, entries: [{ type: "assistant" as const, message: projected }] } turns.push(turn) turnByUserID.set(user.id, turn) + current = turn }) + const notices = new Set(messages.filter(isNotice).map((message) => message.id)) projectedUserMessages.forEach((user) => { + if (notices.has(user.id)) return if (turnByUserID.has(user.id)) return - const turn = { user, assistants: [] } + const turn = { user, entries: [] } const index = turns.findIndex((item) => compareMessages(user, item.user) < 0) if (index < 0) turns.push(turn) if (index >= 0) turns.splice(index, 0, turn) @@ -83,25 +102,34 @@ export namespace Timeline { const activeMessageID = turns.at(-1)?.user.id return { activeMessageID, - rows: turns.flatMap((turn, index) => - constructMessageRows( - turn.user, - getMessageParts, - turn.assistants, - index, - showReasoning, - status, - turn.user.id === activeMessageID, - inlineComments, + rows: [ + ...leading.map( + (message) => + new TimelineRow.Notice({ userMessageID: turns[0]?.user.id ?? message.id, messageID: message.id }), ), - ), + ...turns.flatMap((turn, index) => + constructMessageRows( + turn.user, + getMessageParts, + turn.entries, + index, + showReasoning, + status, + turn.user.id === activeMessageID, + inlineComments, + ), + ), + ], } } export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], - assistantMessages: AssistantMessage[], + entries: Array< + | { type: "assistant"; message: AssistantMessage } + | { type: "notice"; message: Exclude } + >, index: number, showReasoning: boolean, status: SessionStatus["type"], @@ -110,13 +138,14 @@ export namespace Timeline { inlineComments: boolean, ) { const rows: TimelineRow.TimelineRow[] = [] + const assistantMessages = entries.flatMap((entry) => (entry.type === "assistant" ? [entry.message] : [])) const previousUserMessage = index > 0 const userParts = getMessageParts(userMessage.id) const comments = userParts.flatMap((p) => MessageComment.fromPart(p) ?? []) - const compaction = userParts.some((p) => p.type === "compaction") - const interruptedMessageIndex = assistantMessages.findIndex((m) => m.error?.name === "MessageAbortedError") - const interrupted = interruptedMessageIndex !== -1 + const compaction = + userParts.some((p) => p.type === "compaction") && + !entries.some((entry) => entry.type === "notice" && entry.message.type === "compaction") const latestError = assistantMessages.at(-1)?.error const error = latestError?.name === "MessageAbortedError" ? undefined : latestError @@ -125,24 +154,6 @@ export namespace Timeline { .filter((part) => renderable(part, showReasoning)) .map((part) => ({ messageID: message.id, messageIndex, part })), ) - const assistantItems = - interrupted && !compaction - ? [ - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex <= interruptedMessageIndex)).map( - (group) => ({ - type: "part" as const, - group, - }), - ), - { type: "interrupted" as const }, - ...groupParts(assistantPartRefs.filter((ref) => ref.messageIndex > interruptedMessageIndex)).map( - (group) => ({ - type: "part" as const, - group, - }), - ), - ] - : groupParts(assistantPartRefs).map((group) => ({ type: "part" as const, group })) if (previousUserMessage) rows.push(new TimelineRow.TurnGap({ userMessageID: userMessage.id })) if (comments.length > 0 && !inlineComments) @@ -169,26 +180,41 @@ export namespace Timeline { } let assistantGroupIndex = 0 - assistantItems.forEach((item) => { - if (item.type === "interrupted") { - rows.push( - new TimelineRow.TurnDivider({ - userMessageID: userMessage.id, - label: "interrupted", - }), - ) + const appendAssistants = (messages: AssistantMessage[]) => { + const ids = new Set(messages.map((message) => message.id)) + const refs = assistantPartRefs.filter((ref) => ids.has(ref.messageID)) + const interruptedAt = messages.findIndex((message) => message.error?.name === "MessageAbortedError") + const interruptedID = messages[interruptedAt]?.id + const interruptedIndex = assistantMessages.findIndex((message) => message.id === interruptedID) + const before = interruptedID ? refs.filter((ref) => ref.messageIndex <= interruptedIndex) : refs + const after = interruptedID ? refs.filter((ref) => ref.messageIndex > interruptedIndex) : [] + const appendGroups = (items: typeof refs) => + groupParts(items).forEach((group) => { + rows.push( + new TimelineRow.AssistantPart({ + userMessageID: userMessage.id, + group, + previousAssistantPart: assistantGroupIndex > 0, + }), + ) + assistantGroupIndex += 1 + }) + appendGroups(before) + if (interruptedAt >= 0 && !compaction) + rows.push(new TimelineRow.TurnDivider({ userMessageID: userMessage.id, label: "interrupted" })) + appendGroups(after) + } + let assistantSegment: AssistantMessage[] = [] + entries.forEach((entry) => { + if (entry.type === "assistant") { + assistantSegment.push(entry.message) return } - - rows.push( - new TimelineRow.AssistantPart({ - userMessageID: userMessage.id, - group: item.group, - previousAssistantPart: assistantGroupIndex > 0, - }), - ) - assistantGroupIndex += 1 + appendAssistants(assistantSegment) + assistantSegment = [] + rows.push(new TimelineRow.Notice({ userMessageID: userMessage.id, messageID: entry.message.id })) }) + appendAssistants(assistantSegment) if (isActive && status === "busy" && !error && (showReasoning ? assistantPartRefs.length === 0 : true)) { const heading = assistantMessages @@ -316,6 +342,14 @@ export namespace Timeline { function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value) } + + function isNotice( + message: SessionMessageInfo, + ): message is Exclude { + if (message.type === "user" || message.type === "assistant" || message.type === "shell") return false + if (message.type !== "synthetic") return true + return !!message.description?.trim() + } } export namespace MessageComment { diff --git a/packages/app/src/pages/session/timeline/timeline-row.ts b/packages/app/src/pages/session/timeline/timeline-row.ts index 52a1be691a..7a1ce0e916 100644 --- a/packages/app/src/pages/session/timeline/timeline-row.ts +++ b/packages/app/src/pages/session/timeline/timeline-row.ts @@ -15,6 +15,10 @@ export namespace TimelineRow { userMessageID: string anchor: boolean }> {} + export class Notice extends Data.TaggedClass("Notice")<{ + userMessageID: string + messageID: string + }> {} export class TurnDivider extends Data.TaggedClass("TurnDivider")<{ userMessageID: string label: "compaction" | "interrupted" @@ -44,6 +48,7 @@ export namespace TimelineRow { | TurnGap | CommentStrip | UserMessage + | Notice | TurnDivider | AssistantPart | Thinking @@ -59,6 +64,8 @@ export namespace TimelineRow { return `comment-strip:${row.userMessageID}` case "UserMessage": return `user-message:${row.userMessageID}` + case "Notice": + return `notice:${row.messageID}` case "TurnDivider": return `turn-divider:${row.userMessageID}:${row.label}` case "AssistantPart": diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 0cdc5b190f..43b3691b9b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -30,6 +30,10 @@ type SessionCommandSource = { export type SessionCommandContext = { session: SessionCommandSource + background: { + blocking: () => boolean + move: () => Promise + } navigateMessageByOffset: (offset: number) => void setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void @@ -434,6 +438,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0, onSelect: compact, }), + sessionCommand({ + id: "session.background", + title: language.t("command.session.background"), + keybind: "ctrl+b", + disabled: !actions.background.blocking(), + onSelect: actions.background.move, + }), sessionCommand({ id: "session.fork", title: language.t("command.session.fork"), diff --git a/packages/core/src/tool/plugin/shell.ts b/packages/core/src/tool/plugin/shell.ts index 6ec202f7bc..22ad291e5a 100644 --- a/packages/core/src/tool/plugin/shell.ts +++ b/packages/core/src/tool/plugin/shell.ts @@ -115,7 +115,7 @@ export const Plugin = { sessionID, text: `\n${text}\n`, description: command, - metadata: { source: "shell", state }, + metadata: { source: "shell", jobID: id, state }, }) }), Effect.forkIn(scope, { startImmediately: true }), @@ -300,6 +300,7 @@ export const Plugin = { output, content, metadata: { + status: output.status, truncated: output.truncated, ...("exit" in output && output.exit !== undefined ? { exit: output.exit } : {}), ...("shellID" in output && output.shellID !== undefined ? { shellID: output.shellID } : {}), diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index d843dfb8a6..47e613ca2a 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -515,11 +515,10 @@ export function getToolInfo( title: webSearchProviderLabel(metadata?.provider, i18n), subtitle: input.query, } - case "task": { - const type = - typeof input.subagent_type === "string" && input.subagent_type - ? input.subagent_type[0]!.toUpperCase() + input.subagent_type.slice(1) - : undefined + case "task": + case "subagent": { + const raw = input.agent ?? input.subagent_type + const type = typeof raw === "string" && raw ? raw[0]!.toUpperCase() + raw.slice(1) : undefined return { icon: "task", title: agentTitle(i18n, type), @@ -594,19 +593,12 @@ function sessionLink(id: string | undefined, href?: (id: string) => string | und return href?.(id) } -function taskSession( - input: Record, - parentID: string | undefined, - sessions: SessionSummary[] | undefined, - agents?: readonly { name: string; color?: string }[], -) { +function taskSession(input: Record, parentID: string | undefined, sessions: SessionSummary[] | undefined) { 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 } @@ -1553,16 +1545,16 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { // @ts-expect-error const partMetadata = () => part().state?.metadata ?? emptyMetadata const taskId = createMemo(() => { - if (part().tool !== "task") return - const value = partMetadata().sessionId + if (part().tool !== "task" && part().tool !== "subagent") return + const value = partMetadata().sessionID ?? partMetadata().sessionId if (typeof value === "string" && value) return value }) const taskHref = createMemo(() => { - if (part().tool !== "task") return + if (part().tool !== "task" && part().tool !== "subagent") return return sessionLink(taskId(), data.sessionHref) }) const taskSubtitle = createMemo(() => { - if (part().tool !== "task") return undefined + if (part().tool !== "task" && part().tool !== "subagent") return undefined const value = input().description if (typeof value === "string" && value) return value return taskId() @@ -1979,18 +1971,17 @@ ToolRegistry.register({ ) }, }) - ToolRegistry.register({ name: "task", render(props) { const data = useData() const i18n = useI18n() const childSessionId = createMemo(() => { - const value = props.metadata.sessionId + const value = props.metadata.sessionID ?? props.metadata.sessionId if (typeof value === "string" && value) return value - return taskSession(props.input, data.sessionID, data.store.session, data.store.agent) + return taskSession(props.input, data.sessionID, data.store.session) }) - const agent = createMemo(() => taskAgent(props.input.subagent_type, data.store.agent)) + const agent = createMemo(() => taskAgent(props.input.agent ?? props.input.subagent_type, data.store.agent)) const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default")) const tone = createMemo(() => agent().color) const v2Tone = createMemo(() => agent().v2Color) @@ -2000,7 +1991,8 @@ ToolRegistry.register({ ? props.input.description : childSessionId() if (!value) return value - if (props.metadata.background === true) return `${value} (background)` + if (props.input.background === true || props.metadata.background === true || props.metadata.status === "running") + return `${value} (background)` return value }) const running = createMemo(() => props.status === "pending" || props.status === "running") @@ -2087,11 +2079,14 @@ ToolRegistry.register({ }, }) +ToolRegistry.register({ name: "subagent", render: ToolRegistry.render("task") }) + ToolRegistry.register({ name: "shell", render(props) { const i18n = useI18n() - const pending = () => props.status === "pending" || props.status === "running" + const pending = () => + props.status === "pending" || props.status === "running" || props.metadata.status === "running" const sawPending = pending() const text = createMemo(() => { const cmd = props.input.command ?? props.metadata.command ?? ""