diff --git a/apps/web/src/i18n/locales/en.json b/apps/web/src/i18n/locales/en.json index 2d9554e09..44896f1a4 100644 --- a/apps/web/src/i18n/locales/en.json +++ b/apps/web/src/i18n/locales/en.json @@ -666,6 +666,29 @@ "thought": "Thought", "thoughtBriefly": "Thought for a couple of seconds", "thoughtSeconds": "Thought for {seconds}s", + "phase": { + "browse": { "ing": "Exploring", "done": "Explored" }, + "edit": { "ing": "Editing", "done": "Edited" }, + "run": { "ing": "Running", "done": "Ran" }, + "message": { "ing": "Messaging", "done": "Sent" }, + "schedule": { "ing": "Scheduling", "done": "Scheduled" }, + "media": { "ing": "Generating", "done": "Generated" }, + "agent": { "ing": "Delegating", "done": "Delegated" }, + "gui": { "ing": "Browsing", "done": "Browsed" }, + "other": { "ing": "Working", "done": "Worked" } + }, + "frag": { + "fileOperations": "{count} file operation | {count} file operations", + "searches": "{count} searches", + "commands": "{count} commands", + "messages": "{count} messages", + "schedules": "{count} schedules", + "media": "{count} media files", + "agents": "{count} agents", + "steps": "{count} step | {count} steps", + "sites": "{count} sites" + }, + "fragmentSeparator": ", ", "thinkingHints": [ "Connecting the dots", "Piecing it together", diff --git a/apps/web/src/i18n/locales/ja.json b/apps/web/src/i18n/locales/ja.json index f6b437260..cb6245baa 100644 --- a/apps/web/src/i18n/locales/ja.json +++ b/apps/web/src/i18n/locales/ja.json @@ -649,7 +649,30 @@ "steps": "{count} ステップ", "thought": "考え", "thoughtBriefly": "少し考えた", - "thoughtSeconds": "{seconds}秒考えました" + "thoughtSeconds": "{seconds}秒考えました", + "phase": { + "browse": { "ing": "探索中", "done": "探索しました" }, + "edit": { "ing": "編集中", "done": "編集しました" }, + "run": { "ing": "実行中", "done": "実行しました" }, + "message": { "ing": "送信中", "done": "送信しました" }, + "schedule": { "ing": "変更中", "done": "変更しました" }, + "media": { "ing": "生成中", "done": "生成しました" }, + "agent": { "ing": "委譲中", "done": "委譲しました" }, + "gui": { "ing": "閲覧中", "done": "閲覧しました" }, + "other": { "ing": "作業中", "done": "作業しました" } + }, + "frag": { + "fileOperations": "ファイル操作 {count} 件", + "searches": "{count} 件の検索", + "commands": "{count} コマンド", + "messages": "{count} 件のメッセージ", + "schedules": "{count} 件のスケジュール", + "media": "{count} 個のメディアファイル", + "agents": "{count} のAgent", + "steps": "{count} ステップ", + "sites": "{count} 件のサイト" + }, + "fragmentSeparator": "、" }, "currentBot": "現在のBot", "inputPlaceholder": "質問を入力してください…", diff --git a/apps/web/src/i18n/locales/zh.json b/apps/web/src/i18n/locales/zh.json index 73c2fca8e..c36498e5b 100644 --- a/apps/web/src/i18n/locales/zh.json +++ b/apps/web/src/i18n/locales/zh.json @@ -666,6 +666,29 @@ "thought": "已思考", "thoughtBriefly": "思考了几秒", "thoughtSeconds": "已思考 {seconds} 秒", + "phase": { + "browse": { "ing": "探索中", "done": "已探索" }, + "edit": { "ing": "修改中", "done": "已修改" }, + "run": { "ing": "运行中", "done": "已运行" }, + "message": { "ing": "发送中", "done": "已发送" }, + "schedule": { "ing": "调整中", "done": "已调整" }, + "media": { "ing": "生成中", "done": "已生成" }, + "agent": { "ing": "委派中", "done": "已委派" }, + "gui": { "ing": "浏览中", "done": "已浏览" }, + "other": { "ing": "处理中", "done": "已处理" } + }, + "frag": { + "fileOperations": "{count} 次文件操作", + "searches": "{count} 次搜索", + "commands": "{count} 条命令", + "messages": "{count} 条消息", + "schedules": "{count} 个定时任务", + "media": "{count} 个媒体文件", + "agents": "{count} 个 Agent", + "steps": "{count} 步", + "sites": "{count} 个网站" + }, + "fragmentSeparator": "、", "thinkingHints": [ "正在连接线索", "正在拼合信息", diff --git a/apps/web/src/pages/home/components/tool-call-group.test.ts b/apps/web/src/pages/home/components/tool-call-group.test.ts index 27ae39afd..a9f925614 100644 --- a/apps/web/src/pages/home/components/tool-call-group.test.ts +++ b/apps/web/src/pages/home/components/tool-call-group.test.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { createApp, defineComponent, h } from 'vue' +import { createApp, defineComponent, h, nextTick } from 'vue' import { createI18n } from 'vue-i18n' import { afterEach, describe, expect, it, vi } from 'vitest' import type { ContentBlock, ThinkingBlock as ThinkingBlockType, ToolCallBlock as ToolCallBlockType } from '@/store/chat-list' @@ -14,10 +14,20 @@ vi.mock('@/i18n', () => ({ })) import ToolCallGroup from './tool-call-group.vue' -// Pins the ghost-"Thinking…" fix: the collapsed header is a live ticker only -// while the turn streams (`active`). A background task that outlives the turn -// must not pin the ticker — its `running` block used to keep the header saying -// "Thinking…" indefinitely on a finished turn (issue #1106). +// Two contracts pinned here: +// +// 1. Ghost-"Thinking…" fix (issue #1106): the collapsed header is a live +// two-layer summary only while the turn streams (`active`). A background +// task that outlives the turn must not pin the live header — its `running` +// block used to keep the header saying "Thinking…" indefinitely on a +// finished turn. +// +// 2. The two-layer adaptive header: while a segment streams, a muted phase +// verb ("Exploring") leads the header with live bare-count details +// ("2 file operations, 1 command"), and a "now" line below rolls the current +// call; once the segment settles, the verb turns past tense ("Explored") +// and diff totals appear. This guards against regressing to a bare ticker +// of the latest call (which hid the aggregate view until settle). interface MountedGroup { app: ReturnType @@ -33,6 +43,7 @@ afterEach(() => { } }) +let nextBlockId = 0 let nextMessageId = 0 function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDivElement { @@ -50,8 +61,41 @@ function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDiv en: { chat: { thinkingInProgress: 'Thinking', - process: { thought: 'Thought', steps: '{count} steps', run: 'Ran {count} commands' }, - tools: { run: 'Run' }, + process: { + thought: 'Thought', + steps: '{count} steps', + fragmentSeparator: ', ', + phase: { + browse: { ing: 'Exploring', done: 'Explored' }, + edit: { ing: 'Editing', done: 'Edited' }, + run: { ing: 'Running', done: 'Ran' }, + message: { ing: 'Messaging', done: 'Sent' }, + schedule: { ing: 'Scheduling', done: 'Scheduled' }, + media: { ing: 'Generating', done: 'Generated' }, + agent: { ing: 'Delegating', done: 'Delegated' }, + gui: { ing: 'Browsing', done: 'Browsed' }, + other: { ing: 'Working', done: 'Worked' }, + }, + frag: { + fileOperations: '{count} file operation | {count} file operations', + searches: '{count} searches', + commands: '{count} commands', + messages: '{count} messages', + schedules: '{count} schedules', + media: '{count} media files', + agents: '{count} agents', + steps: '{count} step | {count} steps', + sites: '{count} sites', + }, + }, + tools: { + run: 'Run', + read: 'Read', + exec: 'Run', + edit: 'Edit', + ask_user: 'Ask user', + pending: { generic: 'Working…' }, + }, }, }, }, @@ -61,13 +105,15 @@ function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDiv return root } +// The header row is the toggleable button; its whole text covers verb + +// details + diffs. function headerText(root: HTMLElement): string { - // The header label is the span inside the toggleable header row; the capsule - // body holds the per-item rows, so restricting to the first button-bound - // region would be brittle — take the first span with the tracking class. - const spans = [...root.querySelectorAll('span')] - const header = spans.find(span => (span.textContent ?? '').length > 0) - return header?.textContent ?? '' + const button = root.querySelector('button') + return button?.textContent ?? '' +} + +function nowLine(root: HTMLElement): HTMLElement | null { + return root.querySelector('.now-line') } function bgExecTool(id: number, running: boolean): ToolCallBlockType { @@ -83,25 +129,161 @@ function bgExecTool(id: number, running: boolean): ToolCallBlockType { } as unknown as ToolCallBlockType } +function toolBlock(toolName: string, input: unknown, running = false): ToolCallBlockType { + const id = ++nextBlockId + return { + id, + type: 'tool', + name: toolName, + toolName, + input, + running, + done: !running, + tool_call_id: `tc-${id}`, + toolCallId: `tc-${id}`, + result: null, + } +} + function reasoning(id: number): ThinkingBlockType { return { id, type: 'reasoning', content: 'planning the tunnel' } as ThinkingBlockType } -describe('ToolCallGroup header ticker', () => { - it('shows the ticker label while the turn is streaming', () => { +describe('ToolCallGroup live header gating (#1106)', () => { + it('keeps the live two-layer header while the turn is streaming', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], true) - expect(headerText(root)).toBe('Thinking') + // Single-tool segment: header keeps the tool's own specific label; the now + // line carries the model's current step. No phase verb stutter ("Running + // Run xray run") and no bare ticker of the latest item. + expect(headerText(root)).toContain('xray run') + expect(nowLine(root)?.textContent).toContain('Thinking') }) - it('drops the ticker when the turn finished even if a background tool is still running', () => { + it('drops the live header when the turn finished even if a background tool is still running', () => { const root = mountGroup([bgExecTool(1, true), reasoning(2)], false) - // Aggregate summary, not "Thinking" — the model is done; only the spawned - // command is alive, and its own row reports that. + // Settled single-tool label, not "Thinking" — the model is done; only the + // spawned command is alive, and its own row reports that. expect(headerText(root)).not.toBe('Thinking') + expect(nowLine(root)).toBeNull() }) - it('shows the aggregate summary once the turn finished and tools settled', () => { + it('shows the settled label once the turn finished and tools settled', () => { const root = mountGroup([bgExecTool(1, false), reasoning(2)], false) expect(headerText(root)).not.toBe('Thinking') + expect(nowLine(root)).toBeNull() + }) +}) + +describe('ToolCallGroup adaptive header layers', () => { + it('names the phase and live bare counts in the header, and rolls the current call below', () => { + const root = mountGroup([ + toolBlock('read', { path: 'src/auth/session.ts' }), + toolBlock('read', { path: 'src/auth/cookie.ts' }), + toolBlock('exec', { command: 'pnpm test' }, true), + ], true) + + const header = headerText(root) + expect(header).toContain('Exploring') + expect(header).toContain('2 file operations') + expect(header).toContain('1 command') + expect(nowLine(root)?.textContent).toContain('pnpm test') + }) + + it('settles to a past-tense verb with final counts and diff totals', () => { + const root = mountGroup([ + toolBlock('edit', { path: 'a.ts', old_text: 'l1\nl2\nl3', new_text: 'n1\nn2\nn3\nn4\nn5' }), + toolBlock('edit', { path: 'b.ts', old_text: 'l1\nl2', new_text: 'n1\nn2\nn3\nn4' }), + ], false) + + const header = headerText(root) + expect(header).toContain('Edited') + expect(header).toContain('2 file operations') + expect(header).toContain('+9') + expect(header).toContain('-5') + expect(header).not.toContain('Editing') + expect(nowLine(root)).toBeNull() + }) + + it('keeps diff totals hidden while the segment is still streaming', () => { + const root = mountGroup([ + toolBlock('edit', { path: 'a.ts', old_text: 'l1', new_text: 'n1\nn2' }), + toolBlock('edit', { path: 'b.ts', old_text: 'l1', new_text: 'n1\nn2' }), + ], true) + + const header = headerText(root) + expect(header).toContain('Editing') + expect(header).not.toContain('+4') + }) + + it('describes file tool calls as operations rather than unique files', () => { + const root = mountGroup([ + toolBlock('edit', { path: 'same.ts', old_text: 'a', new_text: 'b' }), + toolBlock('edit', { path: 'same.ts', old_text: 'b', new_text: 'c' }), + ], false) + + const header = headerText(root) + expect(header).toContain('2 file operations') + expect(header).not.toContain('2 files') + }) + + it('localizes the fallback count for unclassified tools', () => { + const root = mountGroup([ + toolBlock('write', { path: 'a.ts', content: 'a' }), + toolBlock('ask_user', { question: 'Continue?' }, true), + ], true) + + const header = headerText(root) + expect(header).toContain('1 file operation') + expect(header).not.toContain('1 file operations') + expect(header).toContain('1 step') + expect(header).not.toContain('1 steps') + expect(header).not.toContain('chat.process.frag.steps') + }) + + it('keeps a lone live tool on its own specific label, no phase verb', () => { + const root = mountGroup([ + toolBlock('read', { path: 'src/main.ts' }), + reasoning(2), + ], true) + + const header = headerText(root) + expect(header).toContain('main.ts') + expect(header).not.toContain('Exploring') + expect(nowLine(root)?.textContent).toContain('Thinking') + }) + + it('keeps a lone settled tool on its own specific label, no verb', () => { + const root = mountGroup([ + toolBlock('exec', { command: 'pnpm build' }), + reasoning(2), + ], false) + + const header = headerText(root) + expect(header).toContain('pnpm build') + expect(header).not.toContain('Ran') + }) + + it('shows the pending label in the now line while a tool input is still streaming in', () => { + const root = mountGroup([ + toolBlock('read', { path: 'a.ts' }), + toolBlock('read', { path: 'b.ts' }), + toolBlock('exec', {}, true), + ], true) + + expect(nowLine(root)?.textContent).toContain('Working…') + }) + + it('hides the now line once the user opens the body', async () => { + const root = mountGroup([ + toolBlock('read', { path: 'a.ts' }), + toolBlock('read', { path: 'b.ts' }), + toolBlock('exec', { command: 'ls' }, true), + ], true) + expect(nowLine(root)).not.toBeNull() + + root.querySelector('button')?.click() + await nextTick() + + expect(nowLine(root)).toBeNull() }) }) diff --git a/apps/web/src/pages/home/components/tool-call-group.vue b/apps/web/src/pages/home/components/tool-call-group.vue index ee0e6b89a..af90b7a84 100644 --- a/apps/web/src/pages/home/components/tool-call-group.vue +++ b/apps/web/src/pages/home/components/tool-call-group.vue @@ -34,16 +34,57 @@ :connector="item" /> + + {{ headerLabel }} + + +{{ diffTotals.add }} + -{{ diffTotals.remove }} + +
+ +
+ +
+