feat(browser): tool-name capsules reveal the tool's schema row

A tool-name capsule — a row tag (tool results, call breadcrumbs) or a
call head (→ bash, ← bash) — is now clickable and reveals the tool's
definition row in the Tool Schemas category: the category opens
unfiltered, the named row expands, and a one-shot scroll lands on it
(the same reveal the DNA bands use). A breadcrumb renders one clickable
segment per call; its segment click stops propagation so the row toggle
cannot overwrite the reveal. Tags that do not name tools stay inert.

Hover affordances: tag segments lift to the primary label on the shared
interactive hover face; call heads shift to the brand face + underline.
This commit is contained in:
bowenliang123 committed 2026-09-29 02:21:07 +08:00
1 parent 213efeef15
commit 5a62b2b306
5 files changed
+111 -7

No files matched your search

+67 -7
View File
@@ -1,4 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ReactElement, type ReactNode } from 'react'
import { Fragment, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent, type ReactElement, type ReactNode } from 'react'
import { UNKNOWN_TOOL_SOURCE, type Category, type ContextHeaders, type ContextTimeline, type HeaderTool, type RequestRecord, type SurfaceNode } from '../../shared/types'
import { assemble } from '../assemble'
import type { Assembled } from '../assemble'
@@ -170,6 +170,8 @@ function Section(props: {
count?: number
actions?: ReactNode
meta?: ReactNode
/** Makes the head label a click affordance (the call head's schema reveal); hover styling rides the link class. */
onLabelClick?: () => void
children: ReactNode
}): ReactElement {
const right = props.actions !== undefined || props.meta !== undefined
@@ -177,7 +179,11 @@ function Section(props: {
<div className="lc-ts-card">
<div className={'lc-ts-card-head' + (props.foldHead === true ? ' lc-ts-card-head-wrap' : '')}>
{/* The title recovers an ellipsized label: long mono call names truncate under width pressure. */}
<b className={props.labelClass} title={props.label}>{props.label}</b>
<b
className={(props.labelClass ?? '') + (props.onLabelClick !== undefined ? ' lc-ts-label-link' : '')}
title={props.label}
onClick={props.onLabelClick}
>{props.label}</b>
{right ? <span className="lc-ts-card-right">{props.meta}{props.actions}</span> : null}
{props.count !== undefined ? <span className="lc-ts-card-count">{props.count}</span> : null}
</div>
@@ -356,6 +362,8 @@ function BlocksBody(props: {
rich: RichKit
img: ImageKit
labels: DetailLabels
/** The call head's schema reveal, threaded to every call card and the nested tool-result recursion. */
onSchema: (name: string) => void
}): ReactElement {
const { rich, img, labels } = props
const out: ReactNode[] = []
@@ -398,6 +406,7 @@ function BlocksBody(props: {
key={out.length}
name={typeof blk?.name === 'string' ? blk.name : '?'}
argsRaw={blk?.argsRaw ?? blk?.arguments}
onSchema={props.onSchema}
/>)
continue
}
@@ -410,6 +419,7 @@ function BlocksBody(props: {
rich={rich}
img={img}
labels={labels}
onSchema={props.onSchema}
/>)
continue
}
@@ -471,13 +481,17 @@ function ToolCallCard(props: {
argsRaw: unknown
arrow?: string
status?: ReactNode
/** Reveals the tool's definition row in the Tool Schemas category. */
onSchema: (name: string) => void
}): ReactElement {
const args = useMemo(() => parseCallArgs(props.argsRaw), [props.argsRaw])
const onSchema = props.onSchema
return (
<Section
label={(props.arrow ?? '→') + ' ' + props.name}
labelClass="lc-ts-call-name"
meta={props.status}
onLabelClick={() => { onSchema(props.name) }}
>
{args !== null
? Object.keys(args).map(k => <CallArgRow key={k} name={k} value={args[k]} />)
@@ -510,6 +524,8 @@ function NodeContent(props: {
rich: RichKit
img: ImageKit
labels: DetailLabels
/** The call head's schema reveal, threaded to every block body this node renders. */
onSchema: (name: string) => void
}): ReactElement {
const { node, conv, rich, img, labels } = props
if (conv === undefined) {
@@ -526,7 +542,10 @@ function NodeContent(props: {
)
}
if (conv.kind === 'assistant' && Array.isArray(conv.blocks)) {
return <BlocksBody blocks={conv.blocks} richable textLabel={labels.answer} rich={rich} img={img} labels={labels} />
return <BlocksBody
blocks={conv.blocks} richable textLabel={labels.answer}
rich={rich} img={img} labels={labels} onSchema={props.onSchema}
/>
}
if (conv.kind === 'tool-result') {
const { err, exit } = toolErrOf(node, conv)
@@ -538,10 +557,14 @@ function NodeContent(props: {
name={conv.call.name}
argsRaw={conv.call.argsRaw}
status={labels.callState(err, exit)}
onSchema={props.onSchema}
/>
: null}
{Array.isArray(conv.content)
? <BlocksBody blocks={conv.content} richable={false} textLabel={labels.result} rich={rich} img={img} labels={labels} />
? <BlocksBody
blocks={conv.content} richable={false} textLabel={labels.result}
rich={rich} img={img} labels={labels} onSchema={props.onSchema}
/>
: null}
</>
)
@@ -552,7 +575,10 @@ function NodeContent(props: {
: <></>
}
if (Array.isArray(conv.content)) {
return <BlocksBody blocks={conv.content} richable textLabel={labels.content} rich={rich} img={img} labels={labels} />
return <BlocksBody
blocks={conv.content} richable textLabel={labels.content}
rich={rich} img={img} labels={labels} onSchema={props.onSchema}
/>
}
return <div className="lc-br-note">{props.hint}</div>
}
@@ -840,6 +866,17 @@ export function makeContextBrowser(
focusScrollRef.current = true
}
// A tool-name capsule (a row tag or a call head) reveals the tool's definition row in the Tool Schemas category —
// the same open + unfilter + one-shot scroll reveal the DNA bands use. A name the header epoch does not declare
// still lands on the category, where the filter can find it.
const revealSchema = (name: string): void => {
setCat('tools')
setRowQuery('')
setRowKind(null)
setOpenElem('tool:' + name)
focusScrollRef.current = true
}
const toolCount = (c: string): number => countOf(view, byCat, c)
// A category holding exactly one item opens that row with the category, so
@@ -901,6 +938,23 @@ export function makeContextBrowser(
)
}
// A tool-named tag renders each name as a clickable segment (a breadcrumb 'bash › write' names one tool per
// segment); every other tag stays inert text. The segment click stops propagation — the row button owns the
// toggle, and a bubbling toggle would overwrite the reveal's open element.
const rowTagNode = (tag: string | null, names: readonly string[] | null): ReactNode => {
if (tag === null || names === null) return tag
return names.map((name, i) => (
<Fragment key={name + String(i)}>
{i > 0 ? ' › ' : null}
<span
className="lc-br-tag-link"
title={t('browser.schemaTip')}
onClick={(e) => { e.stopPropagation(); revealSchema(name) }}
>{name}</span>
</Fragment>
))
}
const catBody = (c: string): ReactNode => {
if (c === 'system') {
// No prompt in force at this step. The category only opens without one
@@ -1033,10 +1087,14 @@ export function makeContextBrowser(
// Tag carries the compact fact (tool name, injection form) — one shared subtle chip style; the preview line carries the text — each
// fact shown once.
let tag: string | null = null
// The tool names the tag carries, when it names tools (tool rows, a call breadcrumb) — they render as
// clickable segments revealing the tool's schema; every other tag stays inert text.
let tagNames: string[] | null = null
let preview = nodeText(n)
const id = nodeNameOf(n)
if (n.cat === 'tool') {
tag = n.tool ?? '?'
tagNames = n.tool !== undefined ? [n.tool] : null
preview = callSummaryOf(conv) ?? t('node.toolResult')
} else if (n.cat === 'skill') {
// Skill content (issue #66): a load/invocation names itself, and a
@@ -1054,6 +1112,7 @@ export function makeContextBrowser(
const names = Array.isArray(n.calls) && n.calls.length > 0 ? n.calls : callNamesOf(conv)
if (names.length > 0) {
tag = names.join(' › ')
tagNames = names
preview = (n.text !== undefined && n.text !== '' ? n.text : null)
?? blockSummaryOf(conv)
?? t('node.empty')
@@ -1081,7 +1140,7 @@ export function makeContextBrowser(
preview = n.form === 'snapshot' ? t('node.snapshot') + n.text : n.text
}
}
return { n, conv, rowErr, tag, preview, kinds: msgKindsOf(n, conv) }
return { n, conv, rowErr, tag, tagNames, preview, kinds: msgKindsOf(n, conv) }
})
const q = rowQuery.trim().toLowerCase()
// The assistant category's kind chips: per-kind message counts over ALL
@@ -1123,12 +1182,13 @@ export function makeContextBrowser(
return (
<div>
{rowctl}
{shown.map(({ n, conv, rowErr, tag, preview }) => elemRow(`n${n.seq}`, tag, preview, n.tokens, n.time,
{shown.map(({ n, conv, rowErr, tag, tagNames, preview }) => elemRow(`n${n.seq}`, rowTagNode(tag, tagNames), preview, n.tokens, n.time,
<NodeContent
node={n}
conv={conv}
rich={rich}
img={{ Card: ImageCard, load: props.loadImage }}
onSchema={revealSchema}
// Localized section titles handed in by the parent so the body stays a pure function of props.
labels={{
thinking: t('block.thinking'),
+2
View File
@@ -270,6 +270,7 @@ export const DICT_ZH: Record<string, string> = {
'browser.kind.tool': '工具',
'browser.kind.answer': '回答',
'browser.kindTip': '按内容种类过滤回复:思考、工具调用、回答文本',
'browser.schemaTip': '展开下方工具定义中的同名工具',
'browser.rowNoMatch': '没有匹配当前过滤条件的行',
'browser.loading': '正在从更早的会话历史加载完整内容…',
'browser.notInLog': '会话日志中未找到该条目的完整内容(可能已被压缩或清理)',
@@ -664,6 +665,7 @@ export const DICT_EN: Record<string, string> = {
'browser.kind.tool': 'Tools',
'browser.kind.answer': 'Answer',
'browser.kindTip': 'Filter replies by content kind: thinking, tool calls, or answer text',
'browser.schemaTip': 'Reveal this tool’s definition in Tool Schemas',
'browser.rowNoMatch': 'No rows match the current filter',
'browser.loading': 'Loading full content from older session history…',
'browser.notInLog': 'This item is not in the session log anymore (it may have been compacted away or cleared)',
+4
View File
@@ -54,6 +54,10 @@
.lc-br-err-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--dsw-alias-state-error-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent); }
/* Shrinkable under width pressure (min-width: 0) so a long tool/call tag ellipsizes instead of forcing the row past the card edge. */
.lc-br-tag { min-width: 0; font-size: 11px; color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-bg-layer-1); border: 1px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 0 6px; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Clickable tool-name segment inside a row tag (the row button owns the toggle; the segment click stops propagation).
The ±2px padding/margin swap keeps the hover face hugging the name without shifting the row's layout. */
.lc-br-tag-link { cursor: pointer; padding: 0 2px; margin: 0 -2px; border-radius: 3px; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-br-tag-link:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
/* Registering-plugin chip on tool-schema rows: package/server names scan on a
brand-tinted pill (the badge tint idiom), in the same face and size as the
tool name they tag (the row inherits the UI font at 12px) so the attribution
+4
View File
@@ -16,6 +16,10 @@
group out of the section card; the Section's label title recovers it.
*/
.lc-ts-call-name { font-size: 13px; font-family: var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Clickable call-name head (→ bash): reveals the tool's schema row in the Tool Schemas category. Hover shifts the
name to the brand face + underline — the plugin's link language — over the muted head label it replaces. */
.lc-ts-label-link { cursor: pointer; transition: color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-ts-label-link:hover { color: var(--dsw-alias-brand-primary); text-decoration: underline; text-underline-offset: 2px; }
.lc-ts-card-count { margin-left: auto; }
.lc-ts-card-right { margin-left: auto; display: inline-flex; align-items: center; gap: 8px; }
.lc-ts-card-meta { color: var(--dsw-alias-label-secondary); font-size: 11px; font-weight: 400; white-space: nowrap; font-variant-numeric: tabular-nums; }
+34
View File
@@ -1271,6 +1271,40 @@ describe('ContextBrowser message categories', () => {
await m.unmount()
})
test('tool-name capsules reveal the schema row; the row toggle stays out of the way', async () => {
const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [{ name: 'bash', tokens: 5 }, { name: 'write', tokens: 3 }] }] }
const m = await mountBrowser({ headers })
await click(catRow(m, 'assistant'))
// The stamped breadcrumb 'bash › write' (seq 61) renders one clickable segment per name.
const crumbRow = elemRows(m).find(r => text(r).includes('bash › write')) as HTMLElement
const links = queryAll(crumbRow, '.lc-br-tag-link')
assert.deepEqual(links.map(l => text(l)), ['bash', 'write'])
await click(links[0])
// The reveal opens the Tool Schemas category with the bash definition row expanded...
assert.ok(text(query(m.container, '.lc-br-cat-open')).includes('Tool Schemas'))
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('bash'))
// ...and ONLY the schema body is open: the segment click stopped propagation,
// so the assistant row's own toggle never overwrote the reveal.
assert.equal(queryAll(m.container, '.lc-br-content').length, 1)
await m.unmount()
})
test('the expanded body’s call head reveals the tool schema too', async () => {
const headers: ContextHeaders = { headers: [{ seq: 1, time: 1, systemTokens: 3, tools: [{ name: 'bash', tokens: 5 }, { name: 'noargs', tokens: 2 }] }] }
const m = await mountBrowser({ headers })
await click(catRow(m, 'assistant'))
const row = elemRows(m).find(r => text(r).includes('full cascade')) as HTMLElement
await click(row)
const head = queryAll(m.container, '.lc-ts-card-head b').find(el => text(el) === '→ bash') as HTMLElement
await click(head)
const open = queryAll(m.container, '.lc-br-elem-on')
assert.equal(open.length, 1)
assert.ok(text(open[0]).includes('bash'), 'the call head lands on the bash schema row, expanded')
await m.unmount()
})
test('user images render a placeholder when no loader is wired', async () => {
const m = await mountBrowser({ loadImage: undefined })
await click(catRow(m, 'user'))