From 6c5df58c418b0fa111f9e72e9d7991a1aab984bf Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Fri, 26 Jun 2026 12:56:13 -0500 Subject: [PATCH 1/2] feat(web-react): live reasoning timer + humanized tool-call titles --- src/web-react/chat-messages-segments.test.tsx | 30 +++++++++---- src/web-react/index.tsx | 42 ++++++++++++++++--- 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/web-react/chat-messages-segments.test.tsx b/src/web-react/chat-messages-segments.test.tsx index ec7a453..fc8eaf9 100644 --- a/src/web-react/chat-messages-segments.test.tsx +++ b/src/web-react/chat-messages-segments.test.tsx @@ -43,9 +43,10 @@ describe('ChatMessages segmented turns', () => { const { container } = render() const pre = indexIn(container, 'Checking the workflow format first.') - const schema = indexIn(container, 'get_workflow_schema') + // Unmapped tool names render as humanized titles, e.g. "Get workflow schema". + const schema = indexIn(container, 'Get workflow schema') const mid = indexIn(container, 'Now validating the definition.') - const validate = indexIn(container, 'validate_workflow') + const validate = indexIn(container, 'Validate workflow') const post = indexIn(container, 'Validated. Here is the plan.') // Every needle is present... expect(Math.min(pre, schema, mid, validate, post)).toBeGreaterThanOrEqual(0) @@ -68,7 +69,7 @@ describe('ChatMessages segmented turns', () => { const { container } = render() const body = indexIn(container, 'All done.') - const tool = indexIn(container, 'list_workflows') + const tool = indexIn(container, 'List workflows') expect(body).toBeGreaterThanOrEqual(0) expect(tool).toBeGreaterThanOrEqual(0) // Legacy producers keep the prior layout: content first, tool chips after. @@ -100,8 +101,8 @@ describe('ChatMessages segmented turns', () => { } const { container } = render() const text = container.textContent ?? '' - expect(text).toContain('list_skills') - expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('list_skills')) + expect(text).toContain('List skills') + expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('List skills')) }) it('renders a toolCall not represented in segments rather than dropping it', () => { @@ -114,7 +115,7 @@ describe('ChatMessages segmented turns', () => { toolCalls: [{ id: 'orphan', name: 'list_workflows', status: 'done' }], } const { container } = render() - expect(container.textContent).toContain('list_workflows') + expect(container.textContent).toContain('List workflows') }) it('does not duplicate a toolCall already present as a segment', () => { @@ -128,10 +129,25 @@ describe('ChatMessages segmented turns', () => { toolCalls: [{ id: 't1', name: 'validate_workflow', status: 'done' }], } const { container } = render() - const matches = (container.textContent ?? '').match(/validate_workflow/g) ?? [] + const matches = (container.textContent ?? '').match(/Validate workflow/g) ?? [] expect(matches).toHaveLength(1) }) + it('humanizes an unmapped tool name for the chip title', () => { + const message: ChatUiMessage = { + id: 'm1', + role: 'assistant', + content: '', + segments: [ + { kind: 'tool', call: { id: 't1', name: 'get_credit_balance', status: 'done' } }, + ], + } + const { container } = render() + // The snake_case slug shows as a sentence-cased label, never the raw name. + expect(container.textContent).toContain('Get credit balance') + expect(container.textContent).not.toContain('get_credit_balance') + }) + it('does not leave the reasoning panel Thinking for a segmented message with empty content', () => { const message: ChatUiMessage = { id: 'm1', diff --git a/src/web-react/index.tsx b/src/web-react/index.tsx index 37efc5f..258da41 100644 --- a/src/web-react/index.tsx +++ b/src/web-react/index.tsx @@ -363,9 +363,24 @@ function blockKindOf(call: ChatToolCallInfo): BlockKind { return 'generic' } +/** Humanize an otherwise-unmapped tool name for display: `get_credit_balance` + * → "Get credit balance". Splits on separators and camelCase, then sentence- + * cases — domain-agnostic, so a host's tool reads as a label without this + * shared renderer knowing that host's tool taxonomy. Falls back to the raw name + * when there's nothing to humanize. */ +function humanizeToolName(name: string): string { + const words = name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[_-]+/g, ' ') + .trim() + if (!words) return name + return words.charAt(0).toUpperCase() + words.slice(1) +} + /** Human title for a call, derived from its real arguments. Proposals lead with * the decision verb (docs/product-surfaces.md) rather than the internal tool - * taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". */ + * taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". An + * unmapped tool falls back to its humanized name rather than the raw slug. */ function friendlyToolTitle(call: ChatToolCallInfo): string { const a = call.args ?? {} switch (call.name) { @@ -384,7 +399,7 @@ function friendlyToolTitle(call: ChatToolCallInfo): string { case 'add_citation': return `Cited ${String(a.path ?? '')}` default: - return call.name + return humanizeToolName(call.name) } } @@ -878,6 +893,11 @@ function AssistantMessageImpl({ const el = reasoningScrollRef.current if (el && streaming && !hasAnswerText) el.scrollTop = el.scrollHeight }, [reasoning, streaming, hasAnswerText]) + // Live seconds while the model is reasoning before its answer starts, so a + // long thinking gap shows progress rather than a static "Thinking…". + const thinkingSeconds = useThinkingSeconds( + streaming && !!reasoning && !hasAnswerText, + ) return (
@@ -891,7 +911,9 @@ function AssistantMessageImpl({
{!hasAnswerText ? ( - Thinking… + + Thinking{thinkingSeconds >= 3 ? ` · ${thinkingSeconds}s` : '…'} + ) : thinkMsRef.current != null ? ( `Thought for ${Math.max(1, Math.round(thinkMsRef.current / 1000))}s` ) : ( @@ -949,12 +971,22 @@ function AssistantMessageImpl({ */ const AssistantMessage = memo(AssistantMessageImpl) -function ThinkingRow({ agentLabel }: { agentLabel: string }) { +/** Whole seconds elapsed while `active`, ticking once a second. Powers the live + * "thinking" timers (the pre-first-token row and the reasoning box) so a long + * thinking gap shows progress instead of a frozen label. Counts from when + * `active` first turns true; freezes when it clears. */ +function useThinkingSeconds(active: boolean): number { const [seconds, setSeconds] = useState(0) useEffect(() => { + if (!active) return const id = setInterval(() => setSeconds((s) => s + 1), 1000) return () => clearInterval(id) - }, []) + }, [active]) + return seconds +} + +function ThinkingRow({ agentLabel }: { agentLabel: string }) { + const seconds = useThinkingSeconds(true) return (

{agentLabel}

From e9239484cc41506365c70b4034f2faf849264b20 Mon Sep 17 00:00:00 2001 From: Donovan Tjemmes Date: Fri, 26 Jun 2026 13:27:55 -0500 Subject: [PATCH 2/2] fix(web-react): reset thinking timer on reactivation, add timer tests --- src/web-react/index.tsx | 5 +- src/web-react/use-thinking-seconds.test.tsx | 52 +++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 src/web-react/use-thinking-seconds.test.tsx diff --git a/src/web-react/index.tsx b/src/web-react/index.tsx index 258da41..d674685 100644 --- a/src/web-react/index.tsx +++ b/src/web-react/index.tsx @@ -975,10 +975,13 @@ const AssistantMessage = memo(AssistantMessageImpl) * "thinking" timers (the pre-first-token row and the reasoning box) so a long * thinking gap shows progress instead of a frozen label. Counts from when * `active` first turns true; freezes when it clears. */ -function useThinkingSeconds(active: boolean): number { +export function useThinkingSeconds(active: boolean): number { const [seconds, setSeconds] = useState(0) useEffect(() => { if (!active) return + // Reset on each (re)activation so a reused component resuming "thinking" + // counts from 0 rather than showing the prior phase's stale elapsed time. + setSeconds(0) const id = setInterval(() => setSeconds((s) => s + 1), 1000) return () => clearInterval(id) }, [active]) diff --git a/src/web-react/use-thinking-seconds.test.tsx b/src/web-react/use-thinking-seconds.test.tsx new file mode 100644 index 0000000..fa199cb --- /dev/null +++ b/src/web-react/use-thinking-seconds.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment jsdom +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useThinkingSeconds } from './index' + +describe('useThinkingSeconds', () => { + beforeEach(() => vi.useFakeTimers()) + afterEach(() => vi.useRealTimers()) + + it('counts whole seconds while active', () => { + const { result } = renderHook(() => useThinkingSeconds(true)) + expect(result.current).toBe(0) + act(() => { + vi.advanceTimersByTime(3000) + }) + expect(result.current).toBe(3) + }) + + it('does not advance while inactive', () => { + const { result } = renderHook(() => useThinkingSeconds(false)) + act(() => { + vi.advanceTimersByTime(5000) + }) + expect(result.current).toBe(0) + }) + + it('resets to 0 when reactivated rather than resuming the stale count', () => { + const { result, rerender } = renderHook( + ({ active }) => useThinkingSeconds(active), + { initialProps: { active: true } }, + ) + act(() => { + vi.advanceTimersByTime(4000) + }) + expect(result.current).toBe(4) + // Deactivate (freezes), then reactivate — the counter must restart at 0. + rerender({ active: false }) + rerender({ active: true }) + expect(result.current).toBe(0) + act(() => { + vi.advanceTimersByTime(1000) + }) + expect(result.current).toBe(1) + }) + + it('clears its interval on unmount', () => { + const clearSpy = vi.spyOn(globalThis, 'clearInterval') + const { unmount } = renderHook(() => useThinkingSeconds(true)) + unmount() + expect(clearSpy).toHaveBeenCalled() + }) +})