Skip to content

Commit 503dffd

Browse files
authored
feat(web-react): live reasoning timer + humanized tool-call titles (#149)
* feat(web-react): live reasoning timer + humanized tool-call titles * fix(web-react): reset thinking timer on reactivation, add timer tests
1 parent fdb6472 commit 503dffd

3 files changed

Lines changed: 115 additions & 12 deletions

File tree

src/web-react/chat-messages-segments.test.tsx

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ describe('ChatMessages segmented turns', () => {
4343
const { container } = render(<ChatMessages messages={[message]} />)
4444

4545
const pre = indexIn(container, 'Checking the workflow format first.')
46-
const schema = indexIn(container, 'get_workflow_schema')
46+
// Unmapped tool names render as humanized titles, e.g. "Get workflow schema".
47+
const schema = indexIn(container, 'Get workflow schema')
4748
const mid = indexIn(container, 'Now validating the definition.')
48-
const validate = indexIn(container, 'validate_workflow')
49+
const validate = indexIn(container, 'Validate workflow')
4950
const post = indexIn(container, 'Validated. Here is the plan.')
5051
// Every needle is present...
5152
expect(Math.min(pre, schema, mid, validate, post)).toBeGreaterThanOrEqual(0)
@@ -68,7 +69,7 @@ describe('ChatMessages segmented turns', () => {
6869
const { container } = render(<ChatMessages messages={[message]} />)
6970

7071
const body = indexIn(container, 'All done.')
71-
const tool = indexIn(container, 'list_workflows')
72+
const tool = indexIn(container, 'List workflows')
7273
expect(body).toBeGreaterThanOrEqual(0)
7374
expect(tool).toBeGreaterThanOrEqual(0)
7475
// Legacy producers keep the prior layout: content first, tool chips after.
@@ -100,8 +101,8 @@ describe('ChatMessages segmented turns', () => {
100101
}
101102
const { container } = render(<ChatMessages messages={[message]} />)
102103
const text = container.textContent ?? ''
103-
expect(text).toContain('list_skills')
104-
expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('list_skills'))
104+
expect(text).toContain('List skills')
105+
expect(text.indexOf('After.')).toBeGreaterThan(text.indexOf('List skills'))
105106
})
106107

107108
it('renders a toolCall not represented in segments rather than dropping it', () => {
@@ -114,7 +115,7 @@ describe('ChatMessages segmented turns', () => {
114115
toolCalls: [{ id: 'orphan', name: 'list_workflows', status: 'done' }],
115116
}
116117
const { container } = render(<ChatMessages messages={[message]} />)
117-
expect(container.textContent).toContain('list_workflows')
118+
expect(container.textContent).toContain('List workflows')
118119
})
119120

120121
it('does not duplicate a toolCall already present as a segment', () => {
@@ -128,10 +129,25 @@ describe('ChatMessages segmented turns', () => {
128129
toolCalls: [{ id: 't1', name: 'validate_workflow', status: 'done' }],
129130
}
130131
const { container } = render(<ChatMessages messages={[message]} />)
131-
const matches = (container.textContent ?? '').match(/validate_workflow/g) ?? []
132+
const matches = (container.textContent ?? '').match(/Validate workflow/g) ?? []
132133
expect(matches).toHaveLength(1)
133134
})
134135

136+
it('humanizes an unmapped tool name for the chip title', () => {
137+
const message: ChatUiMessage = {
138+
id: 'm1',
139+
role: 'assistant',
140+
content: '',
141+
segments: [
142+
{ kind: 'tool', call: { id: 't1', name: 'get_credit_balance', status: 'done' } },
143+
],
144+
}
145+
const { container } = render(<ChatMessages messages={[message]} />)
146+
// The snake_case slug shows as a sentence-cased label, never the raw name.
147+
expect(container.textContent).toContain('Get credit balance')
148+
expect(container.textContent).not.toContain('get_credit_balance')
149+
})
150+
135151
it('does not leave the reasoning panel Thinking for a segmented message with empty content', () => {
136152
const message: ChatUiMessage = {
137153
id: 'm1',

src/web-react/index.tsx

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,24 @@ function blockKindOf(call: ChatToolCallInfo): BlockKind {
363363
return 'generic'
364364
}
365365

366+
/** Humanize an otherwise-unmapped tool name for display: `get_credit_balance`
367+
* → "Get credit balance". Splits on separators and camelCase, then sentence-
368+
* cases — domain-agnostic, so a host's tool reads as a label without this
369+
* shared renderer knowing that host's tool taxonomy. Falls back to the raw name
370+
* when there's nothing to humanize. */
371+
function humanizeToolName(name: string): string {
372+
const words = name
373+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
374+
.replace(/[_-]+/g, ' ')
375+
.trim()
376+
if (!words) return name
377+
return words.charAt(0).toUpperCase() + words.slice(1)
378+
}
379+
366380
/** Human title for a call, derived from its real arguments. Proposals lead with
367381
* the decision verb (docs/product-surfaces.md) rather than the internal tool
368-
* taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". */
382+
* taxonomy, so the user reads "Approve: publish …?" not "submit_proposal". An
383+
* unmapped tool falls back to its humanized name rather than the raw slug. */
369384
function friendlyToolTitle(call: ChatToolCallInfo): string {
370385
const a = call.args ?? {}
371386
switch (call.name) {
@@ -384,7 +399,7 @@ function friendlyToolTitle(call: ChatToolCallInfo): string {
384399
case 'add_citation':
385400
return `Cited ${String(a.path ?? '')}`
386401
default:
387-
return call.name
402+
return humanizeToolName(call.name)
388403
}
389404
}
390405

@@ -878,6 +893,11 @@ function AssistantMessageImpl({
878893
const el = reasoningScrollRef.current
879894
if (el && streaming && !hasAnswerText) el.scrollTop = el.scrollHeight
880895
}, [reasoning, streaming, hasAnswerText])
896+
// Live seconds while the model is reasoning before its answer starts, so a
897+
// long thinking gap shows progress rather than a static "Thinking…".
898+
const thinkingSeconds = useThinkingSeconds(
899+
streaming && !!reasoning && !hasAnswerText,
900+
)
881901

882902
return (
883903
<div className="mx-auto w-full max-w-3xl px-6 py-3">
@@ -891,7 +911,9 @@ function AssistantMessageImpl({
891911
<details className="mb-2 rounded-lg border-l-2 border-border/70 bg-muted/20 px-3 py-2" open={!hasAnswerText}>
892912
<summary className="cursor-pointer select-none text-xs font-medium text-muted-foreground">
893913
{!hasAnswerText ? (
894-
<span className="animate-pulse">Thinking…</span>
914+
<span className="animate-pulse">
915+
Thinking{thinkingSeconds >= 3 ? ` · ${thinkingSeconds}s` : '…'}
916+
</span>
895917
) : thinkMsRef.current != null ? (
896918
`Thought for ${Math.max(1, Math.round(thinkMsRef.current / 1000))}s`
897919
) : (
@@ -949,12 +971,25 @@ function AssistantMessageImpl({
949971
*/
950972
const AssistantMessage = memo(AssistantMessageImpl)
951973

952-
function ThinkingRow({ agentLabel }: { agentLabel: string }) {
974+
/** Whole seconds elapsed while `active`, ticking once a second. Powers the live
975+
* "thinking" timers (the pre-first-token row and the reasoning box) so a long
976+
* thinking gap shows progress instead of a frozen label. Counts from when
977+
* `active` first turns true; freezes when it clears. */
978+
export function useThinkingSeconds(active: boolean): number {
953979
const [seconds, setSeconds] = useState(0)
954980
useEffect(() => {
981+
if (!active) return
982+
// Reset on each (re)activation so a reused component resuming "thinking"
983+
// counts from 0 rather than showing the prior phase's stale elapsed time.
984+
setSeconds(0)
955985
const id = setInterval(() => setSeconds((s) => s + 1), 1000)
956986
return () => clearInterval(id)
957-
}, [])
987+
}, [active])
988+
return seconds
989+
}
990+
991+
function ThinkingRow({ agentLabel }: { agentLabel: string }) {
992+
const seconds = useThinkingSeconds(true)
958993
return (
959994
<div className="mx-auto w-full max-w-3xl px-6 py-3">
960995
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{agentLabel}</p>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// @vitest-environment jsdom
2+
import { act, renderHook } from '@testing-library/react'
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4+
import { useThinkingSeconds } from './index'
5+
6+
describe('useThinkingSeconds', () => {
7+
beforeEach(() => vi.useFakeTimers())
8+
afterEach(() => vi.useRealTimers())
9+
10+
it('counts whole seconds while active', () => {
11+
const { result } = renderHook(() => useThinkingSeconds(true))
12+
expect(result.current).toBe(0)
13+
act(() => {
14+
vi.advanceTimersByTime(3000)
15+
})
16+
expect(result.current).toBe(3)
17+
})
18+
19+
it('does not advance while inactive', () => {
20+
const { result } = renderHook(() => useThinkingSeconds(false))
21+
act(() => {
22+
vi.advanceTimersByTime(5000)
23+
})
24+
expect(result.current).toBe(0)
25+
})
26+
27+
it('resets to 0 when reactivated rather than resuming the stale count', () => {
28+
const { result, rerender } = renderHook(
29+
({ active }) => useThinkingSeconds(active),
30+
{ initialProps: { active: true } },
31+
)
32+
act(() => {
33+
vi.advanceTimersByTime(4000)
34+
})
35+
expect(result.current).toBe(4)
36+
// Deactivate (freezes), then reactivate — the counter must restart at 0.
37+
rerender({ active: false })
38+
rerender({ active: true })
39+
expect(result.current).toBe(0)
40+
act(() => {
41+
vi.advanceTimersByTime(1000)
42+
})
43+
expect(result.current).toBe(1)
44+
})
45+
46+
it('clears its interval on unmount', () => {
47+
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
48+
const { unmount } = renderHook(() => useThinkingSeconds(true))
49+
unmount()
50+
expect(clearSpy).toHaveBeenCalled()
51+
})
52+
})

0 commit comments

Comments
 (0)