fix(ui): render task steps from child session
This commit is contained in:
@@ -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 (
|
||||
<ToolCall
|
||||
toolCall={options.toolCall}
|
||||
toolCallId={options.toolCall.id}
|
||||
messageId={options.messageId}
|
||||
messageVersion={options.messageVersion}
|
||||
partVersion={options.partVersion}
|
||||
instanceId={props.instanceId}
|
||||
sessionId={options.sessionId}
|
||||
forceCollapsed={options.forceCollapsed}
|
||||
/>
|
||||
)
|
||||
},
|
||||
scrollHelpers,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> }).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<typeof messageStoreBus.getOrCreate>
|
||||
sessionId: string
|
||||
renderToolCall: NonNullable<import("../types").ToolRendererContext["renderToolCall"]>
|
||||
}) {
|
||||
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 = {
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
<Show when={items().length > 0}>
|
||||
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||
<section class="tool-call-task-section">
|
||||
<header class="tool-call-task-section-header">
|
||||
<span class="tool-call-task-section-title">{t("toolCall.task.sections.steps")}</span>
|
||||
<span class="tool-call-task-section-meta">{t("toolCall.task.steps.count", { count: items().length })}</span>
|
||||
<span class="tool-call-task-section-meta">
|
||||
{t("toolCall.task.steps.count", { count: childToolKeys().length > 0 ? childToolKeys().length : legacyItems().length })}
|
||||
</span>
|
||||
</header>
|
||||
<div class="tool-call-task-section-body">
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
<Show
|
||||
when={childToolKeys().length > 0}
|
||||
fallback={
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={legacyItems()}>
|
||||
{(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 (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={items()}>
|
||||
{(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 (
|
||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
||||
<span class="tool-call-task-icon">{icon}</span>
|
||||
<span class="tool-call-task-label">{toolLabel}</span>
|
||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
||||
<span class="tool-call-task-text">{description}</span>
|
||||
<Show when={statusIcon}>
|
||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
||||
{statusIcon}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<div
|
||||
class="message-text tool-call-markdown tool-call-task-container"
|
||||
ref={scrollHelpers?.registerContainer}
|
||||
onScroll={
|
||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||
}
|
||||
>
|
||||
<div class="tool-call-task-summary">
|
||||
<For each={childToolKeys()}>
|
||||
{(key) => (
|
||||
<Show when={renderToolCall}>
|
||||
{(render) => (
|
||||
<TaskToolCallRow
|
||||
toolKey={key}
|
||||
store={store}
|
||||
sessionId={childSessionId()}
|
||||
renderToolCall={render()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
{scrollHelpers?.renderSentinel?.()}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</section>
|
||||
</Show>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
||||
toolCall: Accessor<ToolCallPart>
|
||||
toolState: Accessor<ToolState | undefined>
|
||||
toolName: Accessor<string>
|
||||
instanceId: string
|
||||
sessionId: string
|
||||
t: (key: string, params?: Record<string, unknown>) => string
|
||||
messageVersion?: Accessor<number | undefined>
|
||||
partVersion?: Accessor<number | undefined>
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user