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..d674685 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,25 @@ 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. */
+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])
+ return seconds
+}
+
+function ThinkingRow({ agentLabel }: { agentLabel: string }) {
+ const seconds = useThinkingSeconds(true)
return (
{agentLabel}
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()
+ })
+})