fix(ui): render task steps from child session
This commit is contained in:
@@ -59,6 +59,11 @@ interface ToolCallProps {
|
|||||||
instanceId: string
|
instanceId: string
|
||||||
sessionId: string
|
sessionId: string
|
||||||
onContentRendered?: () => void
|
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 diagnosticsDefaultExpanded = createMemo(() => (preferences().diagnosticsExpansion || "expanded") === "expanded")
|
||||||
|
|
||||||
const defaultExpandedForTool = createMemo(() => {
|
const defaultExpandedForTool = createMemo(() => {
|
||||||
|
if (props.forceCollapsed) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const prefExpanded = toolOutputDefaultExpanded()
|
const prefExpanded = toolOutputDefaultExpanded()
|
||||||
const toolName = toolCallMemo()?.tool || ""
|
const toolName = toolCallMemo()?.tool || ""
|
||||||
if (toolName === "read") {
|
if (toolName === "read") {
|
||||||
@@ -575,12 +583,29 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
toolCall: toolCallMemo,
|
toolCall: toolCallMemo,
|
||||||
toolState,
|
toolState,
|
||||||
toolName,
|
toolName,
|
||||||
|
instanceId: props.instanceId,
|
||||||
|
sessionId: props.sessionId,
|
||||||
t,
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown: renderMarkdownContent,
|
renderMarkdown: renderMarkdownContent,
|
||||||
renderAnsi: renderAnsiContent,
|
renderAnsi: renderAnsiContent,
|
||||||
renderDiff: renderDiffContent,
|
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,
|
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 { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
import { resolveTitleForTool } from "../tool-title"
|
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 {
|
interface TaskSummaryItem {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,6 +17,70 @@ interface TaskSummaryItem {
|
|||||||
title?: string
|
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 {
|
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||||
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||||
return status
|
return status
|
||||||
@@ -78,7 +145,63 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
return describeTaskTitle(input)
|
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 promptContent = createMemo(() => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return null
|
if (!state) return null
|
||||||
@@ -123,7 +246,7 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
const items = createMemo(() => {
|
const legacyItems = createMemo(() => {
|
||||||
// Track the reactive change points so we only recompute when the part/message changes
|
// Track the reactive change points so we only recompute when the part/message changes
|
||||||
messageVersion?.()
|
messageVersion?.()
|
||||||
partVersion?.()
|
partVersion?.()
|
||||||
@@ -131,6 +254,9 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return []
|
if (!state) return []
|
||||||
|
|
||||||
|
// Prefer deriving steps from the child session when loaded.
|
||||||
|
if (childSessionLoaded()) return []
|
||||||
|
|
||||||
const { metadata } = readToolStatePayload(state)
|
const { metadata } = readToolStatePayload(state)
|
||||||
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
const summary = Array.isArray((metadata as any).summary) ? ((metadata as any).summary as any[]) : []
|
||||||
|
|
||||||
@@ -167,51 +293,84 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
<Show when={items().length > 0}>
|
<Show when={childToolKeys().length > 0 || legacyItems().length > 0}>
|
||||||
<section class="tool-call-task-section">
|
<section class="tool-call-task-section">
|
||||||
<header class="tool-call-task-section-header">
|
<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-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>
|
</header>
|
||||||
<div class="tool-call-task-section-body">
|
<div class="tool-call-task-section-body">
|
||||||
<div
|
<Show
|
||||||
class="message-text tool-call-markdown tool-call-task-container"
|
when={childToolKeys().length > 0}
|
||||||
ref={scrollHelpers?.registerContainer}
|
fallback={
|
||||||
onScroll={
|
<div
|
||||||
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
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">
|
<div
|
||||||
<For each={items()}>
|
class="message-text tool-call-markdown tool-call-task-container"
|
||||||
{(item) => {
|
ref={scrollHelpers?.registerContainer}
|
||||||
const icon = getToolIcon(item.tool)
|
onScroll={
|
||||||
const description = describeToolTitle(item)
|
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
|
||||||
const toolLabel = getToolName(item.tool)
|
}
|
||||||
const status = normalizeStatus(item.status ?? item.state?.status)
|
>
|
||||||
const statusIcon = summarizeStatusIcon(status)
|
<div class="tool-call-task-summary">
|
||||||
const statusKey = summarizeStatusLabel(status)
|
<For each={childToolKeys()}>
|
||||||
const statusLabel = statusKey
|
{(key) => (
|
||||||
? t(`toolCall.status.${statusKey}`)
|
<Show when={renderToolCall}>
|
||||||
: t("toolCall.status.unknown")
|
{(render) => (
|
||||||
const statusAttr = status ?? "pending"
|
<TaskToolCallRow
|
||||||
return (
|
toolKey={key}
|
||||||
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}>
|
store={store}
|
||||||
<span class="tool-call-task-icon">{icon}</span>
|
sessionId={childSessionId()}
|
||||||
<span class="tool-call-task-label">{toolLabel}</span>
|
renderToolCall={render()}
|
||||||
<span class="tool-call-task-separator" aria-hidden="true">—</span>
|
/>
|
||||||
<span class="tool-call-task-text">{description}</span>
|
)}
|
||||||
<Show when={statusIcon}>
|
</Show>
|
||||||
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}>
|
)}
|
||||||
{statusIcon}
|
</For>
|
||||||
</span>
|
</div>
|
||||||
</Show>
|
{scrollHelpers?.renderSentinel?.()}
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</For>
|
|
||||||
</div>
|
</div>
|
||||||
{scrollHelpers?.renderSentinel?.()}
|
</Show>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -74,12 +74,15 @@ function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
|||||||
toolCall: toolCallAccessor,
|
toolCall: toolCallAccessor,
|
||||||
toolState: toolStateAccessor,
|
toolState: toolStateAccessor,
|
||||||
toolName: toolNameAccessor,
|
toolName: toolNameAccessor,
|
||||||
|
instanceId: "",
|
||||||
|
sessionId: "",
|
||||||
t,
|
t,
|
||||||
messageVersion: messageVersionAccessor,
|
messageVersion: messageVersionAccessor,
|
||||||
partVersion: partVersionAccessor,
|
partVersion: partVersionAccessor,
|
||||||
renderMarkdown,
|
renderMarkdown,
|
||||||
renderAnsi,
|
renderAnsi,
|
||||||
renderDiff,
|
renderDiff,
|
||||||
|
renderToolCall: () => null,
|
||||||
scrollHelpers: undefined,
|
scrollHelpers: undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,12 +53,26 @@ export interface ToolRendererContext {
|
|||||||
toolCall: Accessor<ToolCallPart>
|
toolCall: Accessor<ToolCallPart>
|
||||||
toolState: Accessor<ToolState | undefined>
|
toolState: Accessor<ToolState | undefined>
|
||||||
toolName: Accessor<string>
|
toolName: Accessor<string>
|
||||||
|
instanceId: string
|
||||||
|
sessionId: string
|
||||||
t: (key: string, params?: Record<string, unknown>) => string
|
t: (key: string, params?: Record<string, unknown>) => string
|
||||||
messageVersion?: Accessor<number | undefined>
|
messageVersion?: Accessor<number | undefined>
|
||||||
partVersion?: Accessor<number | undefined>
|
partVersion?: Accessor<number | undefined>
|
||||||
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
renderMarkdown(options: MarkdownRenderOptions): JSXElement | null
|
||||||
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
renderAnsi(options: AnsiRenderOptions): JSXElement | null
|
||||||
renderDiff(payload: DiffPayload, options?: DiffRenderOptions): 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
|
scrollHelpers?: ToolScrollHelpers
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,42 @@
|
|||||||
margin: 0;
|
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 {
|
.tool-call-task-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user