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:
1 parent
213efeef15
commit
5a62b2b306
5 files changed
+111
-7
No files matched your search
@@ -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'),
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in new issue
Block a user