diff --git a/src/client/components/browser.tsx b/src/client/components/browser.tsx index 7dc9ccc..463d711 100644 --- a/src/client/components/browser.tsx +++ b/src/client/components/browser.tsx @@ -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: {
{/* The title recovers an ellipsized label: long mono call names truncate under width pressure. */} - {props.label} + {props.label} {right ? {props.meta}{props.actions} : null} {props.count !== undefined ? {props.count} : null}
@@ -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 (
{ onSchema(props.name) }} > {args !== null ? Object.keys(args).map(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 + return } 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) - ? + ? : null} ) @@ -552,7 +575,10 @@ function NodeContent(props: { : <> } if (Array.isArray(conv.content)) { - return + return } return
{props.hint}
} @@ -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) => ( + + {i > 0 ? ' › ' : null} + { e.stopPropagation(); revealSchema(name) }} + >{name} + + )) + } + 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 (
{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, = { '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 = { '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)', diff --git a/src/client/styles/browser.css b/src/client/styles/browser.css index ce2d91d..946da2d 100644 --- a/src/client/styles/browser.css +++ b/src/client/styles/browser.css @@ -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 diff --git a/src/client/styles/detailSections.css b/src/client/styles/detailSections.css index d59faab..54890e0 100644 --- a/src/client/styles/detailSections.css +++ b/src/client/styles/detailSections.css @@ -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; } diff --git a/tests/client/components/browser.spec.ts b/tests/client/components/browser.spec.ts index 1f6a35e..16ce6cd 100644 --- a/tests/client/components/browser.spec.ts +++ b/tests/client/components/browser.spec.ts @@ -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'))