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:
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user