Sync tool-call titles and task summaries
This commit is contained in:
@@ -17,7 +17,8 @@ import type {
|
|||||||
ToolRendererContext,
|
ToolRendererContext,
|
||||||
ToolScrollHelpers,
|
ToolScrollHelpers,
|
||||||
} from "./tool-call/types"
|
} from "./tool-call/types"
|
||||||
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning } from "./tool-call/utils"
|
import { getRelativePath, getToolIcon, getToolName, isToolStateCompleted, isToolStateError, isToolStateRunning, getDefaultToolAction } from "./tool-call/utils"
|
||||||
|
import { resolveTitleForTool } from "./tool-call/tool-title"
|
||||||
import { getLogger } from "../lib/logger"
|
import { getLogger } from "../lib/logger"
|
||||||
|
|
||||||
const log = getLogger("session")
|
const log = getLogger("session")
|
||||||
@@ -710,6 +711,12 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
|
|
||||||
const renderToolTitle = () => {
|
const renderToolTitle = () => {
|
||||||
const state = toolState()
|
const state = toolState()
|
||||||
|
const currentTool = toolName()
|
||||||
|
|
||||||
|
if (currentTool !== "task") {
|
||||||
|
return resolveTitleForTool({ toolName: currentTool, state })
|
||||||
|
}
|
||||||
|
|
||||||
if (!state) return getRendererAction()
|
if (!state) return getRendererAction()
|
||||||
if (state.status === "pending") return getRendererAction()
|
if (state.status === "pending") return getRendererAction()
|
||||||
|
|
||||||
@@ -724,7 +731,7 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
return state.title
|
return state.title
|
||||||
}
|
}
|
||||||
|
|
||||||
return getToolName(toolName())
|
return getToolName(currentTool)
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderToolBody = () => {
|
const renderToolBody = () => {
|
||||||
@@ -918,33 +925,3 @@ export default function ToolCall(props: ToolCallProps) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultToolAction(toolName: string) {
|
|
||||||
switch (toolName) {
|
|
||||||
case "task":
|
|
||||||
return "Delegating..."
|
|
||||||
case "bash":
|
|
||||||
return "Writing command..."
|
|
||||||
case "edit":
|
|
||||||
return "Preparing edit..."
|
|
||||||
case "webfetch":
|
|
||||||
return "Fetching from the web..."
|
|
||||||
case "glob":
|
|
||||||
return "Finding files..."
|
|
||||||
case "grep":
|
|
||||||
return "Searching content..."
|
|
||||||
case "list":
|
|
||||||
return "Listing directory..."
|
|
||||||
case "read":
|
|
||||||
return "Reading file..."
|
|
||||||
case "write":
|
|
||||||
return "Preparing write..."
|
|
||||||
case "todowrite":
|
|
||||||
case "todoread":
|
|
||||||
return "Planning..."
|
|
||||||
case "patch":
|
|
||||||
return "Preparing patch..."
|
|
||||||
default:
|
|
||||||
return "Working..."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,25 +1,84 @@
|
|||||||
import { For, createMemo } from "solid-js"
|
import { For, Show, createMemo } from "solid-js"
|
||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
import type { ToolRenderer } from "../types"
|
import type { ToolRenderer } from "../types"
|
||||||
import { getRelativePath, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
import { getDefaultToolAction, getToolIcon, getToolName, readToolStatePayload } from "../utils"
|
||||||
|
import { getTodoTitle } from "./todo"
|
||||||
|
import { resolveTitleForTool } from "../tool-title"
|
||||||
|
|
||||||
interface TaskSummaryItem {
|
interface TaskSummaryItem {
|
||||||
id: string
|
id: string
|
||||||
tool: string
|
tool: string
|
||||||
input: Record<string, any>
|
input: Record<string, any>
|
||||||
|
metadata: Record<string, any>
|
||||||
|
state?: ToolState
|
||||||
|
status?: ToolState["status"]
|
||||||
|
title?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function describeTaskItem(item: TaskSummaryItem): string {
|
function normalizeStatus(status?: string | null): ToolState["status"] | undefined {
|
||||||
const input = item.input || {}
|
if (status === "pending" || status === "running" || status === "completed" || status === "error") {
|
||||||
switch (item.tool) {
|
return status
|
||||||
case "bash":
|
|
||||||
return typeof input.description === "string" ? input.description : input.command || "bash"
|
|
||||||
case "edit":
|
|
||||||
case "read":
|
|
||||||
case "write":
|
|
||||||
return `${item.tool} ${getRelativePath(typeof input.filePath === "string" ? input.filePath : "")}`.trim()
|
|
||||||
default:
|
|
||||||
return item.tool
|
|
||||||
}
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStatusIcon(status?: ToolState["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "⏸"
|
||||||
|
case "running":
|
||||||
|
return "⏳"
|
||||||
|
case "completed":
|
||||||
|
return "✓"
|
||||||
|
case "error":
|
||||||
|
return "✗"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeStatusLabel(status?: ToolState["status"]) {
|
||||||
|
switch (status) {
|
||||||
|
case "pending":
|
||||||
|
return "Pending"
|
||||||
|
case "running":
|
||||||
|
return "Running"
|
||||||
|
case "completed":
|
||||||
|
return "Completed"
|
||||||
|
case "error":
|
||||||
|
return "Error"
|
||||||
|
default:
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeTaskTitle(input: Record<string, any>) {
|
||||||
|
const description = typeof input.description === "string" ? input.description : undefined
|
||||||
|
const subagent = typeof input.subagent_type === "string" ? input.subagent_type : undefined
|
||||||
|
const base = getToolName("task")
|
||||||
|
if (description && subagent) {
|
||||||
|
return `${base}[${subagent}] ${description}`
|
||||||
|
}
|
||||||
|
if (description) {
|
||||||
|
return `${base} ${description}`
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeToolTitle(item: TaskSummaryItem): string {
|
||||||
|
if (item.title && item.title.length > 0) {
|
||||||
|
return item.title
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.tool === "task") {
|
||||||
|
return describeTaskTitle({ ...item.metadata, ...item.input })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.state) {
|
||||||
|
return resolveTitleForTool({ toolName: item.tool, state: item.state })
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDefaultToolAction(item.tool)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const taskRenderer: ToolRenderer = {
|
export const taskRenderer: ToolRenderer = {
|
||||||
@@ -29,18 +88,9 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
const state = toolState()
|
const state = toolState()
|
||||||
if (!state) return undefined
|
if (!state) return undefined
|
||||||
const { input } = readToolStatePayload(state)
|
const { input } = readToolStatePayload(state)
|
||||||
const description = input.description
|
return describeTaskTitle(input)
|
||||||
const subagent = input.subagent_type
|
|
||||||
const base = getToolName("task")
|
|
||||||
if (description && subagent) {
|
|
||||||
return `${base}[${subagent}] ${description}`
|
|
||||||
}
|
|
||||||
if (description) {
|
|
||||||
return `${base} ${description}`
|
|
||||||
}
|
|
||||||
return base
|
|
||||||
},
|
},
|
||||||
renderBody({ toolState, toolCall, messageVersion, partVersion, scrollHelpers }) {
|
renderBody({ toolState, messageVersion, partVersion, scrollHelpers }) {
|
||||||
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?.()
|
||||||
@@ -54,9 +104,13 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
|
|
||||||
return summary.map((entry, index) => {
|
return summary.map((entry, index) => {
|
||||||
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
const tool = typeof entry?.tool === "string" ? (entry.tool as string) : "unknown"
|
||||||
const input = typeof (entry as any)?.state?.input === "object" && entry.state?.input ? entry.state.input : {}
|
const stateValue = typeof entry?.state === "object" ? (entry.state as ToolState) : undefined
|
||||||
|
const metadataFromEntry = typeof entry?.metadata === "object" && entry.metadata ? entry.metadata : {}
|
||||||
|
const fallbackInput = typeof entry?.input === "object" && entry.input ? entry.input : {}
|
||||||
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
const id = typeof entry?.id === "string" && entry.id.length > 0 ? entry.id : `${tool}-${index}`
|
||||||
return { id, tool, input }
|
const statusValue = normalizeStatus((entry?.status as string | undefined) ?? stateValue?.status)
|
||||||
|
const title = typeof entry?.title === "string" ? entry.title : undefined
|
||||||
|
return { id, tool, input: fallbackInput, metadata: metadataFromEntry, state: stateValue, status: statusValue, title }
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,11 +126,23 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
<For each={items()}>
|
<For each={items()}>
|
||||||
{(item) => {
|
{(item) => {
|
||||||
const icon = getToolIcon(item.tool)
|
const icon = getToolIcon(item.tool)
|
||||||
const description = describeTaskItem(item)
|
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 (
|
return (
|
||||||
<div class="tool-call-task-item" data-task-id={item.id}>
|
<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-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>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
@@ -87,4 +153,3 @@ export const taskRenderer: ToolRenderer = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function getTodoStatusLabel(status: TodoViewStatus): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTodoTitle(state?: ToolState): string {
|
export function getTodoTitle(state?: ToolState): string {
|
||||||
if (!state) return "Plan"
|
if (!state) return "Plan"
|
||||||
|
|
||||||
const todos = extractTodosFromState(state)
|
const todos = extractTodosFromState(state)
|
||||||
|
|||||||
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
86
packages/ui/src/components/tool-call/tool-title.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ToolState } from "@opencode-ai/sdk"
|
||||||
|
import type { ToolRendererContext, ToolRenderer, ToolCallPart } from "./types"
|
||||||
|
import { getDefaultToolAction, getToolName, isToolStateCompleted, isToolStateRunning } from "./utils"
|
||||||
|
import { defaultRenderer } from "./renderers/default"
|
||||||
|
import { bashRenderer } from "./renderers/bash"
|
||||||
|
import { readRenderer } from "./renderers/read"
|
||||||
|
import { writeRenderer } from "./renderers/write"
|
||||||
|
import { editRenderer } from "./renderers/edit"
|
||||||
|
import { patchRenderer } from "./renderers/patch"
|
||||||
|
import { webfetchRenderer } from "./renderers/webfetch"
|
||||||
|
import { todoRenderer } from "./renderers/todo"
|
||||||
|
import { invalidRenderer } from "./renderers/invalid"
|
||||||
|
|
||||||
|
const TITLE_RENDERERS: Record<string, ToolRenderer> = {
|
||||||
|
bash: bashRenderer,
|
||||||
|
read: readRenderer,
|
||||||
|
write: writeRenderer,
|
||||||
|
edit: editRenderer,
|
||||||
|
patch: patchRenderer,
|
||||||
|
webfetch: webfetchRenderer,
|
||||||
|
todowrite: todoRenderer,
|
||||||
|
todoread: todoRenderer,
|
||||||
|
invalid: invalidRenderer,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TitleSnapshot {
|
||||||
|
toolName: string
|
||||||
|
state?: ToolState
|
||||||
|
}
|
||||||
|
|
||||||
|
function lookupRenderer(toolName: string): ToolRenderer {
|
||||||
|
return TITLE_RENDERERS[toolName] ?? defaultRenderer
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStaticToolPart(snapshot: TitleSnapshot): ToolCallPart {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
type: "tool",
|
||||||
|
tool: snapshot.toolName,
|
||||||
|
state: snapshot.state,
|
||||||
|
} as ToolCallPart
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStaticContext(snapshot: TitleSnapshot): ToolRendererContext {
|
||||||
|
const toolStateAccessor = () => snapshot.state
|
||||||
|
const toolNameAccessor = () => snapshot.toolName
|
||||||
|
const toolCallAccessor = () => createStaticToolPart(snapshot)
|
||||||
|
const messageVersionAccessor = () => undefined
|
||||||
|
const partVersionAccessor = () => undefined
|
||||||
|
const renderMarkdown: ToolRendererContext["renderMarkdown"] = () => null
|
||||||
|
const renderDiff: ToolRendererContext["renderDiff"] = () => null
|
||||||
|
|
||||||
|
return {
|
||||||
|
toolCall: toolCallAccessor,
|
||||||
|
toolState: toolStateAccessor,
|
||||||
|
toolName: toolNameAccessor,
|
||||||
|
messageVersion: messageVersionAccessor,
|
||||||
|
partVersion: partVersionAccessor,
|
||||||
|
renderMarkdown,
|
||||||
|
renderDiff,
|
||||||
|
scrollHelpers: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTitleForTool(snapshot: TitleSnapshot): string {
|
||||||
|
const renderer = lookupRenderer(snapshot.toolName)
|
||||||
|
const context = createStaticContext(snapshot)
|
||||||
|
const state = snapshot.state
|
||||||
|
const defaultAction = renderer.getAction?.(context) ?? getDefaultToolAction(snapshot.toolName)
|
||||||
|
|
||||||
|
if (!state || state.status === "pending") {
|
||||||
|
return defaultAction
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateTitle = typeof (state as { title?: string }).title === "string" ? (state as { title?: string }).title : undefined
|
||||||
|
if (stateTitle && stateTitle.length > 0) {
|
||||||
|
return stateTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
const customTitle = renderer.getTitle?.(context)
|
||||||
|
if (customTitle) {
|
||||||
|
return customTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
return getToolName(snapshot.toolName)
|
||||||
|
}
|
||||||
@@ -192,3 +192,33 @@ export function readToolStatePayload(state?: ToolState): {
|
|||||||
output: isToolStateCompleted(state) ? state.output : undefined,
|
output: isToolStateCompleted(state) ? state.output : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDefaultToolAction(toolName: string) {
|
||||||
|
switch (toolName) {
|
||||||
|
case "task":
|
||||||
|
return "Delegating..."
|
||||||
|
case "bash":
|
||||||
|
return "Writing command..."
|
||||||
|
case "edit":
|
||||||
|
return "Preparing edit..."
|
||||||
|
case "webfetch":
|
||||||
|
return "Fetching from the web..."
|
||||||
|
case "glob":
|
||||||
|
return "Finding files..."
|
||||||
|
case "grep":
|
||||||
|
return "Searching content..."
|
||||||
|
case "list":
|
||||||
|
return "Listing directory..."
|
||||||
|
case "read":
|
||||||
|
return "Reading file..."
|
||||||
|
case "write":
|
||||||
|
return "Preparing write..."
|
||||||
|
case "todowrite":
|
||||||
|
case "todoread":
|
||||||
|
return "Planning..."
|
||||||
|
case "patch":
|
||||||
|
return "Preparing patch..."
|
||||||
|
default:
|
||||||
|
return "Working..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,17 +3,77 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-summary {
|
.tool-call-task-summary {
|
||||||
@apply my-2 flex flex-col gap-1.5;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-item {
|
.tool-call-task-item {
|
||||||
font-size: var(--font-size-xs);
|
display: flex;
|
||||||
line-height: var(--line-height-normal);
|
align-items: center;
|
||||||
padding-left: 8px;
|
gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.5rem 0.35rem 0.75rem;
|
||||||
border-left: 2px solid var(--border-base);
|
border-left: 2px solid var(--border-base);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
font-family: var(--font-family-mono);
|
||||||
|
line-height: 1.35;
|
||||||
|
background-color: var(--surface-code);
|
||||||
|
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tool-call-task-item::before {
|
.tool-call-task-item + .tool-call-task-item {
|
||||||
content: "∟ ";
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item:hover {
|
||||||
|
background-color: var(--surface-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="completed"] {
|
||||||
|
border-left-color: var(--status-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="running"] {
|
||||||
|
border-left-color: var(--status-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="pending"] {
|
||||||
|
border-left-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-item[data-task-status="error"] {
|
||||||
|
border-left-color: var(--status-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-icon {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-call-task-label {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-separator {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-call-task-status {
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user