feat(ui): render task prompt/output panes

Task tool calls now show prompt, summary, and output with independent scroll; markdown rendering supports cache keys to avoid collisions.
This commit is contained in:
Shantur Rathore
2026-01-23 22:39:04 +00:00
parent f0b43dbc68
commit 3d3337c7b8
4 changed files with 194 additions and 42 deletions

View File

@@ -23,20 +23,26 @@ export function createMarkdownContentRenderer(params: {
const size = options.size || "default" const size = options.size || "default"
const disableHighlight = options.disableHighlight || false const disableHighlight = options.disableHighlight || false
const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}` const messageClass = `message-text tool-call-markdown${size === "large" ? " tool-call-markdown-large" : ""}`
const disableScrollTracking = options.disableScrollTracking || false
const state = params.toolState() const state = params.toolState()
const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight) const shouldDeferMarkdown = Boolean(state && (state.status === "running" || state.status === "pending") && disableHighlight)
if (shouldDeferMarkdown) { if (shouldDeferMarkdown) {
return ( return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> <div
class={messageClass}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre> <pre class="whitespace-pre-wrap break-words text-sm font-mono">{options.content}</pre>
{params.scrollHelpers.renderSentinel()} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )
} }
const cacheKey = typeof options.cacheKey === "string" && options.cacheKey.length > 0 ? options.cacheKey : undefined
const markdownPart: TextPart = { const markdownPart: TextPart = {
id: params.partId(), id: cacheKey ? `${params.partId()}:${cacheKey}` : params.partId(),
type: "text", type: "text",
text: options.content, text: options.content,
version: params.partVersion?.(), version: params.partVersion?.(),
@@ -48,7 +54,11 @@ export function createMarkdownContentRenderer(params: {
} }
return ( return (
<div class={messageClass} ref={(element) => params.scrollHelpers.registerContainer(element)} onScroll={params.scrollHelpers.handleScroll}> <div
class={messageClass}
ref={(element) => params.scrollHelpers.registerContainer(element, { disableTracking: disableScrollTracking })}
onScroll={disableScrollTracking ? undefined : params.scrollHelpers.handleScroll}
>
<Markdown <Markdown
part={markdownPart} part={markdownPart}
instanceId={params.instanceId} instanceId={params.instanceId}
@@ -57,7 +67,7 @@ export function createMarkdownContentRenderer(params: {
disableHighlight={disableHighlight} disableHighlight={disableHighlight}
onRendered={handleMarkdownRendered} onRendered={handleMarkdownRendered}
/> />
{params.scrollHelpers.renderSentinel()} {params.scrollHelpers.renderSentinel({ disableTracking: disableScrollTracking })}
</div> </div>
) )
} }

View File

@@ -1,8 +1,7 @@
import { For, Show, createMemo } from "solid-js" import { For, Show, createMemo } 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 { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils" import { ensureMarkdownContent, getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
import { getTodoTitle } from "./todo"
import { resolveTitleForTool } from "../tool-title" import { resolveTitleForTool } from "../tool-title"
interface TaskSummaryItem { interface TaskSummaryItem {
@@ -90,7 +89,29 @@ export const taskRenderer: ToolRenderer = {
const { input } = readToolStatePayload(state) const { input } = readToolStatePayload(state)
return describeTaskTitle(input) return describeTaskTitle(input)
}, },
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) { renderBody({ toolState, messageVersion, partVersion, scrollHelpers, renderMarkdown }) {
const promptContent = createMemo(() => {
const state = toolState()
if (!state) return null
const { input } = readToolStatePayload(state)
const prompt = typeof input.prompt === "string" ? input.prompt : null
return ensureMarkdownContent(prompt, undefined, false)
})
const outputContent = createMemo(() => {
const state = toolState()
if (!state) return null
const output = typeof (state as { output?: unknown }).output === "string" ? ((state as { output?: string }).output as string) : null
return ensureMarkdownContent(output, undefined, false)
})
const agentLabel = createMemo(() => {
const state = toolState()
if (!state) return null
const { input } = readToolStatePayload(state)
return typeof input.subagent_type === "string" ? input.subagent_type : null
})
const items = createMemo(() => { const items = 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?.()
@@ -114,41 +135,90 @@ export const taskRenderer: ToolRenderer = {
}) })
}) })
if (items().length === 0) return null
return ( return (
<div <div class="tool-call-task-sections">
class="message-text tool-call-markdown tool-call-task-container" <Show when={promptContent()}>
ref={(element) => scrollHelpers?.registerContainer(element)} <section class="tool-call-task-section">
onScroll={scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined} <header class="tool-call-task-section-header">
> <span class="tool-call-task-section-title">Prompt</span>
<div class="tool-call-task-summary"> <Show when={agentLabel()}>
<For each={items()}> <span class="tool-call-task-section-meta">Agent: {agentLabel()}</span>
{(item) => { </Show>
const icon = getToolIcon(item.tool) </header>
const description = describeToolTitle(item) <div class="tool-call-task-section-body">
const toolLabel = getToolName(item.tool) {renderMarkdown({
const status = normalizeStatus(item.status ?? item.state?.status) content: promptContent()!,
const statusIcon = summarizeStatusIcon(status) cacheKey: "task:prompt",
const statusLabel = summarizeStatusLabel(status) disableScrollTracking: true,
const statusAttr = status ?? "pending" disableHighlight: true,
return ( })}
<div class="tool-call-task-item" data-task-id={item.id} data-task-status={statusAttr}> </div>
<span class="tool-call-task-icon">{icon}</span> </section>
<span class="tool-call-task-label">{toolLabel}</span> </Show>
<span class="tool-call-task-separator" aria-hidden="true"></span>
<span class="tool-call-task-text">{description}</span> <Show when={items().length > 0}>
<Show when={statusIcon}> <section class="tool-call-task-section">
<span class="tool-call-task-status" aria-label={statusLabel} title={statusLabel}> <header class="tool-call-task-section-header">
{statusIcon} <span class="tool-call-task-section-title">Tasks</span>
</span> <span class="tool-call-task-section-meta">{items().length} item(s)</span>
</Show> </header>
<div class="tool-call-task-section-body">
<div
class="message-text tool-call-markdown tool-call-task-container"
ref={(element) => scrollHelpers?.registerContainer(element)}
onScroll={
scrollHelpers ? (event) => scrollHelpers.handleScroll(event as Event & { currentTarget: HTMLDivElement }) : undefined
}
>
<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 statusLabel = summarizeStatusLabel(status)
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> </div>
) {scrollHelpers?.renderSentinel?.()}
}} </div>
</For> </div>
</div> </section>
{scrollHelpers?.renderSentinel?.()} </Show>
<Show when={outputContent()}>
<section class="tool-call-task-section">
<header class="tool-call-task-section-header">
<span class="tool-call-task-section-title">Output</span>
<Show when={agentLabel()}>
<span class="tool-call-task-section-meta">Agent: {agentLabel()}</span>
</Show>
</header>
<div class="tool-call-task-section-body">
{renderMarkdown({
content: outputContent()!,
cacheKey: "task:output",
disableScrollTracking: true,
})}
</div>
</section>
</Show>
</div> </div>
) )
}, },

View File

@@ -13,6 +13,16 @@ export interface MarkdownRenderOptions {
content: string content: string
size?: "default" | "large" size?: "default" | "large"
disableHighlight?: boolean disableHighlight?: boolean
/**
* Optional suffix to avoid render-cache collisions when a tool call renders
* multiple markdown regions (e.g. task prompt vs task output).
*/
cacheKey?: string
/**
* When true, do not register this markdown region with tool-call scroll
* tracking (avoids nested scroll + autoscroll interactions).
*/
disableScrollTracking?: boolean
} }
export interface AnsiRenderOptions { export interface AnsiRenderOptions {

View File

@@ -1,7 +1,69 @@
.tool-call-task-container { .tool-call-task-sections {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: 0;
}
.tool-call-task-section {
border: 1px solid var(--border-base);
overflow: hidden;
background-color: transparent;
border-radius: 0;
}
.tool-call-task-section-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem;
background-color: var(--surface-secondary);
border-bottom: 1px solid var(--border-base);
font-family: var(--font-family-mono);
font-size: 13px;
color: inherit;
}
.tool-call-task-section-title {
font-weight: var(--font-weight-semibold);
}
.tool-call-task-section-meta {
font-family: var(--font-family-mono);
color: var(--text-muted);
}
.tool-call-task-section-body {
background-color: var(--surface-code);
}
.tool-call-task-section-body .tool-call-markdown {
padding: 12px; padding: 12px;
} }
.tool-call-task-container {
padding: 0;
}
/* Keep task lists compact vs prompt/output panes. */
.tool-call-task-container.tool-call-markdown {
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) / 2);
}
/* Prompt + output panes: slightly taller than tasks. */
.tool-call-task-section-body > .tool-call-markdown:not(.tool-call-task-container) {
max-height: calc(var(--tool-call-max-height-compact, calc(25 * 1.4em)) * 2 / 3);
}
.tool-call-task-empty {
font-family: var(--font-family-mono);
font-size: var(--font-size-xs);
line-height: var(--line-height-tight);
color: var(--text-muted);
padding: 0.5rem;
}
.tool-call-task-summary { .tool-call-task-summary {
display: flex; display: flex;
flex-direction: column; flex-direction: column;