From 750a87ef45b9c153e1cdb17043787d9a28944451 Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Thu, 5 Feb 2026 23:08:59 +0000 Subject: [PATCH] fix(ui): render task steps from child session --- packages/ui/src/components/tool-call.tsx | 25 ++ .../components/tool-call/renderers/task.tsx | 239 +++++++++++++++--- .../ui/src/components/tool-call/tool-title.ts | 3 + packages/ui/src/components/tool-call/types.ts | 14 + .../src/styles/messaging/tool-call/task.css | 36 +++ 5 files changed, 277 insertions(+), 40 deletions(-) diff --git a/packages/ui/src/components/tool-call.tsx b/packages/ui/src/components/tool-call.tsx index 1814a373..242b5a52 100644 --- a/packages/ui/src/components/tool-call.tsx +++ b/packages/ui/src/components/tool-call.tsx @@ -59,6 +59,11 @@ interface ToolCallProps { instanceId: string sessionId: string onContentRendered?: () => void + /** + * When true, tool call starts collapsed regardless of user preferences. + * Users can still expand/collapse manually. + */ + forceCollapsed?: boolean } @@ -142,6 +147,9 @@ export default function ToolCall(props: ToolCallProps) { const diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded") const defaultExpandedForTool = createMemo(() => { + if (props.forceCollapsed) { + return false + } const prefExpanded = toolOutputDefaultExpanded() const toolName = toolCallMemo()?.tool || "" if (toolName === "read") { @@ -575,12 +583,29 @@ export default function ToolCall(props: ToolCallProps) { toolCall: toolCallMemo, toolState, toolName, + instanceId: props.instanceId, + sessionId: props.sessionId, t, messageVersion: messageVersionAccessor, partVersion: partVersionAccessor, renderMarkdown: renderMarkdownContent, renderAnsi: renderAnsiContent, renderDiff: renderDiffContent, + renderToolCall: (options) => { + if (!options?.toolCall) return null + return ( + + ) + }, scrollHelpers, } diff --git a/packages/ui/src/components/tool-call/renderers/task.tsx b/packages/ui/src/components/tool-call/renderers/task.tsx index 3bba8ef7..0f425596 100644 --- a/packages/ui/src/components/tool-call/renderers/task.tsx +++ b/packages/ui/src/components/tool-call/renderers/task.tsx @@ -1,8 +1,11 @@ -import { For, Show, createMemo } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, untrack } from "solid-js" import type { ToolState } from "@opencode-ai/sdk" import type { ToolRenderer } from "../types" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { resolveTitleForTool } from "../tool-title" +import { messageStoreBus } from "../../../stores/message-v2/bus" +import { loadMessages } from "../../../stores/session-api" +import { loading, messagesLoaded } from "../../../stores/session-state" interface TaskSummaryItem { id: string @@ -14,6 +17,70 @@ interface TaskSummaryItem { title?: string } +function extractSessionIdFromTaskState(state?: ToolState): string { + if (!state) return "" + const metadata = (state as unknown as { metadata?: Record }).metadata ?? {} + const directId = (metadata as any)?.sessionId ?? (metadata as any)?.sessionID + return typeof directId === "string" ? directId : "" +} + +function splitToolKey(key: string): { messageId: string; partId: string } | null { + const separator = "::" + const index = key.lastIndexOf(separator) + if (index <= 0) return null + const messageId = key.slice(0, index) + const partId = key.slice(index + separator.length) + if (!messageId || !partId) return null + return { messageId, partId } +} + +function TaskToolCallRow(props: { + toolKey: string + store: ReturnType + sessionId: string + renderToolCall: NonNullable +}) { + const parts = createMemo(() => splitToolKey(props.toolKey)) + const messageId = createMemo(() => parts()?.messageId ?? "") + const partId = createMemo(() => parts()?.partId ?? "") + + const record = createMemo(() => { + const id = messageId() + if (!id) return undefined + return props.store.getMessage(id) + }) + + const partEntry = createMemo(() => { + const rec = record() + const pid = partId() + if (!rec || !pid) return undefined + return rec.parts?.[pid] + }) + + const toolPart = createMemo(() => { + const data = partEntry()?.data + return data && (data as any).type === "tool" ? (data as any) : undefined + }) + + const messageVersion = createMemo(() => record()?.revision ?? 0) + const partVersion = createMemo(() => partEntry()?.revision ?? 0) + + const rendered = createMemo(() => { + const part = toolPart() + if (!part) return null + return props.renderToolCall({ + toolCall: part as any, + messageId: messageId(), + messageVersion: messageVersion(), + partVersion: partVersion(), + sessionId: props.sessionId, + forceCollapsed: true, + }) + }) + + return <>{rendered()} +} + function normalizeStatus(status?: string | null): ToolState["status"] | undefined { if (status === "pending" || status === "running" || status === "completed" || status === "error") { return status @@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = { const { input } = readToolStatePayload(state) return describeTaskTitle(input) }, - renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { + renderBody({ toolState, instanceId, renderToolCall, messageVersion, partVersion, scrollHelpers, renderMarkdown, t }) { + const store = messageStoreBus.getOrCreate(instanceId) + const [requestedChildLoad, setRequestedChildLoad] = createSignal(false) + + const childSessionId = createMemo(() => { + const state = toolState() + return extractSessionIdFromTaskState(state) + }) + + const childSessionLoaded = createMemo(() => { + const id = childSessionId() + if (!id) return false + const loadedForInstance = messagesLoaded().get(instanceId) + return loadedForInstance?.has(id) ?? false + }) + + const childSessionLoading = createMemo(() => { + const id = childSessionId() + if (!id) return false + const loadingSet = loading().loadingMessages.get(instanceId) + return loadingSet?.has(id) ?? false + }) + + createEffect(() => { + const id = childSessionId() + if (!id) return + if (requestedChildLoad()) return + if (childSessionLoaded()) return + if (childSessionLoading()) return + setRequestedChildLoad(true) + void loadMessages(instanceId, id) + }) + + const childToolKeys = createMemo(() => { + const id = childSessionId() + if (!id) return [] as string[] + if (!childSessionLoaded()) return [] as string[] + + // React to session changes, but do the scan untracked to avoid + // subscribing to every message/part node in the store. + store.getSessionRevision(id) + return untrack(() => { + const messageIds = store.getSessionMessageIds(id) + const keys: string[] = [] + for (const messageId of messageIds) { + const record = store.getMessage(messageId) + if (!record) continue + for (const partId of record.partIds) { + const entry = record.parts?.[partId] + const data = entry?.data + if (!data || (data as any).type !== "tool") continue + keys.push(`${messageId}::${partId}`) + } + } + return keys + }) + }) const promptContent = createMemo(() => { const state = toolState() if (!state) return null @@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = { return null }) - const items = createMemo(() => { + const legacyItems = createMemo(() => { // Track the reactive change points so we only recompute when the part/message changes messageVersion?.() partVersion?.() @@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = { const state = toolState() if (!state) return [] + // Prefer deriving steps from the child session when loaded. + if (childSessionLoaded()) return [] + const { metadata } = readToolStatePayload(state) const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : [] @@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = { - 0}> + 0 || legacyItems().length > 0}>
{t("toolCall.task.sections.steps")} - +
-
scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined + 0} + fallback={ +
scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined + } + > +
+ + {(item) => { + const icon = getToolIcon(item.tool) + const description = describeToolTitle(item) + const toolLabel = getToolName(item.tool) + const status = normalizeStatus(item.status ?? item.state?.status) + const statusIcon = summarizeStatusIcon(status) + const statusKey = summarizeStatusLabel(status) + const statusLabel = statusKey + ? t(`toolCall.status.${statusKey}`) + : t("toolCall.status.unknown") + const statusAttr = status ?? "pending" + return ( +
+ {icon} + {toolLabel} + + {description} + + + {statusIcon} + + +
+ ) + }} +
+
+ {scrollHelpers?.renderSentinel?.()} +
} > -
- - {(item) => { - const icon = getToolIcon(item.tool) - const description = describeToolTitle(item) - const toolLabel = getToolName(item.tool) - const status = normalizeStatus(item.status ?? item.state?.status) - const statusIcon = summarizeStatusIcon(status) - const statusKey = summarizeStatusLabel(status) - const statusLabel = statusKey - ? t(`toolCall.status.${statusKey}`) - : t("toolCall.status.unknown") - const statusAttr = status ?? "pending" - return ( -
- {icon} - {toolLabel} - - {description} - - - {statusIcon} - - -
- ) - }} -
+
scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined + } + > +
+ + {(key) => ( + + {(render) => ( + + )} + + )} + +
+ {scrollHelpers?.renderSentinel?.()}
- {scrollHelpers?.renderSentinel?.()} -
+
diff --git a/packages/ui/src/components/tool-call/tool-title.ts b/packages/ui/src/components/tool-call/tool-title.ts index d2abfd0a..6d2526c5 100644 --- a/packages/ui/src/components/tool-call/tool-title.ts +++ b/packages/ui/src/components/tool-call/tool-title.ts @@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext { toolCall: toolCallAccessor, toolState: toolStateAccessor, toolName: toolNameAccessor, + instanceId: "", + sessionId: "", t, messageVersion: messageVersionAccessor, partVersion: partVersionAccessor, renderMarkdown, renderAnsi, renderDiff, + renderToolCall: () => null, scrollHelpers: undefined, } } diff --git a/packages/ui/src/components/tool-call/types.ts b/packages/ui/src/components/tool-call/types.ts index 6d6d74ec..c3578a07 100644 --- a/packages/ui/src/components/tool-call/types.ts +++ b/packages/ui/src/components/tool-call/types.ts @@ -53,12 +53,26 @@ export interface ToolRendererContext { toolCall: Accessor toolState: Accessor toolName: Accessor + instanceId: string + sessionId: string t: (key: string, params?: Record) => string messageVersion?: Accessor partVersion?: Accessor renderMarkdown(options: MarkdownRenderOptions): JSXElement | null renderAnsi(options: AnsiRenderOptions): JSXElement | null renderDiff(payload: DiffPayload, options?: DiffRenderOptions): JSXElement | null + /** + * Render another tool call inline. This is provided by the ToolCall shell + * to avoid renderer-level imports that would create cyclic dependencies. + */ + renderToolCall?: (options: { + toolCall: ToolCallPart + messageId?: string + messageVersion?: number + partVersion?: number + sessionId: string + forceCollapsed?: boolean + }) => JSXElement | null scrollHelpers?: ToolScrollHelpers } diff --git a/packages/ui/src/styles/messaging/tool-call/task.css b/packages/ui/src/styles/messaging/tool-call/task.css index b1edff5d..83e435ac 100644 --- a/packages/ui/src/styles/messaging/tool-call/task.css +++ b/packages/ui/src/styles/messaging/tool-call/task.css @@ -76,6 +76,42 @@ margin: 0; } +/* Nested tool calls (child session tool timeline) */ +.tool-call-task-summary .tool-call { + /* Tool calls inside the main message stream are borderless. + Use an overlay stripe so hover backgrounds don't hide it. */ + position: relative; +} + +.tool-call-task-summary .tool-call::before { + content: ""; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + background-color: var(--task-tool-call-stripe, transparent); + pointer-events: none; + z-index: 2; +} + +.tool-call-task-summary .tool-call.tool-call-status-completed, +.tool-call-task-summary .tool-call.tool-call-status-success { + --task-tool-call-stripe: var(--status-success); +} + +.tool-call-task-summary .tool-call.tool-call-status-running { + --task-tool-call-stripe: var(--status-warning); +} + +.tool-call-task-summary .tool-call.tool-call-status-pending { + --task-tool-call-stripe: var(--accent-primary); +} + +.tool-call-task-summary .tool-call.tool-call-status-error { + --task-tool-call-stripe: var(--status-error); +} + .tool-call-task-item { display: flex; align-items: center;