Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/web/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
25 changes: 24 additions & 1 deletion apps/web/src/i18n/locales/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "質問を入力してください…",
Expand Down
23 changes: 23 additions & 0 deletions apps/web/src/i18n/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
"正在连接线索",
"正在拼合信息",
Expand Down
222 changes: 202 additions & 20 deletions apps/web/src/pages/home/components/tool-call-group.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<typeof createApp>
Expand All @@ -33,6 +43,7 @@ afterEach(() => {
}
})

let nextBlockId = 0
let nextMessageId = 0

function mountGroup(items: ContentBlock[], active: boolean | undefined): HTMLDivElement {
Expand All @@ -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…' },
},
},
},
},
Expand All @@ -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 {
Expand All @@ -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()
})
})
Loading
Loading