From f496e444e1b09f4c4453152d7cf995436370dee6 Mon Sep 17 00:00:00 2001 From: NiceChen Date: Sun, 20 Sep 2026 16:39:22 +0800 Subject: [PATCH 01/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=B8=85=E5=8D=95=E5=8F=AF=E6=8A=98=E5=8F=A0=E5=B9=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E8=BF=9B=E5=B1=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 计划条标出待做/进行中/已完成,可收起。快照读失败用横幅重试,不再刷错误。发出去的那句用同一编号;排队下一句会带上图片。 --- .../src/services/chat_runtime/mod.rs | 2 +- .../src/services/chat_runtime/tests.rs | 1 + docs/ui/page-patterns.md | 6 +- src/lib/i18n/locales/en.ts | 10 +++ src/lib/i18n/locales/zh.ts | 10 +++ src/pages/chat/ChatPlanBar.tsx | 87 +++++++++++++++---- src/pages/chat/ChatTranscript.tsx | 1 + src/pages/chat/chat-grok-follow-up.test.ts | 4 + src/pages/chat/chat-grok-follow-up.ts | 22 ++++- src/pages/chat/chat-layout.test.ts | 8 ++ src/pages/chat/chat-runtime-model.test.ts | 12 +++ src/pages/chat/chat-runtime-model.ts | 48 +++++++++- src/pages/chat/chat-snapshot-sync.test.ts | 42 +++++++++ src/pages/chat/chat-snapshot-sync.ts | 80 +++++++++++++++++ src/pages/chat/index.tsx | 18 ++++ src/pages/chat/runtime-run-state.test.ts | 11 +++ src/pages/chat/use-chat-page-send.ts | 87 +++++++++++++++---- src/pages/chat/use-chat-page.ts | 2 + 18 files changed, 406 insertions(+), 45 deletions(-) create mode 100644 src/pages/chat/chat-snapshot-sync.test.ts create mode 100644 src/pages/chat/chat-snapshot-sync.ts diff --git a/crates/agenthub-core/src/services/chat_runtime/mod.rs b/crates/agenthub-core/src/services/chat_runtime/mod.rs index 8effa3f4..671f791c 100644 --- a/crates/agenthub-core/src/services/chat_runtime/mod.rs +++ b/crates/agenthub-core/src/services/chat_runtime/mod.rs @@ -1439,7 +1439,7 @@ impl ActorWorker { let now = Utc::now().to_rfc3339(); let message_id = format!("msg-{}", Uuid::new_v4()); let mut user = ChatMessage { - id: format!("msg-{}", Uuid::new_v4()), + id: client_request_id.to_string(), conversation_id: self.conversation_id.clone(), turn: 0, role: ChatRole::User, diff --git a/crates/agenthub-core/src/services/chat_runtime/tests.rs b/crates/agenthub-core/src/services/chat_runtime/tests.rs index 49fd690f..bf1f5d66 100644 --- a/crates/agenthub-core/src/services/chat_runtime/tests.rs +++ b/crates/agenthub-core/src/services/chat_runtime/tests.rs @@ -131,6 +131,7 @@ fn begin_turn_bumps_conversation_sort_time_without_renaming() { }] }) .unwrap(); + assert_eq!(user.id, "user-1"); let listed = repo.list_conversations().unwrap(); assert_eq!(listed[0].id, "older"); diff --git a/docs/ui/page-patterns.md b/docs/ui/page-patterns.md index c4c5b7a1..7d1e77ce 100644 --- a/docs/ui/page-patterns.md +++ b/docs/ui/page-patterns.md @@ -3,7 +3,7 @@ title: UI 页面模式 type: reference status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # UI Page Patterns @@ -340,7 +340,8 @@ Chat is a one-conversation, one-Agent workbench with a session rail, transcript, - An empty transcript invites typing first (`开始对话` only). Example chips stay above the composer and only fill the draft; the draft-only note is on the chip hover, not a permanent line. The composer placeholder is generic (`发消息…`). Grok / Kiro / Claude queue-only limits and Enter / Shift+Enter sit on hover titles near the composer or send, not as permanent secondary lines on an empty session. On that first-use empty transcript the toolbar keeps Agent, connection, model, thinking, images, and skills, but secondary image/skill labels collapse to icon-only (accessible names stay); the connection label truncates and shows the full name on hover. Cursor is not on this surface. - The send button is the composer's accent action. The session-rail **新建对话** button uses the same theme fill (not gray). Enter sends; Shift+Enter inserts a new line; the footer names the current shortcut on a non-empty transcript, and on an empty session via hover. The bottom-right slot is a single circular control: never Stop and Send side by side. Idle shows Send (disabled when empty). While generating, Send stays when there is draft text and a real action (mid-turn inject, otherwise queue); an empty or blocked generating composer shows Stop in that same slot (square icon, danger styling, same footprint). After click or when the runtime is already cancelling, Stop stays 正在停止 and disabled until the turn ends. A missed cancel request re-enables it. Esc uses the same cancel path unless a dialog, menu, or preview already owns Escape; the shortcut overview and hover layer list Esc. Queued follow-ups show a count and preview with a clear action. The 已停止 banner does not show a raw `cancelled` status word; it uses 已按你的要求停止。可恢复草稿后重发。 After send, focus stays in the composer. Retry creates a new turn using the same validation path. Cursor is not on this surface. - Approval cards offer Allow / Deny; Always allow when this request includes that option. After Always allow, Codex / Grok / Kiro auto-accept later command/file prompts in the current process (Codex typically this turn; Grok / Kiro the live ACP process). Not saved. Chat then shows **本会话已一直允许**, distinct from session-settings auto-approve / Kiro full access; session settings can turn that remember off, and will not fake turning it on. File-change cards title **修改文件** and show the path. -- Streaming process details use a compact one-line summary of 正在读取 / 正在修改 / 正在执行 on the reply. Clicking it opens the right-hand pane with 你说了、thinking, tools, waiting allow/deny, turn usage, and run details (the same pane as Markdown preview; opening a file replaces process). Allow / Deny stay on the card — the pane does not invent buttons. Tool names, statuses, and JSON payloads stay in a per-step **细节** disclosure. Commands, stderr, status events, and exit codes stay in **运行详情**. After the turn ends, Codex / Grok may show a muted turn-only 输入 / 输出 footnote under the reply; nothing while generating, and no session total or window ratio. No fake usage bar. Continuous Codex / Grok / Kiro turns poll the focused snapshot about every 80ms and show **正在想** before the first character, then **正在写** with a caret. The body is the durable `currentMessage` from that read, not a client-side drip of a buffered reply. +- A current-turn plan list sits above the composer when the Agent published one. The header shows completed/total counts and status labels (待做 / 进行中 / 已完成 / 失败); the list collapses. Collapsed view keeps the counts and the in-progress row. Missing status counts as 待做. The bar is not a process timeline. +- Streaming process details use a compact one-line summary of 正在读取 / 正在修改 / 正在执行 on the reply. Clicking it opens the right-hand pane with 你说了、thinking, tools, waiting allow/deny, turn usage, and run details (the same pane as Markdown preview; opening a file replaces process). Allow / Deny stay on the card — the pane does not invent buttons. Tool names, statuses, and JSON payloads stay in a per-step **细节** disclosure. Commands, stderr, status events, and exit codes stay in **运行详情**. After the turn ends, Codex / Grok may show a muted turn-only 输入 / 输出 footnote under the reply; nothing while generating, and no session total or window ratio. No fake usage bar. Continuous Codex / Grok / Kiro turns poll the focused snapshot about every 80ms and show **正在想** before the first character, then **正在写** with a caret. The body is the durable `currentMessage` from that read, not a client-side drip of a buffered reply. A failed poll keeps the last view and shows **没法更新这场对话** with Retry after one second; it does not toast every poll, and it does not fall back to the one-shot send path. - Switching conversations does not cancel the active operation. Codex runtime keeps per-conversation process state and a replay cursor; its snapshot supplies the authoritative current reply. Legacy sends retain their existing in-memory process behavior. - Copy is available for completed user/Agent messages. Running messages do not show copy or retry. @@ -350,6 +351,7 @@ Chat is a one-conversation, one-Agent workbench with a session rail, transcript, - Header: Agent identity, working directory, how this chat connects, automatic-approval state, connection context. - Empty transcript: invite headline only; example chips fill the draft only (draft-only note on chip hover). Composer placeholder is generic (`发消息…`); Grok / Kiro / Claude queue-only limits and Enter / Shift+Enter use hover titles on an empty session. First-use toolbar keeps needed controls and quiets image/skill labels to icons; connection label truncates with a full-name hover. Composer blocker order: hidden Agent → environment not ready → missing authorization → unknown status → missing working directory; send is the composer accent action; rail **新建对话** uses the same theme fill. Enter sends, Shift+Enter makes a new line. While generating, one bottom-right control: Send injects or queues when the draft has text and that channel exists; empty draft shows icon Stop in the same slot; Esc still stops (dialogs/menus first). Queued lines show a count; Stop stays 正在停止 and disabled until the turn ends (re-enables if the cancel request misses); the 已停止 banner hides a raw `cancelled` status word; focus stays in the composer after send. Retry creates a new turn. Several conversations may generate at once. A compact shortcuts control opens the same overview on hover or click; `?` still opens the shortcuts dialog. Delete-confirm dialogs (session rail, backups) keep Enter-to-confirm and show a return-key icon, not the word Enter. - Approval cards: Allow / Deny; Always allow when the request includes that option. Codex / Grok / Kiro in-process remember (not saved). File-change cards show the path. +- Plan bar above the composer: counts and 待做 / 进行中 / 已完成 / 失败; collapsible. Snapshot poll failure: **没法更新这场对话** after one second, Retry, no toast spam. - Streaming process: one-line human 正在读取 / 正在修改 / 正在执行 on the reply; click to open thinking/tools in the right-hand pane; protocol details folded. After the turn ends, muted turn-only 输入 / 输出 under the reply when the protocol sent counts. Copy for completed messages only. - New Codex conversations use durable app-server snapshots and show actual approval/question requests as controls. Replies and stop target the exact run; snapshot failure does not fall back to legacy send. Codex B2 is in: session model/effort, `/` command search, local image attachments, and skills/plugins discovery for this turn (no plan mode; no toolbar skill button — use `/` or Codex auto-use). The composer does not show a duplicate Agent overflow (⋮) next to the selected Agent. `/` lists run-now actions (new chat, copy latest reply). Model/effort/skill appear when the query matches, not as a full dump on a bare `/`. When a Grok/Kiro session is ready and the peer declared commands, `/` also lists those names: choosing one sends `/name` as a normal turn, or inserts `/name ` when the declaration requires args. History, search, settings, Agents, and Connections stay on the rail or header. New Grok conversations are continuous (model/thinking, images, queued follow-ups); **Unsupported**: choosing a skill “for this turn” (no clickable fake control). New Kiro conversations use the ACP continuous channel; old Kiro chats keep the original send path. New Claude conversations use stream-json continuous chat (images; no mid-turn steer; no approval cards in this slice); old Claude chats keep print+resume. See [B2](../archive/chat-codex-b2.md) and [STATUS](../STATUS.md). - Runtime confirmation cards use **允许** / **拒绝**. **一直允许** appears only when this request includes that option (Codex command/file prompts always include it; Grok / Kiro only if the ACP request does, including Kiro `allow_always_tool`). Pending option lists are stored with the request so a snapshot or restart can still show the same buttons. After **一直允许**, Codex / Grok / Kiro auto-accept later command/file prompts in the current process (Codex typically this turn; Grok / Kiro the live ACP process; not saved). Grok / Kiro also forward the server option. Kiro session **完全访问权限** is a separate start-time setting, not this card. Cursor is out of this surface. See [Chat 与 Agent](../concepts/chat-and-agents.md#允许-拒绝-一直允许). diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 6f612558..457207a5 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2871,6 +2871,16 @@ export const en = { needConfirm: 'Needs confirmation', needAnswer: 'Needs your answer', plan: 'Plan', + planProgress: '{done}/{total} done', + planStatusPending: 'To do', + planStatusLive: 'In progress', + planStatusDone: 'Done', + planStatusFailed: 'Failed', + planExpand: 'Expand plan', + planCollapse: 'Collapse plan', + snapshotStale: "Couldn't update this chat", + snapshotStaleHint: 'Showing the last content we have. Retry to read again.', + snapshotRetry: 'Retry', hostCommand: 'Run command', stopCommand: 'Stop this one', stopCommandFailed: "Couldn't stop this command", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 7314d5de..20348e9d 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2851,6 +2851,16 @@ export const zh = { needConfirm: '需要确认', needAnswer: '需要你的回答', plan: '计划', + planProgress: '{done}/{total} 已完成', + planStatusPending: '待做', + planStatusLive: '进行中', + planStatusDone: '已完成', + planStatusFailed: '失败', + planExpand: '展开计划', + planCollapse: '收起计划', + snapshotStale: '没法更新这场对话', + snapshotStaleHint: '停在最后看到的内容。点重试再读一次。', + snapshotRetry: '重试', hostCommand: '执行命令', stopCommand: '停止这条', stopCommandFailed: '没法停止这条命令', diff --git a/src/pages/chat/ChatPlanBar.tsx b/src/pages/chat/ChatPlanBar.tsx index 3c5cbf2b..809b8602 100644 --- a/src/pages/chat/ChatPlanBar.tsx +++ b/src/pages/chat/ChatPlanBar.tsx @@ -1,36 +1,85 @@ +import { useState } from 'react'; +import { ChevronDown } from 'lucide-react'; import { useI18n } from '@/components/shared/LanguageProvider'; import { cn } from '@/lib/utils'; import type { RuntimePlanEntry } from '@/lib/api/chat'; -import { runtimePlanEntryTone, visibleRuntimePlan } from './chat-runtime-model'; +import { + runtimePlanEntryTone, + runtimePlanProgress, + runtimePlanStatusKey, + visibleRuntimePlan, +} from './chat-runtime-model'; export function ChatPlanBar({ plan }: { plan?: RuntimePlanEntry[] | null }) { const { t } = useI18n(); const entries = visibleRuntimePlan(plan); + const [open, setOpen] = useState(true); if (entries.length === 0) return null; + const progress = runtimePlanProgress(entries); + const live = entries.find((entry) => runtimePlanEntryTone(entry.status) === 'live'); + const listId = 'chat-plan-bar-list'; return (
-

{t('chat.runtime.plan')}

-
    - {entries.map((entry, index) => { - const tone = runtimePlanEntryTone(entry.status); - return ( -
  1. - {entry.content} -
  2. - ); - })} -
+ + {open ? ( +
    + {entries.map((entry, index) => { + const tone = runtimePlanEntryTone(entry.status); + return ( +
  1. + + {t(runtimePlanStatusKey(entry.status))} + + {entry.content} +
  2. + ); + })} +
+ ) : live ? ( +

+ {t(runtimePlanStatusKey(live.status))} · + {live.content} +

+ ) : null}
); } diff --git a/src/pages/chat/ChatTranscript.tsx b/src/pages/chat/ChatTranscript.tsx index 3bc01e0c..f8cd4347 100644 --- a/src/pages/chat/ChatTranscript.tsx +++ b/src/pages/chat/ChatTranscript.tsx @@ -136,6 +136,7 @@ export function ChatTranscript({
{g.user && ( 1} diff --git a/src/pages/chat/chat-grok-follow-up.test.ts b/src/pages/chat/chat-grok-follow-up.test.ts index c726120f..7fa82cac 100644 --- a/src/pages/chat/chat-grok-follow-up.test.ts +++ b/src/pages/chat/chat-grok-follow-up.test.ts @@ -102,6 +102,10 @@ describe('queued follow-up items', () => { expect(queuedFollowUpCount(queued)).toBe(2); expect(queuedFollowUpCount([{ id: 'blank', text: ' ' }, { id: 'q-3', text: '第三条' }])).toBe(1); expect(shiftQueuedFollowUp([])).toBeNull(); + const withImage = appendQueuedFollowUp([], '带图', 'q-img', { + images: [{ path: '/tmp/a.png' }], + }); + expect(withImage[0]?.extras).toEqual({ images: [{ path: '/tmp/a.png' }] }); }); it('cancels one item without joining the rest', () => { diff --git a/src/pages/chat/chat-grok-follow-up.ts b/src/pages/chat/chat-grok-follow-up.ts index 01355e81..f07f6603 100644 --- a/src/pages/chat/chat-grok-follow-up.ts +++ b/src/pages/chat/chat-grok-follow-up.ts @@ -1,9 +1,15 @@ import type { RuntimePhase } from '@/lib/backend/contracts/chat-runtime'; import { isRuntimeActive } from './chat-runtime-model'; +export type QueuedFollowUpExtras = { + images?: { path: string }[]; + skills?: { name: string; path: string }[]; +}; + export type QueuedFollowUpItem = { id: string; text: string; + extras?: QueuedFollowUpExtras; }; let queuedFollowUpSeq = 0; @@ -40,11 +46,17 @@ export function chatBusySendMode(input: { return 'queue'; } -export function createQueuedFollowUpItem(text: string, id?: string): QueuedFollowUpItem | null { +export function createQueuedFollowUpItem( + text: string, + id?: string, + extras?: QueuedFollowUpExtras, +): QueuedFollowUpItem | null { const next = text.trim(); if (!next) return null; queuedFollowUpSeq += 1; - return { id: id ?? `queued-${queuedFollowUpSeq}`, text: next }; + const item: QueuedFollowUpItem = { id: id ?? `queued-${queuedFollowUpSeq}`, text: next }; + if (extras) item.extras = extras; + return item; } export function queuedFollowUpItems(queue: readonly QueuedFollowUpItem[]): QueuedFollowUpItem[] { @@ -55,8 +67,9 @@ export function appendQueuedFollowUp( queue: readonly QueuedFollowUpItem[], prompt: string, id?: string, + extras?: QueuedFollowUpExtras, ): QueuedFollowUpItem[] { - const item = createQueuedFollowUpItem(prompt, id); + const item = createQueuedFollowUpItem(prompt, id, extras); if (!item) return [...queue]; return [...queue, item]; } @@ -65,8 +78,9 @@ export function prependQueuedFollowUp( queue: readonly QueuedFollowUpItem[], prompt: string, id?: string, + extras?: QueuedFollowUpExtras, ): QueuedFollowUpItem[] { - const item = createQueuedFollowUpItem(prompt, id); + const item = createQueuedFollowUpItem(prompt, id, extras); if (!item) return [...queue]; return [item, ...queue]; } diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 8825b449..3b25e98a 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -18,6 +18,7 @@ describe('chat layout wiring', () => { expect(page).not.toContain('flex min-w-0 flex-1 flex-col bg-panel'); expect(source('ChatMessageBubble.tsx')).toContain('formatChatDisplayContent'); expect(source('ChatTranscript.tsx')).toContain('overflow-x-hidden overflow-y-auto'); + expect(source('ChatTranscript.tsx')).toContain('key={g.user.id}'); }); it('lets Escape stop an in-flight turn', () => { @@ -535,6 +536,13 @@ describe('chat layout wiring', () => { expect(source('ChatMessageBubble.tsx')).not.toContain('ChatPlanBar'); expect(source('ChatProcessPanel.tsx')).not.toContain('formatVisibleUsage'); expect(source('index.tsx')).toContain('ChatPlanBar'); + expect(source('ChatPlanBar.tsx')).toContain('aria-expanded={open}'); + expect(source('ChatPlanBar.tsx')).toContain('chat.runtime.planProgress'); + expect(source('ChatPlanBar.tsx')).toContain('runtimePlanStatusKey'); + expect(translate('zh', 'chat.runtime.planStatusLive')).toBe('进行中'); + expect(translate('zh', 'chat.runtime.planCollapse')).toBe('收起计划'); + expect(source('use-chat-page-send.ts')).toContain('recordSnapshotPollFailure'); + expect(source('index.tsx')).toContain('chat.runtime.snapshotStale'); expect(source('index.tsx')).toContain('ChatHostTerminals'); expect(source('ChatHostTerminals.tsx')).toContain('chat.runtime.stopCommand'); expect(source('ChatHostTerminals.tsx')).not.toContain('xterm'); diff --git a/src/pages/chat/chat-runtime-model.test.ts b/src/pages/chat/chat-runtime-model.test.ts index ae767add..967596fc 100644 --- a/src/pages/chat/chat-runtime-model.test.ts +++ b/src/pages/chat/chat-runtime-model.test.ts @@ -21,6 +21,8 @@ import { readRuntimeTransport, requestMatchesRuntime, runtimePlanEntryTone, + runtimePlanProgress, + runtimePlanStatusKey, visibleRuntimePlan, } from './chat-runtime-model'; @@ -239,5 +241,15 @@ describe('chat runtime transport guards', () => { expect(runtimePlanEntryTone('in_progress')).toBe('live'); expect(runtimePlanEntryTone('completed')).toBe('done'); expect(runtimePlanEntryTone('pending')).toBe('pending'); + expect(runtimePlanEntryTone('failed')).toBe('failed'); + expect(runtimePlanProgress([ + { content: 'read', status: 'completed' }, + { content: 'edit', status: 'in_progress' }, + { content: 'test', status: 'pending' }, + { content: 'broken', status: 'failed' }, + ])).toEqual({ total: 4, done: 1, live: 1, pending: 1, failed: 1 }); + expect(runtimePlanStatusKey('in_progress')).toBe('chat.runtime.planStatusLive'); + expect(translate('zh', runtimePlanStatusKey('completed'))).toBe('已完成'); + expect(translate('zh', 'chat.runtime.planProgress', { done: 1, total: 3 })).toBe('1/3 已完成'); }); }); diff --git a/src/pages/chat/chat-runtime-model.ts b/src/pages/chat/chat-runtime-model.ts index 1fd167a7..857033db 100644 --- a/src/pages/chat/chat-runtime-model.ts +++ b/src/pages/chat/chat-runtime-model.ts @@ -282,7 +282,7 @@ export function visibleRuntimePlan( return (plan ?? []).filter((entry) => entry.content.trim().length > 0); } -export type RuntimePlanTone = 'live' | 'done' | 'pending'; +export type RuntimePlanTone = 'live' | 'done' | 'pending' | 'failed'; export function runtimePlanEntryTone(status?: string | null): RuntimePlanTone { const normalized = (status ?? '').trim().toLowerCase().replace(/-/g, '_'); @@ -297,5 +297,51 @@ export function runtimePlanEntryTone(status?: string | null): RuntimePlanTone { ) { return 'live'; } + if ( + normalized === 'failed' + || normalized === 'error' + || normalized === 'cancelled' + || normalized === 'canceled' + ) { + return 'failed'; + } return 'pending'; } + +export type RuntimePlanProgress = { + total: number; + done: number; + live: number; + pending: number; + failed: number; +}; + +/** Counts from protocol status only. Missing status counts as pending. */ +export function runtimePlanProgress( + plan?: RuntimePlanEntry[] | null, +): RuntimePlanProgress { + const entries = visibleRuntimePlan(plan); + const progress: RuntimePlanProgress = { + total: entries.length, + done: 0, + live: 0, + pending: 0, + failed: 0, + }; + for (const entry of entries) { + const tone = runtimePlanEntryTone(entry.status); + if (tone === 'done') progress.done += 1; + else if (tone === 'live') progress.live += 1; + else if (tone === 'failed') progress.failed += 1; + else progress.pending += 1; + } + return progress; +} + +export function runtimePlanStatusKey(status?: string | null): MessageKey { + const tone = runtimePlanEntryTone(status); + if (tone === 'done') return 'chat.runtime.planStatusDone'; + if (tone === 'live') return 'chat.runtime.planStatusLive'; + if (tone === 'failed') return 'chat.runtime.planStatusFailed'; + return 'chat.runtime.planStatusPending'; +} diff --git a/src/pages/chat/chat-snapshot-sync.test.ts b/src/pages/chat/chat-snapshot-sync.test.ts new file mode 100644 index 00000000..f8b31674 --- /dev/null +++ b/src/pages/chat/chat-snapshot-sync.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { + beginSnapshotSyncRetry, + idleSnapshotSync, + recordSnapshotPollFailure, + recordSnapshotPollSuccess, + SNAPSHOT_BANNER_AFTER_MS, + snapshotSyncBannerDelayMs, + snapshotSyncForConversation, + snapshotSyncShowsBanner, +} from './chat-snapshot-sync'; + +describe('snapshot poll failure', () => { + it('does not show a banner on the first instant failure', () => { + const failed = recordSnapshotPollFailure(idleSnapshotSync(), 'chat-a', new Error('down'), 1_000); + expect(failed.failures).toBe(1); + expect(snapshotSyncShowsBanner(failed, 1_000)).toBe(false); + expect(snapshotSyncBannerDelayMs(failed, 1_000)).toBe(SNAPSHOT_BANNER_AFTER_MS); + }); + + it('shows one banner after the delay and clears it on success', () => { + const first = recordSnapshotPollFailure(idleSnapshotSync(), 'chat-a', new Error('down'), 1_000); + const second = recordSnapshotPollFailure(first, 'chat-a', new Error('still down'), 1_080); + expect(second.failures).toBe(2); + expect(snapshotSyncShowsBanner(second, 1_080)).toBe(false); + expect(snapshotSyncShowsBanner(second, 1_000 + SNAPSHOT_BANNER_AFTER_MS)).toBe(true); + const retrying = beginSnapshotSyncRetry(second); + expect(retrying.retrying).toBe(true); + const recovered = recordSnapshotPollSuccess(retrying, 'chat-a'); + expect(recovered.failures).toBe(0); + expect(snapshotSyncShowsBanner(recovered, 9_000)).toBe(false); + expect(recordSnapshotPollSuccess(second, 'chat-b').failures).toBe(2); + }); + + it('resets when switching conversations', () => { + const failed = recordSnapshotPollFailure(idleSnapshotSync(), 'chat-a', new Error('down'), 1_000); + const switched = snapshotSyncForConversation('chat-b', failed); + expect(switched.failures).toBe(0); + expect(switched.conversationId).toBe('chat-b'); + expect(snapshotSyncForConversation('chat-a', failed)).toBe(failed); + }); +}); diff --git a/src/pages/chat/chat-snapshot-sync.ts b/src/pages/chat/chat-snapshot-sync.ts new file mode 100644 index 00000000..8fb82160 --- /dev/null +++ b/src/pages/chat/chat-snapshot-sync.ts @@ -0,0 +1,80 @@ +/** Snapshot poll failures must not toast every 80ms. Keep the last view. */ + +export const SNAPSHOT_BANNER_AFTER_MS = 1000; + +export type SnapshotSyncState = { + conversationId: string | null; + failures: number; + firstFailedAt: number | null; + lastError: string | null; + retrying: boolean; +}; + +export function idleSnapshotSync(): SnapshotSyncState { + return { + conversationId: null, + failures: 0, + firstFailedAt: null, + lastError: null, + retrying: false, + }; +} + +export function snapshotSyncForConversation( + conversationId: string | null, + previous: SnapshotSyncState = idleSnapshotSync(), +): SnapshotSyncState { + if (previous.conversationId === conversationId) return previous; + return { ...idleSnapshotSync(), conversationId }; +} + +export function recordSnapshotPollSuccess( + state: SnapshotSyncState, + conversationId: string, +): SnapshotSyncState { + if (state.conversationId && state.conversationId !== conversationId) return state; + return { + conversationId, + failures: 0, + firstFailedAt: null, + lastError: null, + retrying: false, + }; +} + +export function recordSnapshotPollFailure( + state: SnapshotSyncState, + conversationId: string, + error: unknown, + now: number, +): SnapshotSyncState { + const same = state.conversationId === conversationId && state.failures > 0; + const message = error instanceof Error ? error.message : String(error); + return { + conversationId, + failures: same ? state.failures + 1 : 1, + firstFailedAt: same ? state.firstFailedAt ?? now : now, + lastError: message.trim() || null, + retrying: false, + }; +} + +export function beginSnapshotSyncRetry(state: SnapshotSyncState): SnapshotSyncState { + if (!state.conversationId || state.failures === 0) return state; + return { ...state, retrying: true }; +} + +export function snapshotSyncShowsBanner(state: SnapshotSyncState, now: number): boolean { + if (!state.conversationId || state.failures === 0 || state.firstFailedAt == null) { + return false; + } + return now - state.firstFailedAt >= SNAPSHOT_BANNER_AFTER_MS; +} + +export function snapshotSyncBannerDelayMs(state: SnapshotSyncState, now: number): number | null { + if (!state.conversationId || state.failures === 0 || state.firstFailedAt == null) { + return null; + } + const remaining = SNAPSHOT_BANNER_AFTER_MS - (now - state.firstFailedAt); + return remaining > 0 ? remaining : null; +} diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index d34c3238..50b0c08c 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -442,6 +442,24 @@ export default function ChatPage() { onRestoreDraft={() => page.setDraft(page.turnOutcome?.prompt ?? '')} /> ) : null} + {page.snapshotBannerVisible ? ( + +
+
+

{t('chat.runtime.snapshotStale')}

+

{t('chat.runtime.snapshotStaleHint')}

+
+ +
+
+ ) : null} {(() => { const stance = kiroChatStance(page.primaryAgent); if (!stance?.showBanner) return null; diff --git a/src/pages/chat/runtime-run-state.test.ts b/src/pages/chat/runtime-run-state.test.ts index 78ca7e22..de2491f4 100644 --- a/src/pages/chat/runtime-run-state.test.ts +++ b/src/pages/chat/runtime-run-state.test.ts @@ -111,6 +111,17 @@ describe('runtime state across visits and async sources', () => { }); expect(messages).toHaveLength(1); expect(messages[0].content).toBe('haha'); + + const user = upsertRuntimeMessage([], { + id: 'client-1', conversationId: 'a', turn: 1, role: 'user', + content: 'hello', status: 'ok', durationMs: 0, createdAt: '', + }); + const sameUser = upsertRuntimeMessage(user, { + id: 'client-1', conversationId: 'a', turn: 1, role: 'user', + content: 'hello', status: 'ok', durationMs: 0, createdAt: '', + }); + expect(sameUser).toHaveLength(1); + expect(sameUser[0].id).toBe('client-1'); }); it('serializes a start response ahead of a later poll and rejects an older rewind', async () => { diff --git a/src/pages/chat/use-chat-page-send.ts b/src/pages/chat/use-chat-page-send.ts index d0c52893..05c6da14 100644 --- a/src/pages/chat/use-chat-page-send.ts +++ b/src/pages/chat/use-chat-page-send.ts @@ -63,6 +63,16 @@ import { RUNTIME_SNAPSHOT_POLL_ACTIVE_MS, RUNTIME_SNAPSHOT_POLL_BACKGROUND_MS, } from './chat-streaming'; +import { + beginSnapshotSyncRetry, + idleSnapshotSync, + recordSnapshotPollFailure, + recordSnapshotPollSuccess, + snapshotSyncBannerDelayMs, + snapshotSyncForConversation, + snapshotSyncShowsBanner, + type SnapshotSyncState, +} from './chat-snapshot-sync'; /** * Chat 发送 / 取消 / 流式事件 / 过程面板。 @@ -117,6 +127,8 @@ export function useChatPageSend(input: { const [cancelingIds, setCancelingIds] = useState([]); const [processMap, setProcessMap] = useState({}); const [runtime, setRuntime] = useState(null); + const [snapshotSync, setSnapshotSync] = useState(idleSnapshotSync); + const [snapshotRetryNonce, setSnapshotRetryNonce] = useState(0); const runtimeSequenceRef = useRef(new Map()); const runtimeRecordsRef = useRef(new Map()); const runtimeViewsRef = useRef(new Map()); @@ -162,25 +174,33 @@ export function useChatPageSend(input: { publishFollowUps(); }; - const appendFollowUp = (conversationId: string, prompt: string) => { + const appendFollowUp = ( + conversationId: string, + prompt: string, + extras?: QueuedFollowUpItem['extras'], + ) => { setFollowUpQueue( conversationId, - appendQueuedFollowUp(followUpsRef.current.get(conversationId) ?? [], prompt), + appendQueuedFollowUp(followUpsRef.current.get(conversationId) ?? [], prompt, undefined, extras), ); }; - const prependFollowUp = (conversationId: string, prompt: string) => { + const prependFollowUp = ( + conversationId: string, + prompt: string, + extras?: QueuedFollowUpItem['extras'], + ) => { setFollowUpQueue( conversationId, - prependQueuedFollowUp(followUpsRef.current.get(conversationId) ?? [], prompt), + prependQueuedFollowUp(followUpsRef.current.get(conversationId) ?? [], prompt, undefined, extras), ); }; - const dequeueFollowUp = (conversationId: string): string | null => { + const dequeueFollowUp = (conversationId: string): QueuedFollowUpItem | null => { const shifted = shiftQueuedFollowUp(followUpsRef.current.get(conversationId) ?? []); if (!shifted) return null; setFollowUpQueue(conversationId, shifted.rest); - return shifted.next.text; + return shifted.next; }; const removeFollowUp = (conversationId: string, itemId: string) => { @@ -281,6 +301,9 @@ export function useChatPageSend(input: { const canRender = applyUi && acceptsRuntimeSnapshot(activeIdRef.current, activeGenerationRef.current, conversationId, generation); + if (conversationId === activeIdRef.current) { + setSnapshotSync((previous) => recordSnapshotPollSuccess(previous, conversationId)); + } if (canRender && snapshot.currentMessage) { setMessages((previous) => upsertRuntimeMessage(previous, snapshot.currentMessage!)); } @@ -359,8 +382,8 @@ export function useChatPageSend(input: { }, ); } catch (error) { - if (!disposed && isLatestRuntimeRead(readId, runtimeReadRef.current.get(id) ?? 0) && runtime?.enabled) { - toast({ title: error instanceof Error ? error.message : String(error), variant: 'danger' }); + if (!disposed && isLatestRuntimeRead(readId, runtimeReadRef.current.get(id) ?? 0)) { + setSnapshotSync((previous) => recordSnapshotPollFailure(previous, id, error, Date.now())); } } finally { inFlight = false; @@ -371,7 +394,20 @@ export function useChatPageSend(input: { if (!shouldPoll) return () => { disposed = true; }; const timer = window.setInterval(() => void read(), RUNTIME_SNAPSHOT_POLL_ACTIVE_MS); return () => { disposed = true; window.clearInterval(timer); }; - }, [activeId, activeAgentId, runtime?.enabled, runtime?.phase]); + }, [activeId, activeAgentId, runtime?.enabled, runtime?.phase, snapshotRetryNonce]); + + useEffect(() => { + setSnapshotSync((previous) => snapshotSyncForConversation(activeId, previous)); + }, [activeId]); + + useEffect(() => { + const delay = snapshotSyncBannerDelayMs(snapshotSync, Date.now()); + if (delay == null) return; + const timer = window.setTimeout(() => { + setSnapshotSync((previous) => ({ ...previous })); + }, delay); + return () => window.clearTimeout(timer); + }, [snapshotSync.conversationId, snapshotSync.failures, snapshotSync.firstFailedAt]); // Background runs stay owned by their conversations. Poll them so a terminal // snapshot can release that session without a page-wide sending lock. @@ -550,11 +586,11 @@ export function useChatPageSend(input: { } } - async function dispatchQueuedFollowUp(conversationId: string, prompt: string) { - const text = prompt.trim(); + async function dispatchQueuedFollowUp(conversationId: string, item: QueuedFollowUpItem) { + const text = item.text.trim(); if (!text) return; if (activeIdRef.current === conversationId && !sendingIdsRef.current.has(conversationId)) { - await sendPrompt(text, false); + await sendPrompt(text, false, item.extras ?? {}); return; } markSending(conversationId); @@ -562,7 +598,7 @@ export function useChatPageSend(input: { const applyUi = conversationId === activeIdRef.current; const fail = (error: unknown) => { clearSendingFor(conversationId); - prependFollowUp(conversationId, text); + prependFollowUp(conversationId, text, item.extras); toast({ title: error instanceof Error ? error.message : String(error), variant: 'danger', @@ -585,7 +621,7 @@ export function useChatPageSend(input: { try { await enqueueRuntimeSnapshot( conversationId, - () => runtimeStart(conversationId, text, crypto.randomUUID()), + () => runtimeStart(conversationId, text, crypto.randomUUID(), item.extras), (snapshot, sourceVersion) => { applyRuntimeSnapshot( snapshot, @@ -614,12 +650,17 @@ export function useChatPageSend(input: { } } - async function sendPrompt(prompt: string, clearDraft: boolean) { + async function sendPrompt( + prompt: string, + clearDraft: boolean, + extrasOverride?: QueuedFollowUpItem['extras'], + ) { if (!active) return; if (sendingIdsRef.current.has(active.id)) { const next = prompt.trim(); if (!next) return; - appendFollowUp(active.id, next); + appendFollowUp(active.id, next, getStartExtras?.()); + clearStartExtras?.(); if (clearDraft) setDraft(''); toast({ title: t('chat.toast.queuedAfterTurn'), @@ -644,7 +685,8 @@ export function useChatPageSend(input: { markSending(sendConvId); if (clearDraft) setDraft(''); const turnGuess = messages.reduce((max, m) => Math.max(max, m.turn), 0) + 1; - const localUserId = `local-user-${Date.now()}`; + const extras = extrasOverride !== undefined ? extrasOverride : getStartExtras?.(); + const localUserId = crypto.randomUUID(); setMessages((prev) => [ ...prev, { @@ -722,7 +764,7 @@ export function useChatPageSend(input: { try { await enqueueRuntimeSnapshot( sendConvId, - () => runtimeStart(sendConvId, prompt, crypto.randomUUID(), getStartExtras?.()), + () => runtimeStart(sendConvId, prompt, localUserId, extras), async (nextSnapshot, sourceVersion) => { const currentStartRecord = runtimeRecordsRef.current.get(sendConvId); if (currentStartRecord?.cancelRequested && nextSnapshot.runId && isRuntimeActive(nextSnapshot.phase)) { @@ -1020,6 +1062,12 @@ export function useChatPageSend(input: { } } + async function retryRuntimeSnapshot() { + if (!activeId) return; + setSnapshotSync((previous) => beginSnapshotSyncRetry(previous)); + setSnapshotRetryNonce((current) => current + 1); + } + async function steerRuntime(prompt: string): Promise { if (!active || !runtime?.enabled || !runtimeIdRef.current || !prompt.trim()) return false; try { @@ -1057,6 +1105,9 @@ export function useChatPageSend(input: { adoptInflight, cancelIfSending, runtime, + snapshotSync, + snapshotBannerVisible: snapshotSyncShowsBanner(snapshotSync, Date.now()), + retryRuntimeSnapshot, submitRuntimeRequest, clearSessionAllowAlways, steerRuntime, diff --git a/src/pages/chat/use-chat-page.ts b/src/pages/chat/use-chat-page.ts index 78f324ab..26b5bad4 100644 --- a/src/pages/chat/use-chat-page.ts +++ b/src/pages/chat/use-chat-page.ts @@ -748,6 +748,8 @@ export function useChatPage() { clearQueuedFollowUp: send.clearQueuedFollowUp, continueLegacyGrok: send.continueLegacyGrok, runtime: activeRuntime, + snapshotBannerVisible: send.snapshotBannerVisible, + retryRuntimeSnapshot: send.retryRuntimeSnapshot, runtimeLocked: isRuntimeSessionLocked(activeRuntime, { conversationId: active?.id, nativeSessionId: active?.nativeSessionId, From fa134423d9d07093d2ff085ef475a5e938b29905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=83=E6=B0=94?= Date: Sun, 20 Sep 2026 18:02:46 +0800 Subject: [PATCH 02/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9AClaude=20?= =?UTF-8?q?=E6=96=B0=E5=AF=B9=E8=AF=9D=E4=BB=BB=E5=8A=A1=E6=B8=85=E5=8D=95?= =?UTF-8?q?=E6=8E=A5=E5=88=B0=E8=AE=A1=E5=88=92=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude 新对话把 TodoWrite / Task 工具的任务清单写进输入区上方的计划条(可折叠、带进展),不进过程时间线。空更新不抹掉已有清单。旧对话仍走原来的发送方式。 --- .../src/services/chat_runtime/actor_tests.rs | 169 +++++++++ .../src/services/chat_runtime/mod.rs | 187 ++++++++++ .../src/services/chat_runtime/types.rs | 5 +- .../src/utils/stream_parse/claude.rs | 327 +++++++++++++++++- .../src/utils/stream_parse/claude/tests.rs | 184 ++++++++++ docs/STATUS.md | 4 +- docs/concepts/chat-and-agents.md | 4 +- docs/concepts/chat-process-events.md | 4 +- docs/reference/chat-support-depth.md | 4 +- docs/ui/page-patterns.md | 2 +- src/lib/backend/contracts/chat-runtime.ts | 4 +- 11 files changed, 878 insertions(+), 16 deletions(-) diff --git a/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs b/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs index 7f3ff93e..62681eae 100644 --- a/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs +++ b/crates/agenthub-core/src/services/chat_runtime/actor_tests.rs @@ -2651,6 +2651,175 @@ fn claude_stream_result_completes_turn_and_keeps_session() { ); } +#[test] +fn claude_todo_write_fills_snapshot_not_timeline() { + let db = Database::open_in_memory().unwrap(); + conversation_with(&db, "claude-plan", AgentId::Claude, &std::env::temp_dir()); + let mut worker = worker(&db, "claude-plan"); + worker.agent = AgentId::Claude; + worker.store.enable_if_new("claude-plan").unwrap(); + start_placeholder(&mut worker); + + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "session_id": "claude-sess-plan", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_todo", + "name": "TodoWrite", + "input": { + "todos": [ + { "content": "read", "status": "completed" }, + { "content": "edit", "status": "in_progress" } + ] + } + }] + } + }), + ) + .unwrap(); + + let stored = worker.store.snapshot("claude-plan", None).unwrap(); + assert!(!stored.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { .. } + ))); + let snapshot = worker.with_catalog_epoch(stored); + assert_eq!(snapshot.plan.len(), 2); + assert_eq!(snapshot.plan[0].content, "read"); + assert_eq!(snapshot.plan[0].status.as_deref(), Some("completed")); + assert_eq!(snapshot.plan[1].content, "edit"); + assert_eq!(snapshot.plan[1].status.as_deref(), Some("in_progress")); + + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "subject": "write tests" } + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "user", + "tool_use_result": { "task": { "id": "task-2", "subject": "write tests" } }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_create", + "content": "created" + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TaskUpdate", + "input": { "taskId": "task-2", "status": "in_progress" } + }] + } + }), + ) + .unwrap(); + + worker + .notification( + "claude/stream", + &json!({ + "type": "user", + "tool_use_result": { + "oldTodos": [{ "content": "read", "status": "pending" }], + "newTodos": [{ "content": "read", "status": "completed" }] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_todo", + "content": "Todos have been modified successfully." + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_bash", + "name": "Bash", + "input": { "command": "ls" } + }] + } + }), + ) + .unwrap(); + worker + .notification( + "claude/stream", + &json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TodoWrite", + "input": { "todos": [] } + }] + } + }), + ) + .unwrap(); + + let patched = worker.with_catalog_epoch(worker.store.snapshot("claude-plan", None).unwrap()); + assert_eq!(patched.plan.len(), 3); + assert_eq!(patched.plan[2].content, "write tests"); + assert_eq!(patched.plan[2].id.as_deref(), Some("task-2")); + assert_eq!(patched.plan[2].status.as_deref(), Some("in_progress")); + assert!(patched.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { + step: crate::models::ProcessStep::Tool { name, .. }, + .. + } if name == "Bash" + ))); + assert!(!patched.events.iter().any(|event| matches!( + &event.event, + ChatEvent::AgentProcess { + step: crate::models::ProcessStep::Tool { name, .. }, + .. + } if name == "TodoWrite" || name == "TaskCreate" || name == "TaskUpdate" || name == "tool" + ))); + + worker.clear_turn_plan(); + let cleared = worker.with_catalog_epoch(worker.store.snapshot("claude-plan", None).unwrap()); + assert!(cleared.plan.is_empty()); +} + #[cfg(unix)] #[test] fn grok_fs_write_outside_cwd_emits_card_then_writes_on_allow() { diff --git a/crates/agenthub-core/src/services/chat_runtime/mod.rs b/crates/agenthub-core/src/services/chat_runtime/mod.rs index 671f791c..7ad8cf74 100644 --- a/crates/agenthub-core/src/services/chat_runtime/mod.rs +++ b/crates/agenthub-core/src/services/chat_runtime/mod.rs @@ -102,6 +102,10 @@ struct CatalogCache { image_input: Option, catalog_epoch: i64, plan: Vec, + /// TaskCreate tool_use id → subject, until the result assigns a task id. + pending_plan_creates: Vec<(String, String)>, + /// Plan-tool `tool_use` ids this turn, so matching results stay off the timeline. + plan_tool_use_ids: Vec, /// Conversation-local Always allow. Survives catalog refetch; not SQLite. session_allow_always: bool, } @@ -117,6 +121,8 @@ fn merge_catalog_cache(previous: Option<&CatalogCache>, mut fetched: CatalogCach fetched.catalog_epoch = previous.catalog_epoch.max(fetched.catalog_epoch); if fetched.plan.is_empty() { fetched.plan = previous.plan.clone(); + fetched.pending_plan_creates = previous.pending_plan_creates.clone(); + fetched.plan_tool_use_ids = previous.plan_tool_use_ids.clone(); } fetched.session_allow_always = fetched.session_allow_always || previous.session_allow_always; @@ -124,6 +130,128 @@ fn merge_catalog_cache(previous: Option<&CatalogCache>, mut fetched: CatalogCach fetched } +fn apply_claude_plan_op( + cache: &mut CatalogCache, + op: crate::utils::stream_parse::claude::ClaudePlanOp, +) { + use crate::utils::stream_parse::claude::ClaudePlanOp; + match op { + ClaudePlanOp::Replace(entries) => { + let next: Vec = entries + .into_iter() + .filter(|entry| !entry.content.trim().is_empty()) + .map(|entry| RuntimePlanEntry { + content: entry.content, + status: entry.status, + priority: entry.priority, + id: entry.id, + }) + .collect(); + if next.is_empty() { + return; + } + cache.plan = next; + cache.pending_plan_creates.clear(); + } + ClaudePlanOp::Create { + tool_use_id, + content, + status, + id, + priority, + } => { + let content = content.trim(); + if content.is_empty() { + return; + } + if let Some(existing_id) = id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if let Some(existing) = cache + .plan + .iter_mut() + .find(|entry| entry.id.as_deref() == Some(existing_id)) + { + existing.content = content.to_string(); + if status.is_some() { + existing.status = status; + } + if priority.is_some() { + existing.priority = priority; + } + return; + } + } + cache.plan.push(RuntimePlanEntry { + content: content.to_string(), + status: status.or_else(|| Some("pending".into())), + priority, + id: id.filter(|value| !value.trim().is_empty()), + }); + if let Some(tool_use_id) = tool_use_id.filter(|value| !value.is_empty()) { + cache + .pending_plan_creates + .push((tool_use_id, content.to_string())); + } + } + ClaudePlanOp::Update { + id, + status, + content, + priority, + } => { + let id = id.trim(); + if id.is_empty() { + return; + } + let deleted = status.as_deref().is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "deleted" | "removed" + ) + }); + if deleted { + cache.plan.retain(|entry| entry.id.as_deref() != Some(id)); + return; + } + if let Some(existing) = cache + .plan + .iter_mut() + .find(|entry| entry.id.as_deref() == Some(id)) + { + if let Some(status) = status { + existing.status = Some(status); + } + if let Some(content) = content.filter(|value| !value.trim().is_empty()) { + existing.content = content; + } + if let Some(priority) = priority { + existing.priority = Some(priority); + } + } + } + ClaudePlanOp::BindId { tool_use_id, id } => { + let Some(pos) = cache + .pending_plan_creates + .iter() + .position(|(pending, _)| pending == &tool_use_id) + else { + return; + }; + let (_, subject) = cache.pending_plan_creates.remove(pos); + if let Some(entry) = cache + .plan + .iter_mut() + .find(|entry| entry.id.is_none() && entry.content == subject) + { + entry.id = Some(id); + } + } + } +} + /// Map product identity → transport channel. Same channel (e.g. ACP) may be /// shared by multiple AgentIds; identities remain distinct (identity families). fn runtime_channel(agent: Option) -> RuntimeChannel { @@ -1906,6 +2034,12 @@ impl ActorWorker { } } + self.remember_claude_plan_tools(params); + let plan_ops = crate::utils::stream_parse::claude::extract_todo_plan(params); + if !plan_ops.is_empty() { + self.apply_claude_plan(plan_ops); + } + let ty = params.get("type").and_then(Value::as_str).unwrap_or(""); if ty == "result" { let is_err = params @@ -1919,6 +2053,9 @@ impl ActorWorker { if let Some(steps) = crate::utils::stream_parse::claude::parse_line(¶ms.to_string()) { for step in steps { + if self.drops_claude_plan_process(&step) { + continue; + } match step { ProcessStep::Text { text } => { if !text.is_empty() { @@ -1993,6 +2130,9 @@ impl ActorWorker { return Ok(()); }; for step in steps { + if self.drops_claude_plan_process(&step) { + continue; + } match step { ProcessStep::Text { text } => { if !text.is_empty() { @@ -3247,15 +3387,62 @@ impl ActorWorker { content: entry.content, status: entry.status, priority: entry.priority, + id: None, }) .collect(); } } + fn apply_claude_plan(&self, ops: Vec) { + if ops.is_empty() { + return; + } + if let Ok(mut guard) = self.catalogs.lock() { + let cache = guard.entry(self.conversation_id.clone()).or_default(); + for op in ops { + apply_claude_plan_op(cache, op); + } + } + } + + fn remember_claude_plan_tools(&self, params: &Value) { + let ids = crate::utils::stream_parse::claude::plan_tool_use_ids(params); + if ids.is_empty() { + return; + } + if let Ok(mut guard) = self.catalogs.lock() { + let cache = guard.entry(self.conversation_id.clone()).or_default(); + for id in ids { + if !cache.plan_tool_use_ids.iter().any(|existing| existing == &id) { + cache.plan_tool_use_ids.push(id); + } + } + } + } + + fn drops_claude_plan_process(&self, step: &ProcessStep) -> bool { + let ProcessStep::Tool { id, name, .. } = step else { + return false; + }; + if crate::utils::stream_parse::claude::is_claude_plan_tool_name(name) { + return true; + } + let Some(id) = id.as_deref() else { + return false; + }; + self.catalogs.lock().ok().is_some_and(|guard| { + guard + .get(&self.conversation_id) + .is_some_and(|cache| cache.plan_tool_use_ids.iter().any(|known| known == id)) + }) + } + fn clear_turn_plan(&self) { if let Ok(mut guard) = self.catalogs.lock() { if let Some(entry) = guard.get_mut(&self.conversation_id) { entry.plan.clear(); + entry.pending_plan_creates.clear(); + entry.plan_tool_use_ids.clear(); } } } diff --git a/crates/agenthub-core/src/services/chat_runtime/types.rs b/crates/agenthub-core/src/services/chat_runtime/types.rs index 2077b94a..ec4fc992 100644 --- a/crates/agenthub-core/src/services/chat_runtime/types.rs +++ b/crates/agenthub-core/src/services/chat_runtime/types.rs @@ -141,7 +141,7 @@ pub struct RuntimeSnapshot { /// Bumps when Options catalog changes (slash commands, handshake image). Not the command list. #[serde(default)] pub catalog_epoch: i64, - /// Current-turn ACP plan. Live chrome only — not a process row, dropped on the next turn. + /// Current-turn plan. Live chrome only — not a process row, dropped on the next turn. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub plan: Vec, /// Live ACP host commands. One card per terminal id; not a conversation TTY. @@ -193,6 +193,9 @@ pub struct RuntimePlanEntry { pub status: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub priority: Option, + /// Vendor task id when the Agent sent one. Matching only; not shown. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/agenthub-core/src/utils/stream_parse/claude.rs b/crates/agenthub-core/src/utils/stream_parse/claude.rs index 71728293..e52b4bd8 100644 --- a/crates/agenthub-core/src/utils/stream_parse/claude.rs +++ b/crates/agenthub-core/src/utils/stream_parse/claude.rs @@ -56,8 +56,16 @@ impl ClaudeStreamParser { "user" => Some(parse_user_tool_results(&v)), "result" => Some(self.parse_result(&v)), "content_block_delta" | "stream_event" => self.parse_deltaish(&v), - "tool_use" => Some(vec![tool_from_obj(&v, "start")]), - "tool_result" => Some(vec![tool_result_from_obj(&v)]), + "tool_use" => Some(if is_claude_plan_tool_name(tool_name(&v)) { + vec![] + } else { + vec![tool_from_obj(&v, "start")] + }), + "tool_result" => Some(if is_claude_plan_result(&v) { + vec![] + } else { + vec![tool_result_from_obj(&v)] + }), "error" => { let message = v .get("error") @@ -145,7 +153,11 @@ impl ClaudeStreamParser { }); } } - "tool_use" => steps.push(tool_from_obj(block, "start")), + "tool_use" => { + if !is_claude_plan_tool_name(tool_name(block)) { + steps.push(tool_from_obj(block, "start")); + } + } _ => {} } } @@ -225,6 +237,7 @@ pub fn parse_line(line: &str) -> Option> { fn parse_user_tool_results(v: &Value) -> Vec { let mut steps = Vec::new(); + let parent_plan_result = looks_like_plan_output(v); let content = v .pointer("/message/content") .or_else(|| v.get("content")) @@ -232,14 +245,22 @@ fn parse_user_tool_results(v: &Value) -> Vec { .unwrap_or(Value::Null); if let Some(arr) = content.as_array() { for block in arr { - if block.get("type").and_then(|t| t.as_str()) == Some("tool_result") { - steps.push(tool_result_from_obj(block)); + if block.get("type").and_then(|t| t.as_str()) != Some("tool_result") { + continue; + } + if is_claude_plan_result(block) || (parent_plan_result && tool_name(block).is_empty()) { + continue; } + steps.push(tool_result_from_obj(block)); } } steps } +fn tool_name(v: &Value) -> &str { + v.get("name").and_then(|name| name.as_str()).unwrap_or("") +} + fn tool_from_obj(v: &Value, status: &str) -> ProcessStep { let id = v .get("id") @@ -300,5 +321,301 @@ fn tool_result_from_obj(v: &Value) -> ProcessStep { } } +/// One Claude todo / Task tool row. Not a `ProcessStep`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ClaudePlanEntry { + pub content: String, + pub status: Option, + pub priority: Option, + pub id: Option, +} + +/// Mutations from TodoWrite / Task* tool_use and matching results. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ClaudePlanOp { + Replace(Vec), + Create { + tool_use_id: Option, + content: String, + status: Option, + id: Option, + priority: Option, + }, + Update { + id: String, + status: Option, + content: Option, + priority: Option, + }, + BindId { + tool_use_id: String, + id: String, + }, +} + +/// TodoWrite replaces the list; TaskCreate/TaskUpdate patch it. Not a process row. +pub(crate) fn extract_todo_plan(v: &Value) -> Vec { + if let Some(event) = v.get("event") { + let nested = extract_todo_plan(event); + if !nested.is_empty() { + return nested; + } + } + let mut ops = Vec::new(); + for_each_tool_use(v, |block| { + if let Some(op) = plan_op_from_tool_use(block) { + ops.push(op); + } + }); + ops.extend(plan_ops_from_results(v)); + ops +} + +pub(crate) fn is_claude_plan_tool_name(name: &str) -> bool { + plan_tool_kind(name).is_some() +} + +pub(crate) fn plan_tool_use_ids(v: &Value) -> Vec { + let mut ids = Vec::new(); + for_each_tool_use(v, |block| { + if !is_claude_plan_tool_name(tool_name(block)) { + return; + } + if let Some(id) = first_str(block, &["id", "tool_use_id"]) { + ids.push(id); + } + }); + ids +} + +fn is_claude_plan_result(v: &Value) -> bool { + is_claude_plan_tool_name(tool_name(v)) || looks_like_plan_output(v) +} + +fn plan_tool_kind(name: &str) -> Option<&'static str> { + let compact = name + .trim() + .to_ascii_lowercase() + .replace(['_', '-', ' '], ""); + match compact.as_str() { + "todowrite" => Some("write"), + "todoread" => Some("read"), + "taskcreate" => Some("create"), + "taskupdate" => Some("update"), + "taskget" => Some("get"), + "tasklist" => Some("list"), + _ => None, + } +} + +fn for_each_tool_use(v: &Value, mut visit: impl FnMut(&Value)) { + let ty = v.get("type").and_then(Value::as_str).unwrap_or(""); + if ty == "tool_use" { + visit(v); + return; + } + if let Some(block) = v.get("content_block") { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + visit(block); + } + } + let content = v.pointer("/message/content").or_else(|| v.get("content")); + if let Some(items) = content.and_then(Value::as_array) { + for block in items { + if block.get("type").and_then(Value::as_str) == Some("tool_use") { + visit(block); + } + } + } +} + +fn plan_op_from_tool_use(block: &Value) -> Option { + let kind = plan_tool_kind(tool_name(block))?; + let input = block.get("input").unwrap_or(&Value::Null); + match kind { + "write" => { + let entries = input + .get("todos") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(parse_plan_entry) + .collect::>(); + Some(ClaudePlanOp::Replace(entries)) + } + "create" => { + let content = first_str(input, &["subject", "content", "text", "title"])?; + Some(ClaudePlanOp::Create { + tool_use_id: first_str(block, &["id", "tool_use_id"]), + content, + status: first_str(input, &["status"]).or_else(|| Some("pending".into())), + id: first_str(input, &["taskId", "id", "task_id"]), + priority: first_str(input, &["priority"]), + }) + } + "update" => { + let id = first_str(input, &["taskId", "id", "task_id"])?; + Some(ClaudePlanOp::Update { + id, + status: first_str(input, &["status"]), + content: first_str(input, &["subject", "content", "text", "title"]), + priority: first_str(input, &["priority"]), + }) + } + _ => None, + } +} + +fn plan_ops_from_results(v: &Value) -> Vec { + let mut ops = Vec::new(); + let parent_result = v + .get("tool_use_result") + .or_else(|| v.pointer("/message/tool_use_result")); + let content = v.pointer("/message/content").or_else(|| v.get("content")); + if let Some(items) = content.and_then(Value::as_array) { + for block in items { + if block.get("type").and_then(Value::as_str) != Some("tool_result") { + continue; + } + collect_result_ops(block, parent_result, &mut ops); + } + } else if v.get("type").and_then(Value::as_str) == Some("tool_result") { + collect_result_ops(v, parent_result, &mut ops); + } + ops +} + +fn collect_result_ops(block: &Value, parent_result: Option<&Value>, ops: &mut Vec) { + if let Some(tool_use_id) = first_str(block, &["tool_use_id"]) { + if let Some(id) = task_id_from(parent_result).or_else(|| task_id_from(Some(block))) { + ops.push(ClaudePlanOp::BindId { tool_use_id, id }); + } + } + if let Some(entries) = tasks_from_payload(parent_result).or_else(|| tasks_from_value(block)) { + if !entries.is_empty() { + ops.push(ClaudePlanOp::Replace(entries)); + } + } +} + +fn task_id_from(payload: Option<&Value>) -> Option { + payload + .and_then(structured_task_output)? + .pointer("/task/id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn tasks_from_payload(payload: Option<&Value>) -> Option> { + let parsed = payload.and_then(structured_task_output)?; + let items = parsed.get("tasks").and_then(Value::as_array)?; + let entries = items + .iter() + .filter_map(parse_plan_entry) + .collect::>(); + (!entries.is_empty()).then_some(entries) +} + +fn looks_like_plan_output(v: &Value) -> bool { + payload_has_plan_shape(v) + || v.get("tool_use_result") + .is_some_and(payload_has_plan_shape) + || v.pointer("/message/tool_use_result") + .is_some_and(payload_has_plan_shape) + || v.get("content") + .and_then(parse_jsonish) + .is_some_and(|value| payload_has_plan_shape(&value)) + || v.get("output") + .and_then(parse_jsonish) + .is_some_and(|value| payload_has_plan_shape(&value)) +} + +fn payload_has_plan_shape(v: &Value) -> bool { + v.get("task").is_some() + || v.get("tasks").is_some() + || v.get("oldTodos").is_some() + || v.get("newTodos").is_some() + || v.get("old_todos").is_some() + || v.get("new_todos").is_some() + || v.get("updatedFields").is_some() + || v.get("updated_fields").is_some() + || ((v.get("taskId").is_some() || v.get("task_id").is_some()) + && (v.get("success").is_some() || v.get("status").is_some())) +} + +fn structured_task_output(v: &Value) -> Option { + if payload_has_plan_shape(v) { + return Some(v.clone()); + } + if let Some(direct) = v.get("tool_use_result") { + if payload_has_plan_shape(direct) { + return Some(direct.clone()); + } + } + let content = v.get("content").or_else(|| v.get("output"))?; + parse_jsonish(content).filter(payload_has_plan_shape) +} + +fn tasks_from_value(v: &Value) -> Option> { + let parsed = structured_task_output(v)?; + let items = parsed.get("tasks").and_then(Value::as_array)?; + let entries = items + .iter() + .filter_map(parse_plan_entry) + .collect::>(); + (!entries.is_empty()).then_some(entries) +} + +fn parse_plan_entry(value: &Value) -> Option { + let content = first_str(value, &["content", "subject", "text", "title"])?; + Some(ClaudePlanEntry { + content, + status: first_str(value, &["status"]), + priority: first_str(value, &["priority"]), + id: first_str(value, &["id", "taskId", "task_id"]), + }) +} + +fn parse_jsonish(content: &Value) -> Option { + match content { + Value::Object(_) => Some(content.clone()), + Value::String(text) => serde_json::from_str(text).ok(), + Value::Array(items) => { + let text = items + .iter() + .filter_map(|item| { + if item.get("type").and_then(Value::as_str) == Some("text") { + item.get("text").and_then(Value::as_str) + } else { + None + } + }) + .collect::(); + if text.trim().is_empty() { + None + } else { + serde_json::from_str(&text).ok() + } + } + _ => None, + } +} + +fn first_str(value: &Value, keys: &[&str]) -> Option { + for key in keys { + if let Some(text) = value + .get(*key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + { + return Some(text.to_string()); + } + } + None +} + #[cfg(test)] mod tests; diff --git a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs index 09a19376..57690025 100644 --- a/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs +++ b/crates/agenthub-core/src/utils/stream_parse/claude/tests.rs @@ -76,3 +76,187 @@ fn deltas_then_result_does_not_double_assistant_text() { let _ = s.feed(OutputStream::Stdout, ndjson); assert_eq!(s.assistant_text(), "PONGC"); } + +#[test] +fn todo_write_is_plan_not_process_step() { + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "role": "assistant", + "content": [{ + "type": "tool_use", + "id": "toolu_1", + "name": "TodoWrite", + "input": { + "todos": [ + { "content": "read", "status": "completed", "priority": "high" }, + { "content": "edit", "status": "in_progress" }, + { "content": " " }, + { "content": "test", "status": "pending" } + ] + } + }] + } + }); + let steps = parse_line(&payload.to_string()).unwrap(); + assert!(steps.is_empty(), "{steps:?}"); + let ops = super::extract_todo_plan(&payload); + assert_eq!(ops.len(), 1); + match &ops[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].content, "read"); + assert_eq!(entries[0].status.as_deref(), Some("completed")); + assert_eq!(entries[1].status.as_deref(), Some("in_progress")); + assert_eq!(entries[2].content, "test"); + } + other => panic!("expected replace, got {other:?}"), + } +} + +#[test] +fn task_create_and_update_are_plan_patches() { + let create = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "id": "toolu_create", + "name": "TaskCreate", + "input": { "subject": "build auth", "activeForm": "Building auth" } + }] + } + }); + let ops = super::extract_todo_plan(&create); + assert_eq!( + ops, + vec![super::ClaudePlanOp::Create { + tool_use_id: Some("toolu_create".into()), + content: "build auth".into(), + status: Some("pending".into()), + id: None, + priority: None, + }] + ); + let update = serde_json::json!({ + "type": "tool_use", + "id": "toolu_upd", + "name": "TaskUpdate", + "input": { "taskId": "task-1", "status": "in_progress" } + }); + assert_eq!( + super::extract_todo_plan(&update), + vec![super::ClaudePlanOp::Update { + id: "task-1".into(), + status: Some("in_progress".into()), + content: None, + priority: None, + }] + ); +} + +#[test] +fn task_create_result_binds_id() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { "task": { "id": "task-9", "subject": "build auth" } }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_create", + "content": "created" + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); + assert_eq!( + super::extract_todo_plan(&payload), + vec![super::ClaudePlanOp::BindId { + tool_use_id: "toolu_create".into(), + id: "task-9".into(), + }] + ); +} + +#[test] +fn todo_write_result_is_not_process_step() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { + "oldTodos": [{ "content": "read", "status": "pending" }], + "newTodos": [{ "content": "read", "status": "completed" }] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_todo", + "content": "Todos have been modified successfully." + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); +} + +#[test] +fn task_list_result_replaces_plan() { + let payload = serde_json::json!({ + "type": "user", + "tool_use_result": { + "tasks": [ + { "id": "t1", "subject": "read", "status": "completed" }, + { "id": "t2", "subject": "edit", "status": "in_progress" } + ] + }, + "message": { + "content": [{ + "type": "tool_result", + "tool_use_id": "toolu_list", + "content": "listed" + }] + } + }); + assert!(parse_line(&payload.to_string()).unwrap().is_empty()); + match &super::extract_todo_plan(&payload)[0] { + super::ClaudePlanOp::Replace(entries) => { + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].id.as_deref(), Some("t1")); + assert_eq!(entries[1].content, "edit"); + } + other => panic!("expected replace, got {other:?}"), + } +} + +#[test] +fn empty_todo_write_is_replace_with_no_rows() { + let payload = serde_json::json!({ + "type": "assistant", + "message": { + "content": [{ + "type": "tool_use", + "name": "TodoWrite", + "input": { "todos": [] } + }] + } + }); + assert_eq!( + super::extract_todo_plan(&payload), + vec![super::ClaudePlanOp::Replace(vec![])] + ); +} + +#[test] +fn bash_tool_use_still_emits_process_step() { + let steps = parse_line( + r#"{"type":"assistant","message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}"#, + ) + .unwrap(); + assert!(matches!( + &steps[0], + ProcessStep::Tool { name, .. } if name == "Bash" + )); + assert!(super::extract_todo_plan(&serde_json::json!({ + "type": "assistant", + "message": { "content": [{ "type": "tool_use", "name": "Bash", "input": { "command": "ls" } }] } + })) + .is_empty()); +} diff --git a/docs/STATUS.md b/docs/STATUS.md index 1c7f63a6..cc186aad 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,7 +3,7 @@ title: AgentHub 当前实现状态 type: status status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # 当前实现状态 @@ -24,7 +24,7 @@ updated: 2026-09-17 - **新空 Grok 会话**:持续聊天(模型/思考、图片、后续轮排队),**不支持**为本轮指定「用于本次」技能,界面也不画可点的假按钮。真实窗口验收已通过。Chat 只带官方 `grok agent --no-leader stdio` 旗标(`--permission-mode` 写在 `agent` 前或后都会让进程在出卡前退出)。`session/new` 带 `_meta.yoloMode=false`,覆盖本机 always-approve;握手按官方 ACP 声明本机可读写文件、不发 `initialized`。工作目录外写出走本机 `fs/write_text_file`:先出「修改文件」卡片再写文件,可点一直允许;目录内直接写。对方若另发 `session/request_permission`,卡片仍只带对方给的「一直允许」。会话自动批准才加 `--always-approve` 和 `_meta.yoloMode=true`。进程退出时界面写「Grok 已退出」,不写 Codex 的 app-server 字样。 - **新空 Kiro 会话**:`kiro-cli acp` 持续通道(允许/拒绝、停止;生成时不能中途补充,可排队到下一轮)。真实窗口验收已通过(ACP 新对话;打印路径 HTTP 多轮为 Builder ID / 本机登录,不是企业 IdC)。旧对话保留原发送方式。 - **其余 Agent 与旧会话**:仍走原发送方式。 - - **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。 + - **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,或 Claude 新对话用 TodoWrite / Task 工具更新了任务清单,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。 - **过程内用量**:新空 Codex 会话仍解析 `thread/tokenUsage/updated` 的当前轮 `last`(累计 `total` / 窗口只留在总览等用量页)。新空 Grok 会话解析 **当前轮**(`turn_completed.usage`);ACP 没有会话累计字段,不把各轮相加冒充累计。Grok / Kiro 的 `context_usage` 有数字才进用量小字(窗口用量),全 0 不画。解析路径已接;真窗 2026-09-09 见过部分轮次 **没有** `turn_completed.usage`,此时界面不画假数字。对话里只在本轮结束后用小字写输入 / 输出(有缓存才写缓存);生成中不画用量。只显示协议里的数字,不估算费用。Kiro 没有 token 累计数据源。 - **`/` 菜单**:立刻执行的动作(新建对话、复制最近回复;换模型/思考/技能要搜到才列出,避免把整份目录摊在 `/` 上)。Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项:选中后当作一轮正常发出(无必填参数则直接发送 `/名字`;必填参数则插入 `/名字 ` 供补全后再发)。Grok 走标准 ACP `available_commands_update`;Kiro 走 `_kiro.dev/commands/available` 的 `commands[]`(不把技能/工具目录摊进 `/`)。目录变了会重拉;未就绪或未声明则不画,不猜菜单。Kiro 就绪会话「对方命令」Linux 真窗已 PASS(修复 tip `daed5ccf`,现行 tip 仍含该修复):列出对方声明的斜杠项,裸 `/` 不摊技能目录;记录见 `/workspace/qa-issues/CHAT-SLASH-PR365-RETEST-daed5ccf.md`。本条只记这次验过的展示,不把选中发送或其它 Agent 写成已验收。有可启动的命令行时,`/` 可列出「启动命令行」(DeepSeek 为「打开网页会话」),在外部打开,不标成对话页能力。 - **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 顶栏和会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。 diff --git a/docs/concepts/chat-and-agents.md b/docs/concepts/chat-and-agents.md index 8db7da40..2fbe290b 100644 --- a/docs/concepts/chat-and-agents.md +++ b/docs/concepts/chat-and-agents.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat, adapter, and frontend contributors source-of-truth: ChatService/RunService, ChatEvent, stream parsers, Tauri Channel adapter, and chat process reducer -updated: 2026-09-16 +updated: 2026-09-20 --- # Chat 与 Agent 运行 @@ -17,7 +17,7 @@ Chat 是 AgentHub 里的运行工作台。当前一个会话对应一个 Agent - **新空 Codex 会话**:app-server 持续聊天;会话级模型/思考强度、最小操作菜单、本地图片附件、「用于本次」技能已落地(见 [B2](../archive/chat-codex-b2.md))。计划模式未做;文本问答等上游默认稳定后再跟,不打开 under-development 开关、也不造假卡片;Linux AgentHub Chat 不可用 Codex Computer Use。Claude B3 首片见下。 - **新空 Grok 会话**:持续聊天,可选模型和思考等级,支持图片与后续轮排队。不能为本轮指定「用于本次」技能(与 Codex 不同;界面也不画出可点的假按钮)。生成时不能中途补充,只能排队到下一轮。 - **新空 Kiro 会话**:`kiro-cli acp` 持续通道(允许/拒绝、停止;生成时不能中途补充,可排队到下一轮)。旧对话保留原发送方式。本机登录或 `KIRO_API_KEY` 可用时,打印路径可走 HTTP 多轮(`kiro-http:` 前缀);已有 HTTP 会话失败时直接报错并保留会话,不回退成新的命令行会话。企业 IdC / `profileArn` 仍是提案剩余边界(带上参数 ≠ 已验收)。跨页事实见 [STATUS](../STATUS.md)。 -- **新空 Claude 会话**:`claude -p --input-format stream-json` 持续通道(多轮、本地图片;不能在生成中补充;本片无允许/拒绝卡片,默认 `dontAsk` / 危险模式 `bypassPermissions`)。有历史的 Claude 会话仍走 print+resume。见 [Claude B3](../archive/chat-claude-b3.md)。 +- **新空 Claude 会话**:`claude -p --input-format stream-json` 持续通道(多轮、本地图片;不能在生成中补充;本片无允许/拒绝卡片,默认 `dontAsk` / 危险模式 `bypassPermissions`)。对方用 TodoWrite 或 Task 工具更新任务清单时,输入区上方出现与 Grok / Kiro 相同的计划条(可折叠、带进展),不进过程时间线;换轮丢掉。有历史的 Claude 会话仍走 print+resume。见 [Claude B3](../archive/chat-claude-b3.md)。 - **其余 Agent 与旧会话**:保留原有发送方式。 ## 允许 / 拒绝 / 一直允许 diff --git a/docs/concepts/chat-process-events.md b/docs/concepts/chat-process-events.md index 389cf6ec..036b7f4b 100644 --- a/docs/concepts/chat-process-events.md +++ b/docs/concepts/chat-process-events.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat and core contributors source-of-truth: ProcessStep, ChatEvent, RuntimeSnapshot -updated: 2026-09-17 +updated: 2026-09-20 --- # Chat 过程事件 @@ -37,7 +37,7 @@ updated: 2026-09-17 - ACP `config_option_update`(模型 / 思考目录) - 世代号 `catalogEpoch`(只用来重拉 Options) -`plan` 走当前轮计划条,换轮丢掉。宿主 `terminal/*` 走命令卡片,不是对话 TTY。 +`plan` 走当前轮计划条,换轮丢掉。Grok / Kiro 的 ACP `sessionUpdate: plan`,以及 Claude 新对话的 TodoWrite / TaskCreate / TaskUpdate / TaskList,都进这条,不进过程时间线。宿主 `terminal/*` 走命令卡片,不是对话 TTY。 ## 本波边界 diff --git a/docs/reference/chat-support-depth.md b/docs/reference/chat-support-depth.md index 12b9755d..6d07feeb 100644 --- a/docs/reference/chat-support-depth.md +++ b/docs/reference/chat-support-depth.md @@ -4,7 +4,7 @@ description: 每家 Agent 的探测/启动与 Chat 宿主深度对照;与 Capa type: reference status: current owner: maintainers -updated: 2026-09-15 +updated: 2026-09-20 --- # Chat 支持深度矩阵 @@ -35,7 +35,7 @@ updated: 2026-09-15 | Agent | D0 | Chat 主档 | `RuntimeChannel`(新空) | D3 摘要(新空持续会话) | 备注 | |---|---|---|---|---|---| -| Claude | 有 | **D2** 新空;有历史仍 **D1** print+resume | `stream-json` | 模型/思考、图片;**无**允许/拒绝卡(`dontAsk` / 危险 `bypassPermissions`);无「用于本次」 | 见 STATUS / Claude B3 | +| Claude | 有 | **D2** 新空;有历史仍 **D1** print+resume | `stream-json` | 模型/思考、图片;TodoWrite / Task 任务清单进计划条;**无**允许/拒绝卡(`dontAsk` / 危险 `bypassPermissions`);无「用于本次」 | 见 STATUS / Claude B3 | | Codex | 有 | **D2** 新空 | `app-server` | 允许/拒绝/一直允许、模型/思考、图片、「用于本次」、斜杠动态菜单;计划模式未做;CU 无产品路径 | 最深 | | Grok | 有 | **D2** 新空 | `acp` | 允许/拒绝(对方选项)、模型/思考、图片;**无**「用于本次」;生成中不可补充(可排队) | ACP 族 | | Kiro | 有 | **D2** 新空 ACP;旧会话 **D1** | `acp` | 允许/拒绝、停止;模型/思考/权限启动后固定;生成中不可补充 | ACP 族;旧 headless 不切 ACP | diff --git a/docs/ui/page-patterns.md b/docs/ui/page-patterns.md index 7d1e77ce..84606054 100644 --- a/docs/ui/page-patterns.md +++ b/docs/ui/page-patterns.md @@ -353,7 +353,7 @@ Chat is a one-conversation, one-Agent workbench with a session rail, transcript, - Approval cards: Allow / Deny; Always allow when the request includes that option. Codex / Grok / Kiro in-process remember (not saved). File-change cards show the path. - Plan bar above the composer: counts and 待做 / 进行中 / 已完成 / 失败; collapsible. Snapshot poll failure: **没法更新这场对话** after one second, Retry, no toast spam. - Streaming process: one-line human 正在读取 / 正在修改 / 正在执行 on the reply; click to open thinking/tools in the right-hand pane; protocol details folded. After the turn ends, muted turn-only 输入 / 输出 under the reply when the protocol sent counts. Copy for completed messages only. -- New Codex conversations use durable app-server snapshots and show actual approval/question requests as controls. Replies and stop target the exact run; snapshot failure does not fall back to legacy send. Codex B2 is in: session model/effort, `/` command search, local image attachments, and skills/plugins discovery for this turn (no plan mode; no toolbar skill button — use `/` or Codex auto-use). The composer does not show a duplicate Agent overflow (⋮) next to the selected Agent. `/` lists run-now actions (new chat, copy latest reply). Model/effort/skill appear when the query matches, not as a full dump on a bare `/`. When a Grok/Kiro session is ready and the peer declared commands, `/` also lists those names: choosing one sends `/name` as a normal turn, or inserts `/name ` when the declaration requires args. History, search, settings, Agents, and Connections stay on the rail or header. New Grok conversations are continuous (model/thinking, images, queued follow-ups); **Unsupported**: choosing a skill “for this turn” (no clickable fake control). New Kiro conversations use the ACP continuous channel; old Kiro chats keep the original send path. New Claude conversations use stream-json continuous chat (images; no mid-turn steer; no approval cards in this slice); old Claude chats keep print+resume. See [B2](../archive/chat-codex-b2.md) and [STATUS](../STATUS.md). +- New Codex conversations use durable app-server snapshots and show actual approval/question requests as controls. Replies and stop target the exact run; snapshot failure does not fall back to legacy send. Codex B2 is in: session model/effort, `/` command search, local image attachments, and skills/plugins discovery for this turn (no plan mode; no toolbar skill button — use `/` or Codex auto-use). The composer does not show a duplicate Agent overflow (⋮) next to the selected Agent. `/` lists run-now actions (new chat, copy latest reply). Model/effort/skill appear when the query matches, not as a full dump on a bare `/`. When a Grok/Kiro session is ready and the peer declared commands, `/` also lists those names: choosing one sends `/name` as a normal turn, or inserts `/name ` when the declaration requires args. History, search, settings, Agents, and Connections stay on the rail or header. New Grok conversations are continuous (model/thinking, images, queued follow-ups); **Unsupported**: choosing a skill “for this turn” (no clickable fake control). New Kiro conversations use the ACP continuous channel; old Kiro chats keep the original send path. New Claude conversations use stream-json continuous chat (images; no mid-turn steer; no approval cards in this slice); TodoWrite / Task tool lists fill the same plan bar as Grok / Kiro. Old Claude chats keep print+resume. See [B2](../archive/chat-codex-b2.md) and [STATUS](../STATUS.md). - Runtime confirmation cards use **允许** / **拒绝**. **一直允许** appears only when this request includes that option (Codex command/file prompts always include it; Grok / Kiro only if the ACP request does, including Kiro `allow_always_tool`). Pending option lists are stored with the request so a snapshot or restart can still show the same buttons. After **一直允许**, Codex / Grok / Kiro auto-accept later command/file prompts in the current process (Codex typically this turn; Grok / Kiro the live ACP process; not saved). Grok / Kiro also forward the server option. Kiro session **完全访问权限** is a separate start-time setting, not this card. Cursor is out of this surface. See [Chat 与 Agent](../concepts/chat-and-agents.md#允许-拒绝-一直允许). ### Agent touchpoints (Chat) diff --git a/src/lib/backend/contracts/chat-runtime.ts b/src/lib/backend/contracts/chat-runtime.ts index bbe34a6a..ef437a4b 100644 --- a/src/lib/backend/contracts/chat-runtime.ts +++ b/src/lib/backend/contracts/chat-runtime.ts @@ -53,7 +53,7 @@ export interface RuntimeSnapshot { currentMessage?: ChatMessage | null; /** Bumps when the Options catalog changes. Not the command list. */ catalogEpoch?: number; - /** Current-turn ACP plan. Live chrome only — dropped on the next turn. */ + /** Current-turn plan. Chrome only — dropped on the next turn. */ plan?: RuntimePlanEntry[]; /** Live ACP host commands. One card per id; not a conversation TTY. */ hostTerminals?: RuntimeHostTerminal[]; @@ -77,6 +77,8 @@ export interface RuntimePlanEntry { content: string; status?: string | null; priority?: string | null; + /** Vendor task id when the Agent sent one. Matching only; not shown. */ + id?: string | null; } export interface RuntimeReply { From 4c2b8aba8308f3209c0852466574faec912a5ebb Mon Sep 17 00:00:00 2001 From: NiceChen Date: Sun, 20 Sep 2026 21:38:32 +0800 Subject: [PATCH 03/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E5=BF=AB=E6=8D=B7=E5=88=87=E6=8D=A2=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=20(#369)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/i18n/locales/en.ts | 3 + src/lib/i18n/locales/zh.ts | 3 + src/pages/chat/ChatSessionHeader.tsx | 131 ++++++++++++++++++- src/pages/chat/chat-session-switch.test.ts | 145 +++++++++++++++++++++ src/pages/chat/chat-session-switch.ts | 58 +++++++++ src/pages/chat/chat-shortcuts.test.ts | 8 ++ src/pages/chat/chat-shortcuts.ts | 2 + src/pages/chat/index.tsx | 17 ++- src/pages/chat/use-chat-session-switch.ts | 38 ++++++ 9 files changed, 402 insertions(+), 3 deletions(-) create mode 100644 src/pages/chat/chat-session-switch.test.ts create mode 100644 src/pages/chat/chat-session-switch.ts create mode 100644 src/pages/chat/use-chat-session-switch.ts diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 457207a5..aef863da 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2753,6 +2753,7 @@ export const en = { header: { conversation: "Chat", titleAria: "Conversation title", + switchSession: "Switch session", pickCwd: "Click to choose a working directory", cwdUnset: "Working directory not set", cwdMissing: "Folder is gone", @@ -2823,6 +2824,8 @@ export const en = { stop: "Stop", actions: "More actions", history: "Search history", + prevSession: "Previous session", + nextSession: "Next session", model: "Change model", newChat: "New chat", overview: "Shortcut overview", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 20348e9d..c0819a82 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2733,6 +2733,7 @@ export const zh = { header: { conversation: "对话", titleAria: "会话标题", + switchSession: "切换会话", pickCwd: "点击选择工作目录", cwdUnset: "未设置工作目录", cwdMissing: "目录已不存在", @@ -2803,6 +2804,8 @@ export const zh = { stop: "停止", actions: "更多操作", history: "搜索历史会话", + prevSession: "上一条会话", + nextSession: "下一条会话", model: "换模型", newChat: "新建对话", overview: "快捷键一览", diff --git a/src/pages/chat/ChatSessionHeader.tsx b/src/pages/chat/ChatSessionHeader.tsx index c4576f17..4b903059 100644 --- a/src/pages/chat/ChatSessionHeader.tsx +++ b/src/pages/chat/ChatSessionHeader.tsx @@ -1,15 +1,32 @@ import { useEffect, useRef, useState } from 'react'; -import { Copy, FolderOpen, PanelLeftOpen, Settings2, ShieldAlert, Terminal } from 'lucide-react'; +import { + ChevronDown, + ChevronUp, + ChevronsUpDown, + Copy, + FolderOpen, + PanelLeftOpen, + Settings2, + ShieldAlert, + Terminal, +} from 'lucide-react'; import { ChromeActions } from '@/components/layout/ChromeActions'; import { pageRhythm } from '@/components/layout/page-rhythm'; import { copyTextToClipboard } from '@/components/shared/CopyTextButton'; import { useI18n } from '@/components/shared/LanguageProvider'; import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Hint } from '@/components/ui/tooltip'; import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/toast'; import type { Conversation } from '@/lib/types'; import { cn } from '@/lib/utils'; +import { sessionSwitchNeighbors } from './chat-session-switch'; import { isKiroChatAgent } from './chat-kiro-model'; import { chatConnectLabelKey, @@ -32,8 +49,11 @@ export function ChatSessionHeader({ active, railOpen, recordText, + sessions, + sendingConversationIds = [], onExpandRail, onRename, + onFocus, onOpenSettings, onPickWorkingDirectory, runtimeLocked = false, @@ -44,8 +64,11 @@ export function ChatSessionHeader({ active: Conversation | null; railOpen: boolean; recordText?: string; + sessions: readonly Conversation[]; + sendingConversationIds?: readonly string[]; onExpandRail: () => void; onRename: (next: string) => Promise; + onFocus: (id: string) => void; onOpenSettings: () => void; onPickWorkingDirectory: () => void; runtimeLocked?: boolean; @@ -114,7 +137,14 @@ export function ChatSessionHeader({ )}
- {active && editing ? ( + {!railOpen && sessions.length > 0 ? ( + + ) : active && editing ? ( + ); +} + +function ChatSessionSwitcher({ + sessions, + active, + sendingConversationIds, + onFocus, +}: { + sessions: readonly Conversation[]; + active: Conversation | null; + sendingConversationIds: readonly string[]; + onFocus: (id: string) => void; +}) { + const { t } = useI18n(); + const neighbors = sessionSwitchNeighbors(sessions, active?.id ?? null); + const title = active ? conversationTitle(t, active.title) : t('chat.header.conversation'); + const cwdLabel = active ? cwdShortName(active.cwd, t) : t('chat.cwd.unset'); + const sendingHere = Boolean(active && sendingConversationIds.includes(active.id)); + + return ( +
+ + + + + + + {sessions.map((session) => { + const selected = active?.id === session.id; + const sending = sendingConversationIds.includes(session.id); + return ( + onFocus(session.id)} + > + + + {conversationTitle(t, session.title)} + + {cwdShortName(session.cwd, t)} + + + + ); + })} + + + +
+ ); +} diff --git a/src/pages/chat/chat-session-switch.test.ts b/src/pages/chat/chat-session-switch.test.ts new file mode 100644 index 00000000..dc295d55 --- /dev/null +++ b/src/pages/chat/chat-session-switch.test.ts @@ -0,0 +1,145 @@ +import { createElement, type ReactElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { Conversation } from '@/lib/types'; +import { ChatSessionHeader } from './ChatSessionHeader'; +import { + adjacentSessionId, + chatSessionSwitchShortcutAction, + sessionSwitchNeighbors, +} from './chat-session-switch'; + +vi.mock('@/components/shared/LanguageProvider', async () => { + const { createTranslator } = await import('@/lib/i18n'); + const t = createTranslator('zh'); + return { + useI18n: () => ({ lang: 'zh', setLanguage: () => undefined, t }), + }; +}); + +vi.mock('@/components/ui/toast', () => ({ + useToast: () => ({ toast: () => undefined }), +})); + +vi.mock('@/components/layout/ChromeActions', () => ({ + ChromeActions: () => null, +})); + +const sessions = [{ id: 'a' }, { id: 'b' }, { id: 'c' }]; + +describe('adjacentSessionId', () => { + it('returns null for an empty list', () => { + expect(adjacentSessionId([], 'a', 'next')).toBeNull(); + expect(adjacentSessionId([], null, 'prev')).toBeNull(); + }); + + it('cannot switch a single-item list that is already focused', () => { + expect(adjacentSessionId([{ id: 'a' }], 'a', 'next')).toBeNull(); + expect(adjacentSessionId([{ id: 'a' }], 'a', 'prev')).toBeNull(); + }); + + it('focuses the only session when the current id is missing', () => { + expect(adjacentSessionId([{ id: 'a' }], null, 'next')).toBe('a'); + expect(adjacentSessionId([{ id: 'a' }], 'gone', 'prev')).toBe('a'); + }); + + it('walks the flat list and wraps at both ends', () => { + expect(adjacentSessionId(sessions, 'a', 'next')).toBe('b'); + expect(adjacentSessionId(sessions, 'b', 'next')).toBe('c'); + expect(adjacentSessionId(sessions, 'c', 'next')).toBe('a'); + expect(adjacentSessionId(sessions, 'a', 'prev')).toBe('c'); + expect(adjacentSessionId(sessions, 'b', 'prev')).toBe('a'); + expect(adjacentSessionId(sessions, 'c', 'prev')).toBe('b'); + }); + + it('lands on the first or last session when the current id is not in the list', () => { + expect(adjacentSessionId(sessions, null, 'next')).toBe('a'); + expect(adjacentSessionId(sessions, 'gone', 'prev')).toBe('c'); + }); +}); + +describe('sessionSwitchNeighbors', () => { + it('exposes both wrapped neighbors', () => { + expect(sessionSwitchNeighbors(sessions, 'b')).toEqual({ prevId: 'a', nextId: 'c' }); + expect(sessionSwitchNeighbors(sessions, 'a')).toEqual({ prevId: 'c', nextId: 'b' }); + expect(sessionSwitchNeighbors([{ id: 'a' }], 'a')).toEqual({ prevId: null, nextId: null }); + }); +}); + +describe('chatSessionSwitchShortcutAction', () => { + const base = { + key: '', + altKey: true, + metaKey: false, + ctrlKey: false, + shiftKey: false, + overlayOpen: false, + }; + + it('maps Alt+ArrowUp / Alt+ArrowDown', () => { + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp' })).toBe('prev'); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowDown' })).toBe('next'); + expect( + chatSessionSwitchShortcutAction({ ...base, key: 'Unidentified', code: 'ArrowUp' }), + ).toBe('prev'); + }); + + it('ignores chords that already belong to Chat', () => { + expect(chatSessionSwitchShortcutAction({ ...base, key: 'k', ctrlKey: true, altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'n', ctrlKey: true, altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'Enter', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'Escape', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: '/', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', altKey: false })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', ctrlKey: true })).toBeNull(); + expect(chatSessionSwitchShortcutAction({ ...base, key: 'ArrowUp', overlayOpen: true })).toBeNull(); + }); +}); + +function conv(partial?: Partial): Conversation { + return { + id: 'a', + title: '修登录', + agentIds: ['claude'], + cwd: 'D:\\work\\agenthub', + allowDangerous: false, + createdAt: '2026-09-20T00:00:00.000Z', + updatedAt: '2026-09-20T00:00:00.000Z', + ...partial, + }; +} + +function header(partial?: Partial[0]>): ReactElement { + const active = conv(); + return createElement(ChatSessionHeader, { + active, + railOpen: false, + sessions: [active, conv({ id: 'b', title: '下一场', cwd: 'D:\\work\\other' })], + sendingConversationIds: ['a'], + onExpandRail: () => undefined, + onRename: async () => true, + onFocus: () => undefined, + onOpenSettings: () => undefined, + onPickWorkingDirectory: () => undefined, + ...partial, + }); +} + +describe('collapsed session switcher', () => { + it('shows the current title, working-directory short name, and a sending dot', () => { + const html = renderToStaticMarkup(createElement(TooltipProvider, null, header())); + expect(html).toContain('data-help="chat-session-switch"'); + expect(html).toContain('修登录'); + expect(html).toContain('agenthub'); + expect(html).toContain('data-sending=""'); + expect(html).toContain('上一条会话'); + expect(html).toContain('下一条会话'); + }); + + it('keeps the rename title when the history rail is open', () => { + const html = renderToStaticMarkup(createElement(TooltipProvider, null, header({ railOpen: true }))); + expect(html).not.toContain('data-help="chat-session-switch"'); + expect(html).toContain('修登录'); + }); +}); diff --git a/src/pages/chat/chat-session-switch.ts b/src/pages/chat/chat-session-switch.ts new file mode 100644 index 00000000..4afee8f3 --- /dev/null +++ b/src/pages/chat/chat-session-switch.ts @@ -0,0 +1,58 @@ +export type SessionSwitchDirection = 'prev' | 'next'; + +export type SessionSwitchNeighbors = { + prevId: string | null; + nextId: string | null; +}; + +/** + * Previous / next session in the current filtered list. + * Wraps at both ends. A single-item list cannot switch. + */ +export function adjacentSessionId( + sessions: readonly { id: string }[], + currentId: string | null, + direction: SessionSwitchDirection, +): string | null { + if (sessions.length === 0) return null; + const index = currentId == null ? -1 : sessions.findIndex((item) => item.id === currentId); + let nextIndex: number; + if (index < 0) { + nextIndex = direction === 'next' ? 0 : sessions.length - 1; + } else if (sessions.length === 1) { + return null; + } else { + const delta = direction === 'next' ? 1 : -1; + nextIndex = (index + delta + sessions.length) % sessions.length; + } + const target = sessions[nextIndex]?.id ?? null; + return target && target !== currentId ? target : null; +} + +export function sessionSwitchNeighbors( + sessions: readonly { id: string }[], + currentId: string | null, +): SessionSwitchNeighbors { + return { + prevId: adjacentSessionId(sessions, currentId, 'prev'), + nextId: adjacentSessionId(sessions, currentId, 'next'), + }; +} + +/** Alt+↑ / Alt+↓. Leaves Ctrl+K, Ctrl+N, Enter, Esc, and / alone. */ +export function chatSessionSwitchShortcutAction(input: { + key: string; + code?: string; + altKey: boolean; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + overlayOpen: boolean; +}): SessionSwitchDirection | null { + if (input.overlayOpen || !input.altKey || input.metaKey || input.ctrlKey || input.shiftKey) { + return null; + } + if (input.key === 'ArrowUp' || input.code === 'ArrowUp') return 'prev'; + if (input.key === 'ArrowDown' || input.code === 'ArrowDown') return 'next'; + return null; +} diff --git a/src/pages/chat/chat-shortcuts.test.ts b/src/pages/chat/chat-shortcuts.test.ts index 55f20e34..d9cd3923 100644 --- a/src/pages/chat/chat-shortcuts.test.ts +++ b/src/pages/chat/chat-shortcuts.test.ts @@ -14,15 +14,23 @@ describe('chat shortcut overview', () => { 'stop', 'actions', 'history', + 'prevSession', + 'nextSession', 'model', 'newChat', 'overview', ]); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'newChat')?.keys).toBe('Ctrl+N'); + expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'prevSession')?.keys).toBe('Alt+↑'); + expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'nextSession')?.keys).toBe('Alt+↓'); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'overview')?.keys).toBe('?'); expect(CHAT_SHORTCUT_ROWS.find((row) => row.id === 'stop')?.keys).toBe('Esc'); expect(translate('zh', 'chat.shortcuts.stop')).toBe('停止'); expect(translate('en', 'chat.shortcuts.stop')).toBe('Stop'); + expect(translate('zh', 'chat.shortcuts.prevSession')).toBe('上一条会话'); + expect(translate('en', 'chat.shortcuts.prevSession')).toBe('Previous session'); + expect(translate('zh', 'chat.shortcuts.nextSession')).toBe('下一条会话'); + expect(translate('en', 'chat.shortcuts.nextSession')).toBe('Next session'); }); it('shows Cmd on macOS and Ctrl elsewhere', () => { diff --git a/src/pages/chat/chat-shortcuts.ts b/src/pages/chat/chat-shortcuts.ts index d87c5577..b348a802 100644 --- a/src/pages/chat/chat-shortcuts.ts +++ b/src/pages/chat/chat-shortcuts.ts @@ -14,6 +14,8 @@ export const CHAT_SHORTCUT_ROWS: readonly ChatShortcutRow[] = [ { id: 'stop', keys: 'Esc', actionKey: 'chat.shortcuts.stop' }, { id: 'actions', keys: '/', actionKey: 'chat.shortcuts.actions' }, { id: 'history', keys: 'Ctrl+K', actionKey: 'chat.shortcuts.history' }, + { id: 'prevSession', keys: 'Alt+↑', actionKey: 'chat.shortcuts.prevSession' }, + { id: 'nextSession', keys: 'Alt+↓', actionKey: 'chat.shortcuts.nextSession' }, { id: 'model', keys: 'Ctrl+Shift+I', actionKey: 'chat.shortcuts.model' }, { id: 'newChat', keys: 'Ctrl+N', actionKey: 'chat.shortcuts.newChat' }, { id: 'overview', keys: '?', actionKey: 'chat.shortcuts.overview' }, diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 50b0c08c..7cf8b7ef 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { MessagesSquare } from 'lucide-react'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -31,8 +31,10 @@ import { chatMainColumnClass, chatStageClass, composerNativeEditChord, + filterConversations, } from './chat-model'; import { subscribeChatShortcutKeydown } from './chat-shortcuts'; +import { useChatSessionSwitch } from './use-chat-session-switch'; import { chatModShiftIShouldOpenModel } from './chat-model-labels'; import { formatChatSessionRecord, processUserPromptPreview, type TurnGroup } from './chat-format'; import { chatBusySendMode, grokLegacyContinueKind } from './chat-grok-follow-up'; @@ -242,6 +244,16 @@ export default function ChatPage() { }; }, [page.runChatAction]); + const switchSessions = useMemo( + () => filterConversations(page.conversations, page.railQuery), + [page.conversations, page.railQuery], + ); + useChatSessionSwitch({ + sessions: switchSessions, + currentId: page.activeId, + onFocus: page.focusConversation, + }); + if (page.error && page.conversations.length === 0 && !page.listLoading) { return (
@@ -303,8 +315,11 @@ export default function ChatPage() { active={page.active} railOpen={page.railOpen} recordText={formatChatSessionRecord(page.turns, t('common.you'))} + sessions={switchSessions} + sendingConversationIds={page.sendingConversationIds} onExpandRail={() => page.setRailOpen(true)} onRename={page.renameTitle} + onFocus={page.focusConversation} onOpenSettings={() => page.setSettingsOpen(true)} onPickWorkingDirectory={() => void page.pickWorkingDirectory()} runtimeLocked={page.runtimeLocked || page.sendingHere} diff --git a/src/pages/chat/use-chat-session-switch.ts b/src/pages/chat/use-chat-session-switch.ts new file mode 100644 index 00000000..f569d2e3 --- /dev/null +++ b/src/pages/chat/use-chat-session-switch.ts @@ -0,0 +1,38 @@ +import { useEffect } from 'react'; +import { hasEscPriorityOverlay } from '@/lib/skills/preview-keys'; +import { + adjacentSessionId, + chatSessionSwitchShortcutAction, +} from './chat-session-switch'; +import { subscribeChatShortcutKeydown } from './chat-shortcuts'; + +export function useChatSessionSwitch(input: { + sessions: readonly { id: string }[]; + currentId: string | null; + onFocus: (id: string) => void; +}): void { + const { sessions, currentId, onFocus } = input; + const idsKey = sessions.map((item) => item.id).join('\n'); + + useEffect(() => { + const list = idsKey === '' ? [] : idsKey.split('\n').map((id) => ({ id })); + return subscribeChatShortcutKeydown((event) => { + if (event.isComposing) return; + const action = chatSessionSwitchShortcutAction({ + key: event.key, + code: event.code, + altKey: event.altKey, + metaKey: event.metaKey, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + overlayOpen: hasEscPriorityOverlay(), + }); + if (!action) return; + const target = adjacentSessionId(list, currentId, action); + if (!target) return; + event.preventDefault(); + event.stopPropagation(); + onFocus(target); + }); + }, [currentId, idsKey, onFocus]); +} From 11cf42462dedb5152b4996079ff22599aee0e6e5 Mon Sep 17 00:00:00 2001 From: NiceChen Date: Sun, 20 Sep 2026 21:50:10 +0800 Subject: [PATCH 04/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E6=80=9D?= =?UTF-8?q?=E8=80=83=E8=BF=87=E7=A8=8B=E4=B8=8E=E5=B7=A5=E5=85=B7=E5=88=86?= =?UTF-8?q?=E5=BC=80=E6=98=BE=E7=A4=BA=20(#370)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 对话:思考过程与工具分开显示 * 对话:收窄思考步骤类型 --- src/lib/chat-process.test.ts | 99 +++++++++++++++++++++++- src/lib/chat-process.ts | 83 ++++++++++++++++++++ src/pages/chat/ChatMessageBubble.test.ts | 59 ++++++++++++++ src/pages/chat/ChatMessageBubble.tsx | 50 ++++++++++-- src/pages/chat/ChatProcessPanel.test.ts | 27 +++++++ src/pages/chat/ChatProcessPanel.tsx | 71 ++++++++++++++--- src/pages/chat/ChatTranscript.test.ts | 52 +++++++++++++ src/pages/chat/chat-format.test.ts | 3 + 8 files changed, 428 insertions(+), 16 deletions(-) diff --git a/src/lib/chat-process.test.ts b/src/lib/chat-process.test.ts index 65dea0c6..6c16259f 100644 --- a/src/lib/chat-process.test.ts +++ b/src/lib/chat-process.test.ts @@ -10,13 +10,17 @@ import { hasInspectableProcess, hasProcessDetails, isProtocolProcessStep, + latestThinkingStep, mergeThinkingText, mergeToolResult, phaseFromMessageStatus, processKey, processPhaseLabel, reduceProcessEvent, + showBubbleThinkingBar, stepSummary, + thinkingElapsedMs, + timelineHasToolRow, timelineProcessSteps, toolActionTarget, toolActionTone, @@ -622,6 +626,97 @@ describe('chat-process reduceProcessEvent', () => { expect(map['1:grok']?.steps[1]).toMatchObject({ type: 'tool', status: 'end', result: 'ok' }); }); + it('stamps thinking start and freezes duration when the episode ends', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1000, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'Hel', done: false }, + }, + 2000, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 3500)).toBe(1500); + expect(latestThinkingStep(map['1:grok']?.steps)).toMatchObject({ done: false }); + expect(showBubbleThinkingBar(map['1:grok']?.steps, false)).toBe(true); + expect(showBubbleThinkingBar(map['1:grok']?.steps, true)).toBe(false); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'lo', done: false }, + }, + 2800, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(2000); + + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 5200, + ); + expect(map['1:grok']?.thinkingDurationMs).toBe(3200); + expect(thinkingElapsedMs(map['1:grok'], 9000)).toBe(3200); + expect(timelineHasToolRow(map['1:grok']?.steps)).toBe(true); + }); + + it('starts a new thinking timer after a tool', () => { + let map: ProcessMap = reduceProcessEvent( + {}, + { type: 'agentStarted', turn: 1, agent: 'grok', command: 'x' }, + 1, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'first', done: false }, + }, + 100, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'tool', id: 't1', name: 'Read', status: 'start' }, + }, + 400, + ); + map = reduceProcessEvent( + map, + { + type: 'agentProcess', + turn: 1, + agent: 'grok', + step: { type: 'thinking', text: 'second', done: false }, + }, + 900, + ); + expect(map['1:grok']?.thinkingStartedAt).toBe(900); + expect(map['1:grok']?.thinkingDurationMs).toBeUndefined(); + expect(thinkingElapsedMs(map['1:grok'], 1400)).toBe(500); + }); + it('agentFinished marks leftover thinking done', () => { let map: ProcessMap = reduceProcessEvent( {}, @@ -646,9 +741,11 @@ describe('chat-process reduceProcessEvent', () => { agent: 'grok', message: finishedMsg({ status: 'ok', content: 'done', agentId: 'grok' }), }, - 3, + 5002, ); expect(map['1:grok']?.steps[0]).toMatchObject({ type: 'thinking', done: true }); + expect(map['1:grok']?.thinkingStartedAt).toBe(2); + expect(map['1:grok']?.thinkingDurationMs).toBe(5000); }); it('finished finalizes still-active process views for the turn', () => { diff --git a/src/lib/chat-process.ts b/src/lib/chat-process.ts index 41af2d83..cea37e2a 100644 --- a/src/lib/chat-process.ts +++ b/src/lib/chat-process.ts @@ -16,6 +16,8 @@ export type ProcessPhase = | 'cancelled' | 'timeout'; +export type ThinkingStep = Extract; + export type AgentProcessView = { turn: number; agent: AgentKey; @@ -26,6 +28,10 @@ export type AgentProcessView = { /** Structured steps (tool / thinking / status / raw / usage). Cap in reducer. */ steps: ProcessStep[]; updatedAt: number; + /** Wall-clock when the current/last thinking episode started. */ + thinkingStartedAt?: number; + /** Frozen duration for the last thinking episode once it finishes. */ + thinkingDurationMs?: number; }; export type ProcessMap = Record; @@ -344,6 +350,39 @@ export function timelineProcessSteps(steps: ProcessStep[]): ProcessStep[] { return out; } +export function isThinkingStep(step: ProcessStep): step is ThinkingStep { + return step.type === 'thinking'; +} + +export function latestThinkingStep(steps: ProcessStep[] | undefined): ThinkingStep | undefined { + const found = lastMatching(steps ?? [], isThinkingStep); + return found && isThinkingStep(found) ? found : undefined; +} + +/** Live timer, or the frozen duration after thinking ends. */ +export function thinkingElapsedMs(view: AgentProcessView | undefined, now: number): number { + if (!view?.thinkingStartedAt) return 0; + if (view.thinkingDurationMs != null) return Math.max(0, view.thinkingDurationMs); + return Math.max(0, now - view.thinkingStartedAt); +} + +/** Main-column chrome: thinking exists and the assistant body has not arrived. */ +export function showBubbleThinkingBar( + steps: ProcessStep[] | undefined, + hasContent: boolean, +): boolean { + if (hasContent) return false; + return latestThinkingStep(steps) != null; +} + +export function timelineHasToolRow(steps: ProcessStep[] | undefined): boolean { + return timelineProcessSteps(steps ?? []).some( + (step) => step.type === 'tool' || step.type === 'error' || step.type === 'raw', + ); +} + +function lastMatching(items: T[], pred: (item: T) => item is S): S | undefined; +function lastMatching(items: T[], pred: (item: T) => boolean): T | undefined; function lastMatching(items: T[], pred: (item: T) => boolean): T | undefined { for (let i = items.length - 1; i >= 0; i -= 1) { if (pred(items[i])) return items[i]; @@ -549,6 +588,45 @@ function markLastThinkingDone(steps: ProcessStep[]): ProcessStep[] { return steps; } +function freezeThinkingDuration( + view: Pick, + now: number, +): Pick { + if (view.thinkingStartedAt == null || view.thinkingDurationMs != null) { + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: view.thinkingDurationMs, + }; + } + return { + thinkingStartedAt: view.thinkingStartedAt, + thinkingDurationMs: Math.max(0, now - view.thinkingStartedAt), + }; +} + +function stampThinkingTiming( + prev: AgentProcessView, + step: ProcessStep, + now: number, +): Pick { + if (step.type === 'thinking') { + const last = prev.steps[prev.steps.length - 1]; + const mergeIntoOpen = last?.type === 'thinking' && !last.done; + const startedAt = mergeIntoOpen ? (prev.thinkingStartedAt ?? now) : now; + if (step.done) { + return { thinkingStartedAt: startedAt, thinkingDurationMs: Math.max(0, now - startedAt) }; + } + return { thinkingStartedAt: startedAt, thinkingDurationMs: undefined }; + } + if (prev.thinkingStartedAt != null && prev.thinkingDurationMs == null) { + return freezeThinkingDuration(prev, now); + } + return { + thinkingStartedAt: prev.thinkingStartedAt, + thinkingDurationMs: prev.thinkingDurationMs, + }; +} + /** * Codex `item.updated` reasoning is a full snapshot; Grok/Pi/Claude thinking * chunks are deltas. If the new text already contains the previous text as a @@ -686,6 +764,8 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no stdout: prev?.stdout ?? '', stderr: prev?.stderr ?? '', steps: prev?.steps ?? [], + thinkingStartedAt: prev?.thinkingStartedAt, + thinkingDurationMs: prev?.thinkingDurationMs, updatedAt: now, }, }; @@ -728,6 +808,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no ...prev, phase: prev.phase === 'queued' || prev.phase === 'starting' ? 'running' : prev.phase, steps: pushStep(prev.steps, ev.step), + ...stampThinkingTiming(prev, ev.step, now), updatedAt: now, }, }; @@ -744,6 +825,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no phase: phaseFromMessageStatus(ev.message.status), stdout: content || prev.stdout, steps: markLastThinkingDone(prev.steps), + ...freezeThinkingDuration(prev, now), updatedAt: now, }, }; @@ -765,6 +847,7 @@ export function reduceProcessEvent(map: ProcessMap, ev: ChatEvent, now = Date.no // 生产取消时 ok=true;缺省 cancelled 当 false,兼容旧事件 phase: ev.cancelled ? 'cancelled' : ev.ok ? 'ok' : 'failed', steps: markLastThinkingDone(view.steps), + ...freezeThinkingDuration(view, now), updatedAt: now, }; changed = true; diff --git a/src/pages/chat/ChatMessageBubble.test.ts b/src/pages/chat/ChatMessageBubble.test.ts index c5442f9a..cd629ff6 100644 --- a/src/pages/chat/ChatMessageBubble.test.ts +++ b/src/pages/chat/ChatMessageBubble.test.ts @@ -91,6 +91,65 @@ describe('ChatMessageBubble streaming feel', () => { expect(html).not.toContain('已停止'); }); + it('shows a clickable thinking bar instead of three dots when thinking has no body yet', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'secret plan that must not enter the bubble', done: false }], + updatedAt: 1, + thinkingStartedAt: Date.now() - 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考中'); + expect(html).toContain('▸'); + expect(html).not.toContain('正在想'); + expect(html).not.toContain('secret plan that must not enter the bubble'); + expect(html).not.toContain('data-help="chat-process-chip"'); + }); + + it('shows 思考了 after thinking ends and before the reply body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'codex', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'done thinking body', done: true }], + updatedAt: 1, + thinkingStartedAt: 1, + thinkingDurationMs: 3200, + }; + const html = renderToStaticMarkup( + createElement(TooltipProvider, null, createElement(ChatMessageBubble, { + message: agentMessage(''), + process, + isLastTurn: true, + multiAgent: false, + retryDisabled: false, + onRetry: () => undefined, + onOpenProcess: () => undefined, + })), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考了 3.2s'); + expect(html).not.toContain('正在写'); + expect(html).not.toContain('done thinking body'); + }); + it('opens process details from a one-line chip', () => { const process: AgentProcessView = { turn: 1, diff --git a/src/pages/chat/ChatMessageBubble.tsx b/src/pages/chat/ChatMessageBubble.tsx index e98767aa..d933f835 100644 --- a/src/pages/chat/ChatMessageBubble.tsx +++ b/src/pages/chat/ChatMessageBubble.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { AgentThinking } from '@/components/shared/AgentThinking'; import { CopyTextButton } from '@/components/shared/CopyTextButton'; @@ -10,7 +11,11 @@ import { formatProcessHeadline, formatTurnUsageFooter, hasInspectableProcess, + latestThinkingStep, phaseFromMessageStatus, + showBubbleThinkingBar, + thinkingElapsedMs, + timelineHasToolRow, } from '@/lib/chat-process'; import type { AgentProcessView } from '@/lib/chat-process'; import type { AgentKey, ChatMessage } from '@/lib/types'; @@ -20,6 +25,7 @@ import { localizeChatFailure, looksLikeChatProtocolDump, sanitizeCliChatText, + thinkingChromeLabel, } from './chat-format'; import { messageStatusLabel } from './chat-model'; import { streamingActivity, streamingPlaceholderKey } from './chat-streaming'; @@ -159,20 +165,31 @@ function AgentBubble({ : running ? 'running' : null; + const thinking = latestThinkingStep(process?.steps); + const showThinkingBar = showBubbleThinkingBar(process?.steps, hasContent); const showProcessChip = Boolean(onOpenProcess) && ( running || Boolean(process && hasInspectableProcess(process)) - ); + ) && (!showThinkingBar || timelineHasToolRow(process?.steps)); const processHeadline = showProcessChip ? process && effectivePhase ? formatProcessHeadline(process.steps, effectivePhase, t) : messageStatusLabel(t, resolvedStatus, process, hasContent) ?? t('chat.process.summaryGenerating') : ''; - const statusText = (hideRetry && looksFailed) || showProcessChip + const statusText = (hideRetry && looksFailed) || showProcessChip || showThinkingBar ? null : messageStatusLabel(t, resolvedStatus, process, hasContent); const activity = running ? streamingActivity(process, hasContent) : null; const showRetry = isLastTurn && looksFailed && !hideRetry; const usageText = formatTurnUsageFooter(process?.steps, running, t); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + if (!showThinkingBar || thinking?.done) return; + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, [showThinkingBar, thinking?.done]); + const thinkingLabel = thinking + ? thinkingChromeLabel(Boolean(thinking.done), thinkingElapsedMs(process, now), t) + : ''; return (
@@ -200,6 +217,29 @@ function AgentBubble({ )}
+ {showThinkingBar && thinkingLabel ? ( + + ) : null} {showProcessChip && processHeadline && onOpenProcess ? (
- ) : running ? ( + ) : running && !showThinkingBar ? ( - ) : ( + ) : !running ? ( {displayError || t('chat.bubble.noOutput')} - )} + ) : null} {displayError && (looksFailed || message.status !== 'ok') && displayContent && (

{displayError}

)} diff --git a/src/pages/chat/ChatProcessPanel.test.ts b/src/pages/chat/ChatProcessPanel.test.ts index d1c20973..4dea1f5a 100644 --- a/src/pages/chat/ChatProcessPanel.test.ts +++ b/src/pages/chat/ChatProcessPanel.test.ts @@ -102,13 +102,40 @@ describe('ChatProcessPanel human copy', () => { view({ phase: 'ok', steps: [{ type: 'thinking', text: '先看工作目录', done: true }], + thinkingStartedAt: 1, + thinkingDurationMs: 3200, }), 'ok', ); expect(html).toContain('先看工作目录'); + expect(html).toContain('思考了 3.2s'); + expect(html).toContain('data-help="chat-process-thinking"'); expect(html).toMatch(/]*open/); }); + it('keeps thinking as a fold separate from tool rows and pins live text', () => { + const html = renderPanel( + view({ + phase: 'running', + steps: [ + { type: 'thinking', text: '先看目录再改', done: false }, + { type: 'tool', name: 'Read', status: 'start', input: { path: 'README.md' } }, + ], + thinkingStartedAt: Date.now() - 3200, + }), + ); + expect(html).toContain('data-help="chat-process-thinking"'); + expect(html).toContain('data-help="chat-process-tool"'); + expect(html).toContain('思考中'); + expect(html).toContain('正在读取 README.md'); + expect(html).toContain('[overflow-anchor:none]'); + expect(html).toContain('max-h-40'); + const thinkingAt = html.indexOf('data-help="chat-process-thinking"'); + const toolAt = html.indexOf('data-help="chat-process-tool"'); + expect(thinkingAt).toBeGreaterThan(-1); + expect(toolAt).toBeGreaterThan(thinkingAt); + }); + it('offers one-click copy on JSON in tool details', () => { const html = renderPanel( view({ diff --git a/src/pages/chat/ChatProcessPanel.tsx b/src/pages/chat/ChatProcessPanel.tsx index 95c02565..e6950209 100644 --- a/src/pages/chat/ChatProcessPanel.tsx +++ b/src/pages/chat/ChatProcessPanel.tsx @@ -15,6 +15,7 @@ import { formatToolStep, formatUsageStep, isProtocolProcessStep, + latestThinkingStep, phaseFromMessageStatus, stepSummary, timelineProcessSteps, @@ -194,13 +195,21 @@ function toolHasProtocolDetails(step: Extract): b ); } -function ProcessStepRow({ step }: { step: ProcessStep }) { +function ProcessStepRow({ + step, + thinkingStartedAt, + thinkingDurationMs, +}: { + step: ProcessStep; + thinkingStartedAt?: number; + thinkingDurationMs?: number; +}) { const { t } = useI18n(); if (step.type === 'tool') { const input = formatStepInput(step.input); const live = toolActionTone(step.status) === 'live'; return ( -
+
; + return ( + + ); } if (step.type === 'error') { return
{step.message}
; @@ -259,15 +276,24 @@ function ThinkingStepRow({ text, done, defaultOpen, + startedAt, + durationMs, }: { text: string; done: boolean; defaultOpen: boolean; + startedAt?: number; + durationMs?: number; }) { const { t } = useI18n(); - const [elapsedMs, setElapsedMs] = useState(0); - const startRef = useRef(Date.now()); + const startRef = useRef(startedAt ?? Date.now()); + const [now, setNow] = useState(() => Date.now()); const [open, setOpen] = useState(defaultOpen); + const bodyRef = useRef(null); + + useEffect(() => { + if (startedAt != null) startRef.current = startedAt; + }, [startedAt]); useEffect(() => { if (done) { @@ -275,20 +301,26 @@ function ThinkingStepRow({ return; } setOpen(true); - startRef.current = Date.now(); - const tick = () => setElapsedMs(Math.max(0, Date.now() - startRef.current)); + const tick = () => setNow(Date.now()); tick(); const id = window.setInterval(tick, 1000); return () => window.clearInterval(id); }, [done, defaultOpen]); + const elapsedMs = done + ? (durationMs ?? 0) + : Math.max(0, now - startRef.current); const label = thinkingChromeLabel(done, elapsedMs, t); - const body = clipProcessTail(text); + useLayoutEffect(() => { + if (!done) pinElementScrollToBottom(bodyRef.current); + }, [body, done]); + return (
{ e.stopPropagation(); @@ -304,7 +336,12 @@ function ThinkingStepRow({ )} {body ? ( -
{body}
+
+          {body}
+        
) : null}
); @@ -343,6 +380,7 @@ export function ChatProcessPanel({ ); const timelineRef = useRef(null); const stderrRef = useRef(null); + const latestThinking = latestThinkingStep(timeline); useLayoutEffect(() => { pinElementScrollToBottom(timelineRef.current); @@ -367,7 +405,20 @@ export function ChatProcessPanel({
) : null} {timeline.map((step, i) => ( - + ))} {pendingConfirm ? (
diff --git a/src/pages/chat/ChatTranscript.test.ts b/src/pages/chat/ChatTranscript.test.ts index 1ce4eed1..668d06fd 100644 --- a/src/pages/chat/ChatTranscript.test.ts +++ b/src/pages/chat/ChatTranscript.test.ts @@ -2,6 +2,7 @@ import { createElement, createRef, type ReactElement } from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; +import type { AgentProcessView } from '@/lib/chat-process'; import type { ChatMessage, Conversation } from '@/lib/types'; import { chatTranscriptSurfaceClass } from './chat-model'; import { ChatTranscript } from './ChatTranscript'; @@ -101,6 +102,57 @@ describe('ChatTranscript surfaces', () => { expect(html).not.toContain('示例只填入输入框,由你发送'); }); + it('puts a clickable thinking bar on the assistant bubble before any body', () => { + const process: AgentProcessView = { + turn: 1, + agent: 'claude', + phase: 'running', + stdout: '', + stderr: '', + steps: [{ type: 'thinking', text: 'hidden thinking', done: false }], + updatedAt: 1, + thinkingStartedAt: Date.now() - 1500, + }; + const html = renderMarkup( + createElement(ChatTranscript, { + active: conversation(), + turns: [ + { + turn: 1, + user: userMessage('hello from chat'), + agents: [ + { + id: 'm-agent', + conversationId: 'c1', + turn: 1, + role: 'agent', + agentId: 'claude', + content: '', + status: 'running', + durationMs: 0, + createdAt: '2026-08-16T00:00:00.000Z', + }, + ], + }, + ], + processMap: { '1:claude': process }, + listLoading: false, + messagesLoading: false, + sending: true, + retryDisabled: false, + scrollRef: createRef(), + bottomRef: createRef(), + onScroll: () => undefined, + onRetry: () => undefined, + onOpenProcess: () => undefined, + }), + ); + expect(html).toContain('data-help="chat-thinking-bar"'); + expect(html).toContain('思考中'); + expect(html).not.toContain('hidden thinking'); + expect(html).not.toContain('正在想'); + }); + it('does not paint a panel card once a turn exists', () => { const html = renderTranscript([ { turn: 1, user: userMessage('hello from chat'), agents: [] }, diff --git a/src/pages/chat/chat-format.test.ts b/src/pages/chat/chat-format.test.ts index d5d20e17..c677db99 100644 --- a/src/pages/chat/chat-format.test.ts +++ b/src/pages/chat/chat-format.test.ts @@ -148,6 +148,9 @@ describe('chat-format thinking chrome', () => { expect(thinkingChromeLabel(false, 3200, t)).toBe('思考中 · 3.2s'); expect(thinkingChromeLabel(true, 3200, t)).toBe('思考了 3.2s'); expect(thinkingChromeLabel(true, 0, t)).toBe('思考完成'); + const tEn = createTranslator('en'); + expect(thinkingChromeLabel(false, 3200, tEn)).toBe('Thinking · 3.2s'); + expect(thinkingChromeLabel(true, 3200, tEn)).toBe('Thought for 3.2s'); }); it('pins process/thinking overflow to the newest line', () => { From ea9abefc66b2ae2e5efd0ba0483c37b9504e9508 Mon Sep 17 00:00:00 2001 From: NiceChen Date: Sun, 20 Sep 2026 21:50:47 +0800 Subject: [PATCH 05/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E6=9C=AC?= =?UTF-8?q?=E8=BD=AE=E6=94=B9=E8=BF=87=E7=9A=84=E6=96=87=E4=BB=B6=E5=8F=AF?= =?UTF-8?q?=E9=A2=84=E8=A7=88=20(#367)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 对话:本轮改过的文件可预览 * 测试:允许卡片内核对文件路径 --- e2e/browser/chat-file-approval.spec.ts | 2 +- src/lib/i18n/locales/en.ts | 1 + src/lib/i18n/locales/zh.ts | 1 + src/pages/chat/ChatEditPreviewPanel.tsx | 166 ++++++++++ src/pages/chat/chat-edit-preview.test.ts | 278 ++++++++++++++++ src/pages/chat/chat-edit-preview.ts | 377 ++++++++++++++++++++++ src/pages/chat/chat-layout.test.ts | 6 + src/pages/chat/chat-preview-model.test.ts | 14 + src/pages/chat/chat-preview-model.ts | 21 +- src/pages/chat/index.tsx | 42 ++- 10 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 src/pages/chat/ChatEditPreviewPanel.tsx create mode 100644 src/pages/chat/chat-edit-preview.test.ts create mode 100644 src/pages/chat/chat-edit-preview.ts diff --git a/e2e/browser/chat-file-approval.spec.ts b/e2e/browser/chat-file-approval.spec.ts index 15e58173..cb32f351 100644 --- a/e2e/browser/chat-file-approval.spec.ts +++ b/e2e/browser/chat-file-approval.spec.ts @@ -20,7 +20,7 @@ test('path-only file approval stays readable without inventing a diff', async ({ const card = page.locator('[data-help="chat-file-change-preview-path-only"]'); await expect(card).toBeVisible({ timeout: 20_000 }); await expect(page.getByText('修改文件', { exact: true }).first()).toBeVisible(); - await expect(page.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); + await expect(card.getByText('/workspace/notes.md', { exact: true })).toBeVisible(); await expect(page.getByText('仅有路径,无内容预览')).toBeVisible(); await expect(page.getByRole('button', { name: '允许', exact: true })).toBeVisible(); await expect(page.getByRole('button', { name: '一直允许' })).toBeVisible(); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index aef863da..bd2e37ad 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2777,6 +2777,7 @@ export const en = { failed: "Couldn't open", emptyBody: "Nothing to show", truncatedSuffix: " · Truncated", + viewEdit: "View edits", }, composer: { placeholder: "Message an agent…", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index c0819a82..fa1e0821 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2757,6 +2757,7 @@ export const zh = { failed: "无法打开", emptyBody: "没有内容", truncatedSuffix: " · 已截断", + viewEdit: "查看修改", }, composer: { placeholder: "发给 Agent…", diff --git a/src/pages/chat/ChatEditPreviewPanel.tsx b/src/pages/chat/ChatEditPreviewPanel.tsx new file mode 100644 index 00000000..c3ac4792 --- /dev/null +++ b/src/pages/chat/ChatEditPreviewPanel.tsx @@ -0,0 +1,166 @@ +import { useEffect, useId } from 'react'; +import { PanelRightClose } from 'lucide-react'; +import { SourcePreview } from '@/components/shared/SourcePreview'; +import { CopyableFileName } from '@/components/shared/CopyableFileName'; +import { pathTailLabel } from '@/components/shared/file-name-label'; +import { useI18n } from '@/components/shared/LanguageProvider'; +import { Button } from '@/components/ui/button'; +import { Tip } from '@/components/ui/tooltip'; +import { CHAT_FILE_PREVIEW_MAX_CHARS } from '@/lib/source-preview'; +import { hasEscPriorityOverlay } from '@/lib/skills/preview-keys'; +import { cn } from '@/lib/utils'; +import { + sameEditPath, + turnEditDiffText, + type TurnEditFile, +} from './chat-edit-preview'; + +function fileName(path: string): string { + const parts = path.trim().split(/[/\\]/).filter(Boolean); + return parts[parts.length - 1] ?? path.trim(); +} + +export function ChatTurnEditList({ + files, + selectedPath, + onSelect, +}: { + files: TurnEditFile[]; + selectedPath?: string; + onSelect: (file: TurnEditFile) => void; +}) { + const { t } = useI18n(); + if (files.length === 0) return null; + return ( +
+

{t('chat.preview.viewEdit')}

+
    + {files.map((file) => { + const selected = Boolean(selectedPath && sameEditPath(selectedPath, file.path)); + const statusLabel = file.status === 'live' + ? t('chat.process.toolEdit') + : t('chat.process.toolEditDone'); + return ( +
  • + +
  • + ); + })} +
+
+ ); +} + +export function ChatEditPreviewPanel({ + file, + open, + width, + onClose, + className, +}: { + file: TurnEditFile; + open: boolean; + width?: number; + onClose: () => void; + className?: string; +}) { + const { t } = useI18n(); + const titleId = useId(); + const name = fileName(file.path); + const diff = turnEditDiffText(file) ?? ''; + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key !== 'Escape') return; + if (hasEscPriorityOverlay()) return; + e.preventDefault(); + onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( + + ); +} diff --git a/src/pages/chat/chat-edit-preview.test.ts b/src/pages/chat/chat-edit-preview.test.ts new file mode 100644 index 00000000..c7bdb57f --- /dev/null +++ b/src/pages/chat/chat-edit-preview.test.ts @@ -0,0 +1,278 @@ +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { TooltipProvider } from '@/components/ui/tooltip'; +import type { ProcessMap } from '@/lib/chat-process'; +import type { ProcessStep } from '@/lib/types'; +import { ChatTurnEditList } from './ChatEditPreviewPanel'; +import { + extractEditFilesFromSteps, + extractTurnEdits, + formatSimpleDiff, + latestProcessTurn, + sameEditPath, + turnEditDiffText, + turnEditHasInlineDiff, +} from './chat-edit-preview'; + +function tool( + name: string, + status: string, + input?: unknown, + result?: string | null, +): ProcessStep { + return { type: 'tool', name, status, input, result }; +} + +function mapWith(steps: ProcessStep[], turn = 1, agent: 'codex' | 'grok' = 'codex'): ProcessMap { + return { + [`${turn}:${agent}`]: { + turn, + agent, + phase: 'running', + stdout: '', + stderr: '', + updatedAt: turn, + steps, + }, + }; +} + +describe('extractEditFilesFromSteps', () => { + it('keeps live Write paths as 正在修改 and completed as 已修改', () => { + expect( + extractEditFilesFromSteps([ + tool('Write', 'start', { path: 'src/a.ts' }), + ]), + ).toEqual([{ path: 'src/a.ts', status: 'live' }]); + expect( + extractEditFilesFromSteps([ + tool('Write', 'end', { path: 'src/a.ts' }), + ]), + ).toEqual([{ path: 'src/a.ts', status: 'done' }]); + }); + + it('ignores read / execute tools and failed edits', () => { + expect( + extractEditFilesFromSteps([ + tool('Read', 'end', { path: 'README.md' }), + tool('Bash', 'end', { command: 'ls' }), + tool('Write', 'error', { path: 'src/a.ts' }), + ]), + ).toEqual([]); + }); + + it('reads target_file, file_path, and file:// URIs', () => { + expect( + extractEditFilesFromSteps([ + tool('Edit', 'end', { target_file: '/workspace/src/app.ts' }), + tool('StrReplace', 'end', { file_path: 'lib\\b.ts' }), + tool('Delete', 'end', { uri: 'file://localhost/workspace/README.md' }), + ]), + ).toEqual([ + { path: '/workspace/src/app.ts', status: 'done' }, + { path: 'lib\\b.ts', status: 'done' }, + { path: '/workspace/README.md', status: 'done' }, + ]); + }); + + it('dedupes the same path and keeps the later status', () => { + expect( + extractEditFilesFromSteps([ + tool('Write', 'start', { path: 'src/a.ts' }), + tool('StrReplace', 'end', { path: 'src\\a.ts', old_string: 'a', new_string: 'b' }), + ]), + ).toEqual([ + { + path: 'src\\a.ts', + status: 'done', + before: 'a', + after: 'b', + }, + ]); + }); + + it('copies old/new text from StrReplace input', () => { + const files = extractEditFilesFromSteps([ + tool('StrReplace', 'end', { + path: 'src/app.ts', + old_string: "const name = 'old';", + new_string: "const name = 'new';", + }), + ]); + expect(files[0]?.before).toBe("const name = 'old';"); + expect(files[0]?.after).toBe("const name = 'new';"); + expect(turnEditHasInlineDiff(files[0]!)).toBe(true); + }); + + it('copies nested changes[] and fileChanges map', () => { + expect( + extractEditFilesFromSteps([ + tool('apply_patch', 'end', { + changes: [ + { path: 'src/app.ts', before: 'old', after: 'new' }, + { path: 'src/b.ts' }, + ], + }), + ]), + ).toEqual([ + { path: 'src/app.ts', status: 'done', before: 'old', after: 'new' }, + { path: 'src/b.ts', status: 'done' }, + ]); + + const written = extractEditFilesFromSteps([ + tool('apply_patch', 'end', { + fileChanges: { + '/tmp/example.txt': { type: 'add', content: 'ok' }, + }, + }), + ]); + expect(written).toEqual([ + { path: '/tmp/example.txt', status: 'done', after: 'ok' }, + ]); + expect(turnEditHasInlineDiff(written[0]!)).toBe(false); + }); + + it('reads grok operation.diff when it looks like a unified diff', () => { + const files = extractEditFilesFromSteps([ + tool('edit', 'end', { + toolCall: { + kind: 'edit', + rawInput: { + operation: { + type: 'update_file', + path: 'README.md', + diff: '@@ -1,2 +1,3 @@\n hello\n+world\n', + }, + }, + }, + }), + ]); + expect(files[0]?.path).toBe('README.md'); + expect(files[0]?.diff).toContain('@@ -1,2 +1,3 @@'); + expect(turnEditHasInlineDiff(files[0]!)).toBe(true); + }); + + it('does not treat a protocol snippet without old/new as an inline diff', () => { + const files = extractEditFilesFromSteps([ + tool('file_change', 'end', { + item: { + changes: [ + { + path: '/workspace/probe.txt', + kind: { type: 'add' }, + diff: 'FILECHANGE_OK\n', + }, + ], + }, + }), + ]); + expect(files[0]?.path).toBe('/workspace/probe.txt'); + expect(turnEditHasInlineDiff(files[0]!)).toBe(false); + }); + + it('parses JSON tool results and a path on the tool name', () => { + expect( + extractEditFilesFromSteps([ + tool( + 'Write', + 'end', + { path: 'notes.md' }, + JSON.stringify({ old_string: 'a', new_string: 'b' }), + ), + ]), + ).toEqual([ + { path: 'notes.md', status: 'done', before: 'a', after: 'b' }, + ]); + expect( + extractEditFilesFromSteps([tool('Write src/named.ts', 'end')]), + ).toEqual([{ path: 'src/named.ts', status: 'done' }]); + }); +}); + +describe('extractTurnEdits', () => { + it('uses the latest turn and merges agents on that turn', () => { + const processMap: ProcessMap = { + ...mapWith([tool('Write', 'end', { path: 'old.ts' })], 1, 'codex'), + ...mapWith([tool('Write', 'start', { path: 'src/a.ts' })], 2, 'codex'), + ...mapWith([tool('Edit', 'end', { path: 'src/b.ts' })], 2, 'grok'), + }; + expect(latestProcessTurn(processMap)).toBe(2); + expect(extractTurnEdits(processMap).map((file) => file.path)).toEqual([ + 'src/a.ts', + 'src/b.ts', + ]); + expect(extractTurnEdits(processMap, 1)).toEqual([ + { path: 'old.ts', status: 'done' }, + ]); + }); + + it('returns an empty list when there is no process map', () => { + expect(extractTurnEdits({})).toEqual([]); + }); +}); + +describe('simple diff', () => { + it('marks the changed middle lines', () => { + expect(formatSimpleDiff('keep\nold\nend', 'keep\nnew\nend', 'src/a.ts')).toBe( + [ + '--- src/a.ts', + '+++ src/a.ts', + '@@ -2,1 +2,1 @@', + '-old', + '+new', + ].join('\n'), + ); + }); + + it('sameEditPath treats slash variants as one file', () => { + expect(sameEditPath('src\\a.ts', 'src/a.ts')).toBe(true); + expect(sameEditPath('src/a.ts', 'src/b.ts')).toBe(false); + }); + + it('turnEditDiffText prefers a real patch over inventing one', () => { + expect( + turnEditDiffText({ + path: 'README.md', + diff: '@@ -1 +1,2 @@\n hello\n+world\n', + }), + ).toContain('@@ -1 +1,2 @@'); + expect( + turnEditDiffText({ + path: 'a.ts', + before: 'a', + after: 'b', + }), + ).toContain('-a'); + expect( + turnEditDiffText({ + path: 'a.ts', + after: 'only-new', + }), + ).toBeNull(); + }); +}); + +describe('ChatTurnEditList', () => { + it('labels this turn\'s files with 查看修改 / 正在修改 / 已修改', () => { + const html = renderToStaticMarkup( + createElement( + TooltipProvider, + null, + createElement(ChatTurnEditList, { + files: [ + { path: 'src/a.ts', status: 'live' }, + { path: 'src/b.ts', status: 'done' }, + ], + onSelect: () => undefined, + }), + ), + ); + expect(html).toContain('data-help="chat-turn-edits"'); + expect(html).toContain('查看修改'); + expect(html).toContain('正在修改'); + expect(html).toContain('已修改'); + expect(html).toContain('src/a.ts'); + expect(html).toContain('src/b.ts'); + }); +}); diff --git a/src/pages/chat/chat-edit-preview.ts b/src/pages/chat/chat-edit-preview.ts new file mode 100644 index 00000000..af8e0b72 --- /dev/null +++ b/src/pages/chat/chat-edit-preview.ts @@ -0,0 +1,377 @@ +import { + classifyToolAction, + toolActionTone, + type ProcessMap, +} from '@/lib/chat-process'; +import type { ProcessStep } from '@/lib/types'; + +export type TurnEditStatus = 'live' | 'done'; + +export type TurnEditFile = { + path: string; + status: TurnEditStatus; + before?: string; + after?: string; + diff?: string; +}; + +const PATH_KEYS = [ + 'path', + 'file', + 'filePath', + 'file_path', + 'target_file', + 'targetFile', + 'uri', + 'fileUri', + 'file_uri', +] as const; + +const BEFORE_KEYS = ['before', 'oldText', 'old_text', 'old_string'] as const; +const AFTER_KEYS = ['content', 'contents', 'newText', 'new_text', 'new_string', 'after'] as const; + +type ToolStep = Extract; + +type CollectedEdit = { + path: string; + before?: string; + after?: string; + diff?: string; +}; + +export function latestProcessTurn(processMap: ProcessMap): number | null { + let max = Number.NEGATIVE_INFINITY; + for (const view of Object.values(processMap)) { + if (typeof view.turn === 'number' && view.turn > max) max = view.turn; + } + return Number.isFinite(max) ? max : null; +} + +export function sameEditPath(a: string, b: string): boolean { + return normalizeEditPath(a) === normalizeEditPath(b); +} + +export function turnEditHasInlineDiff(file: TurnEditFile): boolean { + return Boolean(turnEditDiffText(file)); +} + +/** Unified diff already on the tool payload, or a simple diff from old+new text. */ +export function turnEditDiffText(file: Pick): string | null { + const patch = file.diff?.trim() ? file.diff : ''; + if (patch && looksLikeUnifiedDiff(patch)) return file.diff ?? patch; + if (file.before != null && file.after != null && file.before !== file.after) { + return formatSimpleDiff(file.before, file.after, file.path); + } + return null; +} + +export function formatSimpleDiff(before: string, after: string, path = ''): string { + const a = before.split('\n'); + const b = after.split('\n'); + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) start += 1; + let endA = a.length; + let endB = b.length; + while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { + endA -= 1; + endB -= 1; + } + const minusCount = Math.max(0, endA - start); + const plusCount = Math.max(0, endB - start); + const lines: string[] = []; + if (path.trim()) { + lines.push(`--- ${path.trim()}`); + lines.push(`+++ ${path.trim()}`); + } + lines.push(`@@ -${start + 1},${minusCount} +${start + 1},${plusCount} @@`); + for (let i = start; i < endA; i += 1) lines.push(`-${a[i]}`); + for (let i = start; i < endB; i += 1) lines.push(`+${b[i]}`); + return lines.join('\n'); +} + +export function extractEditFilesFromSteps(steps: ProcessStep[]): TurnEditFile[] { + const files: TurnEditFile[] = []; + for (const step of steps) { + if (step.type !== 'tool') continue; + if (classifyToolAction(step.name) !== 'edit') continue; + const tone = toolActionTone(step.status); + if (tone === 'failed') continue; + const status: TurnEditStatus = tone === 'live' ? 'live' : 'done'; + const collected = filesFromToolStep(step); + for (const item of collected) { + upsertEditFile(files, { ...item, status }); + } + } + return files; +} + +/** This turn's 正在修改 / 已修改 files. Defaults to the latest turn in the map. */ +export function extractTurnEdits(processMap: ProcessMap, turn?: number): TurnEditFile[] { + const targetTurn = turn ?? latestProcessTurn(processMap); + if (targetTurn == null) return []; + const views = Object.values(processMap) + .filter((view) => view.turn === targetTurn) + .sort((a, b) => a.updatedAt - b.updatedAt); + const files: TurnEditFile[] = []; + for (const view of views) { + for (const file of extractEditFilesFromSteps(view.steps)) { + upsertEditFile(files, file); + } + } + return files; +} + +function filesFromToolStep(step: ToolStep): CollectedEdit[] { + const fromInput = collectEdits(step.input, 0); + const parsedResult = parseMaybeJson(step.result); + const fromResult = collectEdits(parsedResult, 0); + const merged = mergeCollected(fromInput, fromResult); + if (merged.length === 1) { + const orphan = textsFromUnknown(parsedResult); + if (orphan) merged[0] = overlayTexts(merged[0], orphan); + } + if (merged.length > 0) return merged; + const fallback = pathFromUnknown(step.input) ?? pathFromToolName(step.name); + return fallback ? [{ path: fallback }] : []; +} + +function textsFromUnknown(value: unknown): Pick | null { + if (typeof value === 'string' && looksLikeUnifiedDiff(value)) return { diff: value }; + const rec = asRecord(value); + if (!rec) return null; + const before = firstText(rec, BEFORE_KEYS); + const after = firstText(rec, AFTER_KEYS); + const diff = firstText(rec, ['diff', 'patch'] as const); + if (before == null && after == null && diff == null) return null; + const out: Pick = {}; + if (before != null) out.before = before; + if (after != null) out.after = after; + if (diff != null) out.diff = diff; + return out; +} + +function overlayTexts(row: CollectedEdit, extra: Pick): CollectedEdit { + return { + path: row.path, + before: row.before ?? extra.before, + after: row.after ?? extra.after, + diff: row.diff ?? extra.diff, + }; +} + +function collectEdits(value: unknown, depth: number): CollectedEdit[] { + if (depth > 3) return []; + const rec = asRecord(value); + if (!rec) { + const path = typeof value === 'string' ? normalizePath(value) : undefined; + return path && looksLikeFilePath(path) ? [{ path }] : []; + } + + const fromChanges = collectFromArray(rec.changes, depth); + if (fromChanges.length) return fromChanges; + + const fileChanges = asRecord(rec.fileChanges); + if (fileChanges) { + const rows: CollectedEdit[] = []; + for (const [path, row] of Object.entries(fileChanges)) { + rows.push(...pushChange(row, path, depth)); + } + if (rows.length) return rows; + } + + const fromLocations = collectFromArray(rec.locations, depth); + if (fromLocations.length) return fromLocations; + + const fromFiles = collectFilesField(rec.files, depth); + if (fromFiles.length) return fromFiles; + + if (rec.operation != null) { + const fromOperation = pushChange(rec.operation, undefined, depth); + if (fromOperation.length) return fromOperation; + } + + const self = pushChange(rec, undefined, depth); + if (self.length) return self; + + if (rec.item != null) { + const nested = collectEdits(rec.item, depth + 1); + if (nested.length) return nested; + } + + const rawInput = rec.toolCall && asRecord(rec.toolCall)?.rawInput; + if (rawInput != null) { + const nested = collectEdits(rawInput, depth + 1); + if (nested.length) return nested; + } + + if (rec.toolCall != null) { + const nested = collectEdits(rec.toolCall, depth + 1); + if (nested.length) return nested; + } + + return []; +} + +function collectFromArray(value: unknown, depth: number): CollectedEdit[] { + if (!Array.isArray(value)) return []; + const rows: CollectedEdit[] = []; + for (const item of value) { + rows.push(...pushChange(item, typeof item === 'string' ? item : undefined, depth)); + } + return rows; +} + +function collectFilesField(value: unknown, depth: number): CollectedEdit[] { + if (!Array.isArray(value)) return []; + const rows: CollectedEdit[] = []; + for (const item of value) { + if (typeof item === 'string') { + const path = normalizePath(item); + if (path) rows.push({ path }); + continue; + } + rows.push(...pushChange(item, undefined, depth)); + } + return rows; +} + +function pushChange(value: unknown, fallbackPath: string | undefined, depth: number): CollectedEdit[] { + const rec = asRecord(value); + const path = (rec ? pathFromRecord(rec) : undefined) + ?? (fallbackPath ? normalizePath(fallbackPath) : undefined); + if (!path) { + if (rec && depth < 3) return collectEdits(value, depth + 1); + return []; + } + const before = rec ? firstText(rec, BEFORE_KEYS) : undefined; + const after = rec ? firstText(rec, AFTER_KEYS) : undefined; + const diff = rec ? firstText(rec, ['diff', 'patch'] as const) : undefined; + const row: CollectedEdit = { path }; + if (before != null) row.before = before; + if (after != null) row.after = after; + if (diff != null) row.diff = diff; + return [row]; +} + +function mergeCollected(first: CollectedEdit[], second: CollectedEdit[]): CollectedEdit[] { + const out: CollectedEdit[] = []; + for (const item of first) upsertCollected(out, item); + for (const item of second) upsertCollected(out, item); + return out; +} + +function upsertCollected(files: CollectedEdit[], next: CollectedEdit): void { + const index = files.findIndex((row) => sameEditPath(row.path, next.path)); + if (index < 0) { + files.push(next); + return; + } + const prev = files[index]; + files[index] = { + path: next.path || prev.path, + before: next.before ?? prev.before, + after: next.after ?? prev.after, + diff: next.diff ?? prev.diff, + }; +} + +function upsertEditFile(files: TurnEditFile[], next: TurnEditFile): void { + const index = files.findIndex((row) => sameEditPath(row.path, next.path)); + if (index < 0) { + files.push(next); + return; + } + const prev = files[index]; + files[index] = { + path: next.path || prev.path, + status: next.status, + before: next.before ?? prev.before, + after: next.after ?? prev.after, + diff: next.diff ?? prev.diff, + }; +} + +function pathFromUnknown(value: unknown): string | undefined { + if (typeof value === 'string') { + const path = normalizePath(value); + return path && looksLikeFilePath(path) ? path : undefined; + } + const rec = asRecord(value); + return rec ? pathFromRecord(rec) : undefined; +} + +function pathFromRecord(rec: Record): string | undefined { + for (const key of PATH_KEYS) { + const found = firstString(rec[key]); + const path = found ? normalizePath(found) : undefined; + if (path) return path; + } + return undefined; +} + +function pathFromToolName(name: string): string | undefined { + const parts = name.trim().split(/\s+/).filter(Boolean); + if (parts.length < 2) return undefined; + const last = parts[parts.length - 1]; + if (!last || !looksLikeFilePath(last)) return undefined; + return normalizePath(last); +} + +function looksLikeFilePath(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed || /\s/.test(trimmed) && !/[/\\]/.test(trimmed)) return false; + return /[/\\]/.test(trimmed) || /\.[A-Za-z0-9]{1,8}$/.test(trimmed); +} + +function normalizePath(raw: string): string | undefined { + let trimmed = raw.trim(); + if (!trimmed) return undefined; + if (trimmed.startsWith('file://')) { + trimmed = trimmed.slice('file://'.length); + if (trimmed.toLowerCase().startsWith('localhost')) { + trimmed = trimmed.slice('localhost'.length); + } + } + return trimmed || undefined; +} + +function normalizeEditPath(path: string): string { + return path.trim().replace(/\\/g, '/').replace(/\/+$/, ''); +} + +function looksLikeUnifiedDiff(text: string): boolean { + return /^(diff --git |--- |\+\+\+ |@@ )/m.test(text); +} + +function parseMaybeJson(value: unknown): unknown { + if (typeof value !== 'string') return value; + const trimmed = value.trim(); + if (!trimmed) return value; + const start = trimmed[0]; + if (start !== '{' && start !== '[') return value; + try { + return JSON.parse(trimmed) as unknown; + } catch { + return value; + } +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function firstText(rec: Record, keys: readonly string[]): string | undefined { + for (const key of keys) { + const found = firstString(rec[key]); + if (found != null) return found; + } + return undefined; +} + +function firstString(value: unknown): string | undefined { + if (typeof value === 'string' && value.trim()) return value; + if (!Array.isArray(value)) return undefined; + const parts = value.filter((item): item is string => typeof item === 'string' && Boolean(item.trim())); + return parts.length > 0 ? parts.join('\n') : undefined; +} diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 3b25e98a..c9448641 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -119,6 +119,12 @@ describe('chat layout wiring', () => { expect(source('ChatMarkdownPreviewPanel.tsx')).toContain('pathTailLabel'); expect(source('ChatMarkdownPreviewPanel.tsx')).toContain("label={folder}"); expect(source('index.tsx')).toContain('pushChatPreview'); + expect(source('index.tsx')).toContain('extractTurnEdits'); + expect(source('index.tsx')).toContain('ChatTurnEditList'); + expect(source('index.tsx')).toContain('ChatEditPreviewPanel'); + expect(source('index.tsx')).toContain('openChatEditPreview'); + expect(translate('zh', 'chat.preview.viewEdit')).toBe('查看修改'); + expect(translate('en', 'chat.preview.viewEdit')).toBe('View edits'); }); it('opens the turn process in the same right-hand pane', () => { diff --git a/src/pages/chat/chat-preview-model.test.ts b/src/pages/chat/chat-preview-model.test.ts index 791f93ab..76b14e5d 100644 --- a/src/pages/chat/chat-preview-model.test.ts +++ b/src/pages/chat/chat-preview-model.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest'; import { chatPreviewCanBack, chatPreviewPath, + isChatEditPreview, isChatFilePreview, isChatProcessInspect, + openChatEditPreview, openChatPreviewRoot, openChatProcessInspect, popChatPreview, @@ -45,4 +47,16 @@ describe('chat preview stack', () => { openChatPreviewRoot('/repo/README.md'), ); }); + + it('opens an edit preview by path without a back stack', () => { + const target = openChatEditPreview('src/a.ts'); + expect(isChatEditPreview(target)).toBe(true); + expect(isChatFilePreview(target)).toBe(false); + expect(chatPreviewPath(target)).toBe('src/a.ts'); + expect(chatPreviewCanBack(target)).toBe(false); + expect(popChatPreview(target)).toBeNull(); + expect(pushChatPreview(target, '/repo/README.md')).toEqual( + openChatPreviewRoot('/repo/README.md'), + ); + }); }); diff --git a/src/pages/chat/chat-preview-model.ts b/src/pages/chat/chat-preview-model.ts index 07c912a5..5eca26ce 100644 --- a/src/pages/chat/chat-preview-model.ts +++ b/src/pages/chat/chat-preview-model.ts @@ -13,7 +13,15 @@ export type ChatProcessInspectTarget = { agent: AgentKey; }; -export type ChatInspectTarget = ChatFilePreviewTarget | ChatProcessInspectTarget; +export type ChatEditPreviewTarget = { + kind: 'edit'; + path: string; +}; + +export type ChatInspectTarget = + | ChatFilePreviewTarget + | ChatProcessInspectTarget + | ChatEditPreviewTarget; /** File-or-process inspect target for the chat right pane. */ export type ChatPreviewTarget = ChatInspectTarget; @@ -30,7 +38,14 @@ export function isChatProcessInspect( return target?.kind === 'process'; } +export function isChatEditPreview( + target: ChatInspectTarget | null | undefined, +): target is ChatEditPreviewTarget { + return target?.kind === 'edit'; +} + export function chatPreviewPath(target: ChatInspectTarget | null | undefined): string { + if (isChatEditPreview(target)) return target.path; if (!isChatFilePreview(target) || !target.stack.length) return ''; return target.stack[target.stack.length - 1] ?? ''; } @@ -57,6 +72,10 @@ export function openChatProcessInspect(turn: number, agent: AgentKey): ChatProce return { kind: 'process', turn, agent }; } +export function openChatEditPreview(path: string): ChatEditPreviewTarget { + return { kind: 'edit', path }; +} + export function pushChatPreview( target: ChatInspectTarget | null | undefined, next: string, diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 7cf8b7ef..6c6d6a4e 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -38,14 +38,23 @@ import { useChatSessionSwitch } from './use-chat-session-switch'; import { chatModShiftIShouldOpenModel } from './chat-model-labels'; import { formatChatSessionRecord, processUserPromptPreview, type TurnGroup } from './chat-format'; import { chatBusySendMode, grokLegacyContinueKind } from './chat-grok-follow-up'; +import { ChatEditPreviewPanel, ChatTurnEditList } from './ChatEditPreviewPanel'; import { ChatMarkdownPreviewPanel } from './ChatMarkdownPreviewPanel'; import { ChatProcessInspectPanel } from './ChatProcessInspectPanel'; +import { + extractTurnEdits, + sameEditPath, + turnEditHasInlineDiff, + type TurnEditFile, +} from './chat-edit-preview'; import { chatPreviewCanBack, chatPreviewLine, chatPreviewPath, + isChatEditPreview, isChatFilePreview, isChatProcessInspect, + openChatEditPreview, openChatPreviewRoot, openChatProcessInspect, popChatPreview, @@ -119,6 +128,24 @@ export default function ChatPage() { } preview.open(previous); }, [preview.close, preview.open, preview.target]); + const turnEdits = useMemo(() => extractTurnEdits(page.processMap), [page.processMap]); + const openTurnEdit = useCallback( + (file: TurnEditFile) => { + if (turnEditHasInlineDiff(file)) { + preview.open(openChatEditPreview(file.path)); + return; + } + preview.open(openChatPreviewRoot(file.path)); + }, + [preview.open], + ); + const editPreviewPath = isChatEditPreview(preview.target) ? preview.target.path : ''; + const selectedEdit = editPreviewPath + ? turnEdits.find((file) => sameEditPath(file.path, editPreviewPath)) ?? null + : null; + const showEditDiff = Boolean( + selectedEdit && turnEditHasInlineDiff(selectedEdit), + ); useEffect(() => { preview.reset(); @@ -543,6 +570,11 @@ export default function ChatPage() { ); })()} + - {isChatFilePreview(preview.target) ? ( + {isChatFilePreview(preview.target) || (isChatEditPreview(preview.target) && !showEditDiff) ? ( + ) : showEditDiff && selectedEdit ? ( + ) : isChatProcessInspect(preview.target) ? ( Date: Sun, 20 Sep 2026 21:58:01 +0800 Subject: [PATCH 06/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=BC=9A=E8=AF=9D=E6=8C=89=E5=B7=A5=E4=BD=9C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E5=88=86=E7=BB=84=20(#368)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 对话:历史会话按工作目录分组 * 对话:工作区组标题改用 Hint * 对话:工作区组 Hint 不再传 className --- e2e/browser/boot-and-navigation.spec.ts | 4 +- e2e/browser/helpers.ts | 2 +- src/lib/i18n/index.test.ts | 3 +- src/lib/i18n/locales/en.ts | 2 +- src/lib/i18n/locales/zh.ts | 2 +- src/pages/chat/ChatSessionRail.test.ts | 52 +++++++++-- src/pages/chat/ChatSessionRail.tsx | 73 +++++++++++++--- src/pages/chat/chat-layout.test.ts | 3 +- src/pages/chat/chat-model.test.ts | 108 ++++++++++++++++------- src/pages/chat/chat-model.ts | 109 ++++++++++++++---------- src/pages/chat/use-chat-page.ts | 4 +- 11 files changed, 262 insertions(+), 100 deletions(-) diff --git a/e2e/browser/boot-and-navigation.spec.ts b/e2e/browser/boot-and-navigation.spec.ts index f0dcc3ef..9e594be4 100644 --- a/e2e/browser/boot-and-navigation.spec.ts +++ b/e2e/browser/boot-and-navigation.spec.ts @@ -20,7 +20,7 @@ test('app boots on mock and primary navigation works', async ({ page }) => { await expect(page).toHaveURL(/#\/projects/); await expect(page.getByRole('heading', { name: '历史' })).toBeVisible(); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect( page.getByRole('textbox', { name: '消息输入' }).or(page.getByText('还没有可对话的 Agent')), @@ -172,7 +172,7 @@ test('page title sits in the top bar; Chat has neither title nor in-app notifica expect(Math.abs(createBox!.y + createBox!.height / 2 - (leadBox!.y + leadBox!.height / 2))).toBeLessThanOrEqual(8); expect(Math.abs(leadBox!.y - connectionsTop)).toBeLessThanOrEqual(12); - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page).toHaveURL(/#\/chat/); await expect(page.getByRole('button', { name: '通知' })).toHaveCount(0); await expect(page.getByRole('heading', { level: 1 })).toHaveCount(0); diff --git a/e2e/browser/helpers.ts b/e2e/browser/helpers.ts index b320a648..3e842485 100644 --- a/e2e/browser/helpers.ts +++ b/e2e/browser/helpers.ts @@ -82,7 +82,7 @@ export async function addClaudeApiKeyAndSwitch(page: Page): Promise { } export async function openChatComposer(page: Page): Promise { - await goNav(page, '对话'); + await goNav(page, '工作区'); await expect(page.getByRole('textbox', { name: '消息输入' })).toBeVisible({ timeout: 20_000, }); diff --git a/src/lib/i18n/index.test.ts b/src/lib/i18n/index.test.ts index d1e668a9..4584ed1b 100644 --- a/src/lib/i18n/index.test.ts +++ b/src/lib/i18n/index.test.ts @@ -66,6 +66,7 @@ describe('translate / interpolate', () => { expect(translate('en', 'common.save')).toBe('Save'); expect(translate('zh', 'common.save')).toBe('保存'); const t = createTranslator('en'); + expect(t('nav.chat')).toBe('Workspace'); expect(t('nav.routes')).toBe('Routes'); expect(t('routes.page.title')).toBe('Routes'); expect(t('nav.dashboard')).toBe('Dashboard'); @@ -80,7 +81,7 @@ describe('translate / interpolate', () => { expect(zhNav('nav.routes')).toBe('路由'); expect(zhNav('routes.page.title')).toBe('路由'); expect(zhNav('nav.settings')).toBe('设置'); - expect(zhNav('nav.chat')).toBe('对话'); + expect(zhNav('nav.chat')).toBe('工作区'); expect(zhNav('nav.agents')).toBe('Agent'); expect(zhNav('chrome.onboarding.enterDashboard')).toBe('进入总览'); expect(zhNav('chrome.onboarding.skipGuide')).toBe('跳过引导'); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index bd2e37ad..d1296ec5 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -40,7 +40,7 @@ export const en = { nav: { workspace: 'Workspace', manage: 'Manage', - chat: 'Chat', + chat: 'Workspace', agents: 'Agents', skills: 'Skills', mcp: 'MCP', diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index fa1e0821..2481f397 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -39,7 +39,7 @@ export const zh = { nav: { workspace: '工作区', manage: '管理', - chat: '对话', + chat: '工作区', agents: 'Agent', skills: '技能', mcp: 'MCP', diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 1387f861..46afa7ba 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -5,7 +5,11 @@ import { describe, expect, it, vi } from 'vitest'; import { TooltipProvider } from '@/components/ui/tooltip'; import type { Conversation } from '@/lib/types'; import { createTranslator } from '@/lib/i18n'; -import { conversationRailHintView, conversationSemanticTitle } from './chat-model'; +import { + conversationRailHintView, + conversationSemanticTitle, + type ConversationWorkspaceGroup, +} from './chat-model'; import { ChatSessionRail } from './ChatSessionRail'; vi.mock('@/components/shared/LanguageProvider', async () => { @@ -34,12 +38,27 @@ function renderMarkup(node: ReactElement) { return renderToStaticMarkup(createElement(TooltipProvider, null, node)); } +function workspaceGroup( + items: Conversation[], + partial?: Partial, +): ConversationWorkspaceGroup { + const cwd = items[0]?.cwd ?? null; + return { + key: cwd ? `path:${cwd}` : 'unset', + label: cwd ? 'demo-project' : '未设置工作目录', + cwd, + pathLabel: null, + items, + ...partial, + }; +} + function rail(partial?: Partial[0]>) { const item = conversation(); return createElement(ChatSessionRail, { open: true, listLoading: false, - groups: [{ key: 'today', label: '今天', items: [item] }], + groups: [workspaceGroup([item])], conversations: [item], filteredCount: 1, query: '', @@ -65,7 +84,7 @@ describe('ChatSessionRail titles', () => { 'Use your terminal to write exactly what I asked without clipping the title'; const html = renderMarkup( rail({ - groups: [{ key: 'today', label: '今天', items: [conversation({ title: full })] }], + groups: [workspaceGroup([conversation({ title: full })])], conversations: [conversation({ title: full })], firstUserContentById: { c1: full }, }), @@ -94,7 +113,7 @@ describe('ChatSessionRail titles', () => { renderMarkup( rail({ activeId: active.id, - groups: [{ key: 'today', label: '今天', items: [active, clipped] }], + groups: [workspaceGroup([active, clipped])], conversations: [active, clipped], filteredCount: 2, firstUserContentById, @@ -133,7 +152,30 @@ describe('ChatSessionRail titles', () => { expect(hint.meta).toContain('/workspace/demo-project'); const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); expect(src).toContain('conversationRailHintView('); - expect(src).not.toContain('cwdShortName'); + expect(src).toContain('data-help="chat-workspace-group-label"'); + }); + + it('groups sessions under a collapsible working-directory header', () => { + const html = renderMarkup(rail()); + expect(html).toContain('data-help="chat-workspace-group"'); + expect(html).toContain('data-help="chat-workspace-group-label"'); + expect(html).toContain('demo-project'); + expect(html).toContain('aria-expanded="true"'); + expect(html).not.toContain('今天'); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('ChevronDown'); + expect(src).toContain('ChevronRight'); + }); + + it('shows the full path when two workspaces share a short name', () => { + const item = conversation(); + const html = renderMarkup( + rail({ + groups: [workspaceGroup([item], { pathLabel: '/home/alice/demo-project' })], + }), + ); + expect(html).toContain('data-help="chat-workspace-group-path"'); + expect(html).toContain('/home/alice/demo-project'); }); it('paints 新建对话 with the theme fill', () => { diff --git a/src/pages/chat/ChatSessionRail.tsx b/src/pages/chat/ChatSessionRail.tsx index 9f4b1423..efd91b24 100644 --- a/src/pages/chat/ChatSessionRail.tsx +++ b/src/pages/chat/ChatSessionRail.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; -import { Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { ChevronDown, ChevronRight, Loader2, PanelLeftClose, Plus, Trash2 } from 'lucide-react'; import { AgentLogo } from '@/components/shared/AgentLogo'; import { NavResizeHandle } from '@/components/layout/NavResizeHandle'; import { pageRhythm } from '@/components/layout/page-rhythm'; @@ -28,7 +28,7 @@ import { conversationRailMarkColor, conversationRailSelectedFill, conversationTitle, - type ConversationDayGroup, + type ConversationWorkspaceGroup, } from './chat-model'; export function ChatSessionRail({ @@ -56,7 +56,7 @@ export function ChatSessionRail({ }: { open: boolean; listLoading: boolean; - groups: ConversationDayGroup[]; + groups: ConversationWorkspaceGroup[]; conversations: Conversation[]; filteredCount: number; query: string; @@ -85,6 +85,8 @@ export function ChatSessionRail({ const pending = conversations.find((c) => c.id === deleteConfirmId) ?? null; const railRef = useRef(null); const searchInputRef = useRef(null); + const [collapsedKeys, setCollapsedKeys] = useState>(() => new Set()); + const searching = Boolean(query.trim()); useEffect(() => { if (!open || !searchFocusNonce) return; const timer = window.setTimeout(() => { @@ -93,6 +95,17 @@ export function ChatSessionRail({ }, 0); return () => window.clearTimeout(timer); }, [open, searchFocusNonce]); + useEffect(() => { + if (!activeId) return; + const key = groups.find((group) => group.items.some((item) => item.id === activeId))?.key; + if (!key) return; + setCollapsedKeys((prev) => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }, [activeId, groups, historyRevealNonce]); useEffect(() => { if (!open || !historyRevealNonce) return; const timer = window.setTimeout(() => { @@ -180,12 +193,47 @@ export function ChatSessionRail({

{t('chat.rail.noMatch')}

) : ( - groups.map((group) => ( -
-
- {group.label} -
- {group.items.map((c) => { + groups.map((group) => { + const expanded = searching || !collapsedKeys.has(group.key); + return ( +
+ + + + {expanded ? group.items.map((c) => { const selected = activeId === c.id; const sending = sendingConversationIds.includes(c.id); return ( @@ -255,9 +303,10 @@ export function ChatSessionRail({
); - })} + }) : null}
- )) + ); + }) )}
!next && onCancelDelete()}> diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index c9448641..4cef4438 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -310,7 +310,8 @@ describe('chat layout wiring', () => { expect(rail).toContain('AgentLogo'); expect(rail).toContain('hint={false}'); expect(rail).not.toContain('conversationAgentLine'); - expect(rail).not.toContain('cwdShortName'); + expect(rail).toContain('data-help="chat-workspace-group"'); + expect(rail).toContain('aria-expanded'); expect(rail).toContain('conversationTitle'); expect(rail).not.toContain('isBlankConversationDraft'); expect(rail).toContain("t('chat.rail.searchPlaceholder')"); diff --git a/src/pages/chat/chat-model.test.ts b/src/pages/chat/chat-model.test.ts index d18181ad..ac24597b 100644 --- a/src/pages/chat/chat-model.test.ts +++ b/src/pages/chat/chat-model.test.ts @@ -61,7 +61,9 @@ import { isBlankConversationDraft, draftForFocusedConversation, filterConversations, - groupConversationsByDay, + conversationWorkspaceKey, + groupConversationsByWorkspace, + UNSET_WORKSPACE_KEY, isChatAgentSelectable, liveSendingIds, incomingSendingIds, @@ -375,41 +377,69 @@ describe('filterConversations', () => { }); }); -describe('groupConversationsByDay', () => { - // 本地时区 2026-08-16 15:00 - const now = new Date(2026, 7, 16, 15, 0, 0, 0).getTime(); - - function at(y: number, m: number, d: number, h = 12): string { - return new Date(y, m, d, h, 0, 0, 0).toISOString(); +describe('groupConversationsByWorkspace', () => { + function at(day: number, hour = 12): string { + return new Date(Date.UTC(2026, 7, day, hour, 0, 0)).toISOString(); } - it('buckets across local-day boundaries and drops empty groups', () => { - const today = conv({ id: 't', title: 'today', updatedAt: at(2026, 7, 16, 1) }); - const yesterday = conv({ id: 'y', title: 'yest', updatedAt: at(2026, 7, 15, 23) }); - const week = conv({ id: 'w', title: 'week', updatedAt: at(2026, 7, 11, 8) }); - const earlier = conv({ id: 'e', title: 'old', updatedAt: at(2026, 7, 9, 8) }); - const groups = groupConversationsByDay([today, yesterday, week, earlier], now, t); + it('merges the same working directory across Windows case and slash variants', () => { + const newer = conv({ + id: 'a', + cwd: 'D:\\projects\\Demo', + updatedAt: at(16, 15), + }); + const older = conv({ + id: 'b', + cwd: 'd:/projects/demo/', + updatedAt: at(15, 10), + }); + const groups = groupConversationsByWorkspace([older, newer], t); + expect(groups).toHaveLength(1); + expect(groups[0].key).toBe(conversationWorkspaceKey('D:\\projects\\Demo')); + expect(groups[0].label).toBe('Demo'); + expect(groups[0].cwd).toBe('D:\\projects\\Demo'); + expect(groups[0].pathLabel).toBeNull(); + expect(groups[0].items.map((c) => c.id)).toEqual(['a', 'b']); + }); - expect(groups.map((g) => g.key)).toEqual(['today', 'yesterday', 'week', 'earlier']); - expect(groups.map((g) => g.label)).toEqual(['今天', '昨天', '近 7 天', '更早']); - expect(groups.map((g) => g.items.map((c) => c.id))).toEqual([['t'], ['y'], ['w'], ['e']]); + it('keeps POSIX paths with different case in separate groups', () => { + const upper = conv({ id: 'A', cwd: '/tmp/App', updatedAt: at(16) }); + const lower = conv({ id: 'B', cwd: '/tmp/app', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([upper, lower], t); + expect(groups.map((g) => g.cwd)).toEqual(['/tmp/App', '/tmp/app']); + expect(groups[0].key).not.toBe(groups[1].key); }); - it('keeps input order inside a group and omits empty buckets', () => { - const a = conv({ id: 'a', updatedAt: at(2026, 7, 16, 14) }); - const b = conv({ id: 'b', updatedAt: at(2026, 7, 16, 10) }); - const groups = groupConversationsByDay([a, b], now, t); - expect(groups).toHaveLength(1); - expect(groups[0].key).toBe('today'); - expect(groups[0].items.map((c) => c.id)).toEqual(['a', 'b']); + it('puts sessions without a working directory in one unset group', () => { + const withDir = conv({ id: 'dir', cwd: '/tmp/app', updatedAt: at(16) }); + const empty = conv({ id: 'empty', cwd: ' ', updatedAt: at(15) }); + const missing = conv({ id: 'missing', cwd: null, updatedAt: at(14) }); + const groups = groupConversationsByWorkspace([withDir, empty, missing], t); + expect(groups.map((g) => g.key)).toEqual([ + conversationWorkspaceKey('/tmp/app'), + UNSET_WORKSPACE_KEY, + ]); + expect(groups[1].label).toBe('未设置工作目录'); + expect(groups[1].cwd).toBeNull(); + expect(groups[1].items.map((c) => c.id)).toEqual(['empty', 'missing']); + expect(groups[0].items.map((c) => c.id)).toEqual(['dir']); }); - it('puts today-minus-6 in week and today-minus-7 in earlier', () => { - const sixDays = conv({ id: 's', updatedAt: at(2026, 7, 10, 12) }); - const sevenDays = conv({ id: 'v', updatedAt: at(2026, 7, 9, 12) }); - const groups = groupConversationsByDay([sixDays, sevenDays], now, t); - expect(groups.find((g) => g.key === 'week')?.items.map((c) => c.id)).toEqual(['s']); - expect(groups.find((g) => g.key === 'earlier')?.items.map((c) => c.id)).toEqual(['v']); + it('sorts groups and items by updated time, newest first', () => { + const olderApp = conv({ id: 'old-app', cwd: '/tmp/app', updatedAt: at(10) }); + const newerApp = conv({ id: 'new-app', cwd: '/tmp/app', updatedAt: at(16) }); + const other = conv({ id: 'other', cwd: '/tmp/other', updatedAt: at(12) }); + const groups = groupConversationsByWorkspace([olderApp, other, newerApp], t); + expect(groups.map((g) => g.cwd)).toEqual(['/tmp/app', '/tmp/other']); + expect(groups[0].items.map((c) => c.id)).toEqual(['new-app', 'old-app']); + }); + + it('shows the full path when two workspaces share a short name', () => { + const alice = conv({ id: 'alice', cwd: '/home/alice/app', updatedAt: at(16) }); + const bob = conv({ id: 'bob', cwd: '/home/bob/app', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([alice, bob], t); + expect(groups.map((g) => g.label)).toEqual(['app', 'app']); + expect(groups.map((g) => g.pathLabel)).toEqual(['/home/alice/app', '/home/bob/app']); }); }); @@ -598,6 +628,26 @@ describe('newConversationDefaults', () => { cwd: '/tmp/app', }); }); + + it('places a new session in the current workspace group', () => { + const active = conv({ + id: 'a', + agentIds: ['claude'], + cwd: '/tmp/app', + updatedAt: '2026-08-16T10:00:00.000Z', + }); + const defaults = newConversationDefaults(active, agents); + const created = conv({ + id: 'new', + agentIds: defaults.agentIds, + cwd: defaults.cwd, + updatedAt: '2026-08-16T12:00:00.000Z', + }); + const groups = groupConversationsByWorkspace([created, active], t); + expect(defaults.cwd).toBe('/tmp/app'); + expect(groups).toHaveLength(1); + expect(groups[0].items.map((c) => c.id)).toEqual(['new', 'a']); + }); }); describe('selectConversationAgent', () => { diff --git a/src/pages/chat/chat-model.ts b/src/pages/chat/chat-model.ts index 942091b6..199164a8 100644 --- a/src/pages/chat/chat-model.ts +++ b/src/pages/chat/chat-model.ts @@ -23,6 +23,7 @@ import type { ChatMessageStatus, Conversation, } from '@/lib/types'; +import { normalizeProjectMergePath } from '@/pages/projects/project-groups'; import { relativeTime, type TurnGroup } from './chat-format'; import { streamingStatusKey } from './chat-streaming'; @@ -166,21 +167,18 @@ export function chatAgentPickerEmptyCopy(t: TranslateFn, kind: ChatPickerEmptyKi return { text: t('chat.picker.none'), action: t('chat.picker.goAgents') }; } -export type ConversationDayKey = 'today' | 'yesterday' | 'week' | 'earlier'; +/** Conversations without a working directory share this rail group. */ +export const UNSET_WORKSPACE_KEY = 'unset'; -export type ConversationDayGroup = { - key: ConversationDayKey; +export type ConversationWorkspaceGroup = { + key: string; label: string; + cwd: string | null; + /** Full path under the short name when two workspaces share that name. */ + pathLabel: string | null; items: Conversation[]; }; -const DAY_KEYS: Record = { - today: 'chat.day.today', - yesterday: 'chat.day.yesterday', - week: 'chat.day.week', - earlier: 'chat.day.earlier', -}; - const RETRY_STATUSES = new Set(['failed', 'cancelled', 'timeout']); /** Interactive TUI resume command for a Hub conversation, when a native id is known. */ @@ -226,51 +224,72 @@ export function filterConversations(convs: Conversation[], query: string): Conve }); } -function startOfLocalDay(ms: number): Date { - const d = new Date(ms); - d.setHours(0, 0, 0, 0); - return d; -} - function parseUpdatedAt(iso: string): number { const t = Date.parse(iso.includes('T') ? iso : `${iso.replace(' ', 'T')}Z`); return Number.isNaN(t) ? 0 : t; } -export function groupConversationsByDay( +function trimmedConversationCwd(cwd: string | null | undefined): string | null { + const trimmed = cwd?.trim(); + return trimmed ? trimmed : null; +} + +/** Same merge key as history-page path groups: Windows case-insensitive, POSIX not. */ +export function conversationWorkspaceKey(cwd: string | null | undefined): string { + const trimmed = trimmedConversationCwd(cwd); + if (!trimmed) return UNSET_WORKSPACE_KEY; + return `path:${normalizeProjectMergePath(trimmed)}`; +} + +function compareConversationsByUpdatedAt(a: Conversation, b: Conversation): number { + return parseUpdatedAt(b.updatedAt) - parseUpdatedAt(a.updatedAt) || a.id.localeCompare(b.id); +} + +/** + * Group Hub conversations by working directory. Sessions without a folder stay + * in one unset group and never mix with path groups. + */ +export function groupConversationsByWorkspace( convs: Conversation[], - nowMs: number, t: TranslateFn, -): ConversationDayGroup[] { - const today = startOfLocalDay(nowMs); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const week = new Date(today); - week.setDate(week.getDate() - 6); - - const todayStart = today.getTime(); - const yesterdayStart = yesterday.getTime(); - const weekStart = week.getTime(); - - const buckets: Record = { - today: [], - yesterday: [], - week: [], - earlier: [], - }; +): ConversationWorkspaceGroup[] { + const buckets = new Map(); + for (const conversation of convs) { + const key = conversationWorkspaceKey(conversation.cwd); + const list = buckets.get(key); + if (list) list.push(conversation); + else buckets.set(key, [conversation]); + } - for (const c of convs) { - const ts = parseUpdatedAt(c.updatedAt); - if (ts >= todayStart) buckets.today.push(c); - else if (ts >= yesterdayStart) buckets.yesterday.push(c); - else if (ts >= weekStart) buckets.week.push(c); - else buckets.earlier.push(c); + const groups: ConversationWorkspaceGroup[] = []; + for (const [key, items] of buckets) { + const sortedItems = [...items].sort(compareConversationsByUpdatedAt); + const primary = sortedItems[0]; + const cwd = trimmedConversationCwd(primary?.cwd); + groups.push({ + key, + label: key === UNSET_WORKSPACE_KEY ? t('chat.header.cwdUnset') : cwdShortName(cwd, t), + cwd, + pathLabel: null, + items: sortedItems, + }); } - const order: ConversationDayKey[] = ['today', 'yesterday', 'week', 'earlier']; - return order - .filter((key) => buckets[key].length > 0) - .map((key) => ({ key, label: t(DAY_KEYS[key]), items: buckets[key] })); + const shortNameCounts = new Map(); + for (const group of groups) { + if (group.key === UNSET_WORKSPACE_KEY) continue; + shortNameCounts.set(group.label, (shortNameCounts.get(group.label) ?? 0) + 1); + } + for (const group of groups) { + if (group.key === UNSET_WORKSPACE_KEY || !group.cwd) continue; + if ((shortNameCounts.get(group.label) ?? 0) > 1) group.pathLabel = group.cwd; + } + + return groups.sort((a, b) => { + const aTs = parseUpdatedAt(a.items[0]?.updatedAt ?? ''); + const bTs = parseUpdatedAt(b.items[0]?.updatedAt ?? ''); + return bTs - aTs || a.label.localeCompare(b.label) || a.key.localeCompare(b.key); + }); } export function sendBlockers(input: { diff --git a/src/pages/chat/use-chat-page.ts b/src/pages/chat/use-chat-page.ts index 26b5bad4..56aae2ad 100644 --- a/src/pages/chat/use-chat-page.ts +++ b/src/pages/chat/use-chat-page.ts @@ -26,7 +26,7 @@ import { firstUserContentByListedConversations, filterConversations, mergeFirstUserContentById, - groupConversationsByDay, + groupConversationsByWorkspace, isChatAgentSelectable, savedLoginAgentIdsFromWallet, selectConversationAgent, @@ -600,7 +600,7 @@ export function useChatPage() { const railGroups = useMemo(() => { const filtered = filterConversations(conversations, railQuery); - return groupConversationsByDay(filtered, Date.now(), t); + return groupConversationsByWorkspace(filtered, t); }, [conversations, railQuery, t]); const filteredCount = useMemo( From 7e9db79f9f57919a83c0500f478f2b6c08e3bf62 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:22:25 +0800 Subject: [PATCH 07/13] chore(usage): refresh embedded model pricing from LiteLLM (#366) Auto-synced offline snapshot. Units remain USD per 1M tokens; no FX. Co-authored-by: nicechencs <186533068+nicechencs@users.noreply.github.com> --- .../src/usage/embedded-pricing.json | 92 ++++++++++++------- .../src/usage/embedded-pricing.meta.json | 8 +- 2 files changed, 62 insertions(+), 38 deletions(-) diff --git a/crates/agenthub-core/src/usage/embedded-pricing.json b/crates/agenthub-core/src/usage/embedded-pricing.json index 851f7c1b..7a572a81 100644 --- a/crates/agenthub-core/src/usage/embedded-pricing.json +++ b/crates/agenthub-core/src/usage/embedded-pricing.json @@ -413,12 +413,24 @@ "cacheCreate": 2.5, "cacheRead": 0.5 }, + "dashscope/qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.2, + "cacheRead": 0.016 + }, "dashscope/qwen3.8-max": { "input": 2, "output": 6, "cacheCreate": 2, "cacheRead": 0.25 }, + "dashscope/qwen3.8-omni-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.15, + "cacheRead": 0.016 + }, "dashscope/qwq-plus": { "input": 0.8, "output": 2.4, @@ -821,17 +833,17 @@ "cacheRead": 0.03 }, "gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cacheCreate": 0.75, + "cacheRead": 0.075 + }, + "gemini-flash-lite-latest": { "input": 0.3, "output": 2.5, "cacheCreate": 0.3, "cacheRead": 0.03 }, - "gemini-flash-lite-latest": { - "input": 0.1, - "output": 0.4, - "cacheCreate": 0.1, - "cacheRead": 0.01 - }, "gemini-gemma-2-27b-it": { "input": 0.35, "output": 1.05, @@ -853,13 +865,13 @@ "cacheCreate": 1.5 }, "gemini-pro-latest": { - "input": 1.25, - "output": 10, - "cacheCreate": 1.25, - "cacheRead": 0.125, - "inputAbove200k": 2.5, - "outputAbove200k": 15, - "cacheReadAbove200k": 0.25 + "input": 2, + "output": 12, + "cacheCreate": 2, + "cacheRead": 0.2, + "inputAbove200k": 4, + "outputAbove200k": 18, + "cacheReadAbove200k": 0.4 }, "gemini-robotics-er-1.5-preview": { "input": 0.3, @@ -873,10 +885,10 @@ "cacheCreate": 1 }, "gemini-robotics-er-2-preview": { - "input": 2, - "output": 10, - "cacheCreate": 2, - "cacheRead": 0.2 + "input": 1, + "output": 5, + "cacheCreate": 1, + "cacheRead": 0.1 }, "gemini-robotics-er-2-streaming-preview": { "input": 2, @@ -1029,17 +1041,17 @@ "cacheRead": 0.075 }, "gemini/gemini-flash-latest": { + "input": 0.75, + "output": 3.75, + "cacheCreate": 0.75, + "cacheRead": 0.075 + }, + "gemini/gemini-flash-lite-latest": { "input": 0.3, "output": 2.5, "cacheCreate": 0.3, "cacheRead": 0.03 }, - "gemini/gemini-flash-lite-latest": { - "input": 0.1, - "output": 0.4, - "cacheCreate": 0.1, - "cacheRead": 0.01 - }, "gemini/gemini-gemma-2-27b-it": { "input": 0.35, "output": 1.05, @@ -1061,13 +1073,13 @@ "cacheCreate": 1.5 }, "gemini/gemini-pro-latest": { - "input": 1.25, - "output": 10, - "cacheCreate": 1.25, - "cacheRead": 0.125, - "inputAbove200k": 2.5, - "outputAbove200k": 15, - "cacheReadAbove200k": 0.25 + "input": 2, + "output": 12, + "cacheCreate": 2, + "cacheRead": 0.2, + "inputAbove200k": 4, + "outputAbove200k": 18, + "cacheReadAbove200k": 0.4 }, "gemini/gemini-robotics-er-1.5-preview": { "input": 0.3, @@ -1081,10 +1093,10 @@ "cacheCreate": 1 }, "gemini/gemini-robotics-er-2-preview": { - "input": 2, - "output": 10, - "cacheCreate": 2, - "cacheRead": 0.2 + "input": 1, + "output": 5, + "cacheCreate": 1, + "cacheRead": 0.1 }, "gemini/gemini-robotics-er-2-streaming-preview": { "input": 2, @@ -2830,12 +2842,24 @@ "cacheCreate": 2.5, "cacheRead": 0.5 }, + "qwen3.8-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.2, + "cacheRead": 0.016 + }, "qwen3.8-max": { "input": 2, "output": 6, "cacheCreate": 2, "cacheRead": 0.25 }, + "qwen3.8-omni-flash": { + "input": 0.15, + "output": 0.47, + "cacheCreate": 0.15, + "cacheRead": 0.016 + }, "qwq-plus": { "input": 0.8, "output": 2.4, diff --git a/crates/agenthub-core/src/usage/embedded-pricing.meta.json b/crates/agenthub-core/src/usage/embedded-pricing.meta.json index c0cd1b5d..91c1bc61 100644 --- a/crates/agenthub-core/src/usage/embedded-pricing.meta.json +++ b/crates/agenthub-core/src/usage/embedded-pricing.meta.json @@ -1,9 +1,9 @@ { "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json", - "fetchedAt": "2026-09-16T11:17:33.814Z", - "modelCount": 515, - "fromLitellmRows": 299, - "aliasKeysAdded": 211, + "fetchedAt": "2026-09-20T11:05:19.474Z", + "modelCount": 519, + "fromLitellmRows": 301, + "aliasKeysAdded": 213, "overrideKeys": 16, "unit": "USD per 1M tokens", "notes": "Offline embedded snapshot. Runtime does not fetch pricing. Re-run scripts/update-embedded-pricing.mjs or wait for daily CI." From 97859eb8e49ec19f2fcd5763aa96eb9a04af5365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=83=E6=B0=94?= Date: Sun, 20 Sep 2026 22:26:23 +0800 Subject: [PATCH 08/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA=E6=98=BE=E7=A4=BA=E6=96=87=E4=BB=B6=E5=A4=B9?= =?UTF-8?q?=E5=90=8D=EF=BC=8C=E6=82=AC=E5=81=9C=E7=9C=8B=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=EF=BC=8C=E5=8A=A0=E5=8F=B7=E5=BC=80=E6=96=B0=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/i18n/locales/en.ts | 1 + src/lib/i18n/locales/zh.ts | 1 + src/pages/chat/ChatSessionRail.test.ts | 29 ++++++++++++------ src/pages/chat/ChatSessionRail.tsx | 36 ++++++++++++++++------- src/pages/chat/chat-layout.test.ts | 1 + src/pages/chat/chat-model.test.ts | 26 ++++++++++++++-- src/pages/chat/chat-model.ts | 19 +++--------- src/pages/chat/index.tsx | 2 +- src/pages/chat/use-chat-page-sessions.ts | 7 +++-- src/pages/projects/project-format.test.ts | 9 ++++++ src/pages/projects/project-format.ts | 25 ++++++++++++++-- src/pages/projects/project-groups.test.ts | 2 ++ src/pages/projects/project-groups.ts | 6 +++- 13 files changed, 119 insertions(+), 45 deletions(-) diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index d1296ec5..4948d9bb 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2738,6 +2738,7 @@ export const en = { expandHistory: "Expand history", resize: "Resize history", newChat: "New chat", + newChatInWorkspace: "New chat in this folder", newChatDisabled: "Install or unhide an agent first", searchPlaceholder: "Search title or folder", searchAria: "Search title or folder", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 2481f397..9e19d266 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2718,6 +2718,7 @@ export const zh = { expandHistory: "展开历史", resize: "拖动调整历史宽度", newChat: "新建对话", + newChatInWorkspace: "在此工作目录新建对话", newChatDisabled: "请先安装或取消隐藏 Agent", searchPlaceholder: "搜索标题或工作目录", searchAria: "搜索标题或工作目录", diff --git a/src/pages/chat/ChatSessionRail.test.ts b/src/pages/chat/ChatSessionRail.test.ts index 46afa7ba..64684e9b 100644 --- a/src/pages/chat/ChatSessionRail.test.ts +++ b/src/pages/chat/ChatSessionRail.test.ts @@ -47,7 +47,6 @@ function workspaceGroup( key: cwd ? `path:${cwd}` : 'unset', label: cwd ? 'demo-project' : '未设置工作目录', cwd, - pathLabel: null, items, ...partial, }; @@ -167,15 +166,27 @@ describe('ChatSessionRail titles', () => { expect(src).toContain('ChevronRight'); }); - it('shows the full path when two workspaces share a short name', () => { + it('hides a plus on the folder until hover, then starts a chat in that folder', () => { + const html = renderMarkup(rail()); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(html).toContain('data-help="chat-workspace-new"'); + expect(src).toContain('group-hover:opacity-100'); + expect(src).toContain('onNewChat(group.cwd)'); + expect(html).toContain('opacity-0'); + }); + + it('puts the folder path on hover, not in the group header', () => { const item = conversation(); - const html = renderMarkup( - rail({ - groups: [workspaceGroup([item], { pathLabel: '/home/alice/demo-project' })], - }), - ); - expect(html).toContain('data-help="chat-workspace-group-path"'); - expect(html).toContain('/home/alice/demo-project'); + const html = renderMarkup(rail({ groups: [workspaceGroup([item])] })); + const src = readFileSync(new URL('./ChatSessionRail.tsx', import.meta.url), 'utf8'); + expect(src).toContain('Hint label={group.cwd ?? group.label}'); + expect(src).not.toContain('chat-workspace-group-path'); + expect(html).toContain('data-help="chat-workspace-group-label"'); + expect(html).toContain('demo-project'); + const labelStart = html.indexOf('data-help="chat-workspace-group-label"'); + const labelHtml = html.slice(labelStart, labelStart + 180); + expect(labelHtml).toContain('demo-project'); + expect(labelHtml).not.toContain('/workspace/demo-project'); }); it('paints 新建对话 with the theme fill', () => { diff --git a/src/pages/chat/ChatSessionRail.tsx b/src/pages/chat/ChatSessionRail.tsx index efd91b24..46ec0266 100644 --- a/src/pages/chat/ChatSessionRail.tsx +++ b/src/pages/chat/ChatSessionRail.tsx @@ -67,7 +67,7 @@ export function ChatSessionRail({ hasUsableAgent: boolean; deleteConfirmId: string | null; onToggleRail: () => void; - onNewChat: () => void; + onNewChat: (cwd?: string | null) => void; onFocus: (id: string) => void; onRequestDelete: (id: string) => void; onCancelDelete: () => void; @@ -202,10 +202,11 @@ export function ChatSessionRail({ data-help="chat-workspace-group" data-workspace-key={group.key} > +
+ {group.cwd ? ( + + + + ) : null} +
{expanded ? group.items.map((c) => { const selected = activeId === c.id; const sending = sendingConversationIds.includes(c.id); diff --git a/src/pages/chat/chat-layout.test.ts b/src/pages/chat/chat-layout.test.ts index 4cef4438..7e9ddfb6 100644 --- a/src/pages/chat/chat-layout.test.ts +++ b/src/pages/chat/chat-layout.test.ts @@ -476,6 +476,7 @@ describe('chat layout wiring', () => { expect(page).toContain('runtimeReady: page.runtime != null'); expect(page).toContain('legacyNewChatAction'); expect(page).toContain('handleNewChat'); + expect(page).toContain('onNewChat={(cwd) => void page.handleNewChat(cwd)}'); }); it('uses shared Button for chrome icons and composer chips', () => { diff --git a/src/pages/chat/chat-model.test.ts b/src/pages/chat/chat-model.test.ts index ac24597b..34d32ccd 100644 --- a/src/pages/chat/chat-model.test.ts +++ b/src/pages/chat/chat-model.test.ts @@ -355,6 +355,18 @@ describe('cwdShortName', () => { expect(cwdShortName('/', t)).toBe('/'); expect(cwdShortName('///', t)).toBe('/'); }); + + it('uses the folder name when the path ends with a current-dir segment', () => { + expect(cwdShortName('D:\\projects\\demo\\.', t)).toBe('demo'); + expect(cwdShortName('/home/user/proj/.', t)).toBe('proj'); + expect(cwdShortName('C:\\.', t)).toBe('C:'); + }); + + it('does not show a lone dot as the workspace name', () => { + expect(cwdShortName('.', t)).toBe('未设目录'); + expect(cwdShortName('./', t)).toBe('未设目录'); + expect(cwdShortName('.\\', t)).toBe('未设目录'); + }); }); describe('filterConversations', () => { @@ -398,7 +410,6 @@ describe('groupConversationsByWorkspace', () => { expect(groups[0].key).toBe(conversationWorkspaceKey('D:\\projects\\Demo')); expect(groups[0].label).toBe('Demo'); expect(groups[0].cwd).toBe('D:\\projects\\Demo'); - expect(groups[0].pathLabel).toBeNull(); expect(groups[0].items.map((c) => c.id)).toEqual(['a', 'b']); }); @@ -434,12 +445,21 @@ describe('groupConversationsByWorkspace', () => { expect(groups[0].items.map((c) => c.id)).toEqual(['new-app', 'old-app']); }); - it('shows the full path when two workspaces share a short name', () => { + it('keeps colliding folder names as the short label; full path stays on cwd', () => { const alice = conv({ id: 'alice', cwd: '/home/alice/app', updatedAt: at(16) }); const bob = conv({ id: 'bob', cwd: '/home/bob/app', updatedAt: at(15) }); const groups = groupConversationsByWorkspace([alice, bob], t); expect(groups.map((g) => g.label)).toEqual(['app', 'app']); - expect(groups.map((g) => g.pathLabel)).toEqual(['/home/alice/app', '/home/bob/app']); + expect(groups.map((g) => g.cwd)).toEqual(['/home/alice/app', '/home/bob/app']); + }); + + it('merges a trailing current-dir segment into the same folder group', () => { + const dotted = conv({ id: 'dot', cwd: 'D:\\projects\\Demo\\.', updatedAt: at(16) }); + const plain = conv({ id: 'plain', cwd: 'D:\\projects\\Demo', updatedAt: at(15) }); + const groups = groupConversationsByWorkspace([dotted, plain], t); + expect(groups).toHaveLength(1); + expect(groups[0].label).toBe('Demo'); + expect(groups[0].items.map((c) => c.id)).toEqual(['dot', 'plain']); }); }); diff --git a/src/pages/chat/chat-model.ts b/src/pages/chat/chat-model.ts index 199164a8..2e492bf9 100644 --- a/src/pages/chat/chat-model.ts +++ b/src/pages/chat/chat-model.ts @@ -11,6 +11,7 @@ import type { } from '@/lib/backend/contracts/ticket'; import type { TranslateFn } from '@/lib/i18n'; import { processPhaseLabel, type AgentProcessView } from '@/lib/chat-process'; +import { folderNameFromCwd } from '@/lib/open-chat-cwd'; import { nativeResumeCommand } from '@/lib/session-resume'; import { activeBindingForAgent, @@ -174,8 +175,6 @@ export type ConversationWorkspaceGroup = { key: string; label: string; cwd: string | null; - /** Full path under the short name when two workspaces share that name. */ - pathLabel: string | null; items: Conversation[]; }; @@ -205,13 +204,14 @@ export function cwdShortName(cwd: string | null | undefined, t: TranslateFn): st if (cwd == null) return t('chat.cwd.unset'); const trimmed = cwd.trim(); if (!trimmed) return t('chat.cwd.unset'); + const name = folderNameFromCwd(trimmed); + if (name && name !== '.' && name !== '..') return name; const stripped = trimmed.replace(/[\\/]+$/, ''); if (!stripped) { // POSIX 根 `/`(或 `///`)去尾分隔后为空,仍应显示 `/` return trimmed.includes('/') ? '/' : t('chat.cwd.unset'); } - const parts = stripped.split(/[\\/]/); - return parts[parts.length - 1] || t('chat.cwd.unset'); + return t('chat.cwd.unset'); } export function filterConversations(convs: Conversation[], query: string): Conversation[] { @@ -270,21 +270,10 @@ export function groupConversationsByWorkspace( key, label: key === UNSET_WORKSPACE_KEY ? t('chat.header.cwdUnset') : cwdShortName(cwd, t), cwd, - pathLabel: null, items: sortedItems, }); } - const shortNameCounts = new Map(); - for (const group of groups) { - if (group.key === UNSET_WORKSPACE_KEY) continue; - shortNameCounts.set(group.label, (shortNameCounts.get(group.label) ?? 0) + 1); - } - for (const group of groups) { - if (group.key === UNSET_WORKSPACE_KEY || !group.cwd) continue; - if ((shortNameCounts.get(group.label) ?? 0) > 1) group.pathLabel = group.cwd; - } - return groups.sort((a, b) => { const aTs = parseUpdatedAt(a.items[0]?.updatedAt ?? ''); const bTs = parseUpdatedAt(b.items[0]?.updatedAt ?? ''); diff --git a/src/pages/chat/index.tsx b/src/pages/chat/index.tsx index 6c6d6a4e..e073ff15 100644 --- a/src/pages/chat/index.tsx +++ b/src/pages/chat/index.tsx @@ -326,7 +326,7 @@ export default function ChatPage() { hasUsableAgent={page.hasUsableAgent} deleteConfirmId={page.deleteConfirmId} onToggleRail={() => page.setRailOpen(false)} - onNewChat={() => void page.handleNewChat()} + onNewChat={(cwd) => void page.handleNewChat(cwd)} onFocus={page.focusConversation} onRequestDelete={page.setDeleteConfirmId} onCancelDelete={() => page.setDeleteConfirmId(null)} diff --git a/src/pages/chat/use-chat-page-sessions.ts b/src/pages/chat/use-chat-page-sessions.ts index 9285e975..9e53db7a 100644 --- a/src/pages/chat/use-chat-page-sessions.ts +++ b/src/pages/chat/use-chat-page-sessions.ts @@ -360,7 +360,7 @@ export function useChatPageSessions(input: { // eslint-disable-next-line react-hooks/exhaustive-deps -- one-shot when from= is set }, [searchParams]); - async function handleNewChat() { + async function handleNewChat(cwdOverride?: string | null) { let status = agentStatus; if (!agentsReady) { try { @@ -374,7 +374,10 @@ export function useChatPageSessions(input: { if (defaults.agentIds.length === 0) return; try { if (activeId) draftsRef.current.set(activeId, draft); - const conv = await createConversation(defaults.agentIds, defaults.cwd); + const conv = await createConversation( + defaults.agentIds, + cwdOverride === undefined ? defaults.cwd : cwdOverride, + ); setConversations((prev) => [conv, ...prev]); setActiveId(conv.id); setMessages([]); diff --git a/src/pages/projects/project-format.test.ts b/src/pages/projects/project-format.test.ts index 270d4c3b..e7f2f452 100644 --- a/src/pages/projects/project-format.test.ts +++ b/src/pages/projects/project-format.test.ts @@ -29,6 +29,15 @@ describe('displayTitle', () => { expect(displayTitle({ title: 'app', alias: ' ' })).toBe('app'); expect(displayTitle({ title: 'app' })).toBe('app'); }); + + it('uses the folder name when the stored title is a current-dir dot', () => { + expect( + displayTitle({ + title: '.', + actualPath: 'C:\\Users\\demo\\AgentHub\\.', + }), + ).toBe('AgentHub'); + }); }); describe('projectDisplayPath', () => { diff --git a/src/pages/projects/project-format.ts b/src/pages/projects/project-format.ts index 8d897e95..6b646305 100644 --- a/src/pages/projects/project-format.ts +++ b/src/pages/projects/project-format.ts @@ -1,11 +1,30 @@ import type { TranslateFn } from '@/lib/i18n'; +import { folderNameFromCwd } from '@/lib/open-chat-cwd'; import { restoreProjectWorkspacePath } from '@/lib/path-open'; import { nativeResumeCommand as planResumeCommand } from '@/lib/session-resume'; import type { AgentProject, AgentSession } from '@/lib/types'; -export function displayTitle(p: Pick): string { - const a = p.alias?.trim(); - return a || p.title; +function usableName(raw?: string | null): string { + const name = raw?.trim() ?? ''; + if (!name || name === '.' || name === '..') return ''; + return name; +} + +export function displayTitle( + p: Pick & + Partial>, +): string { + const alias = usableName(p.alias); + if (alias) return alias; + const title = usableName(p.title); + if (title) return title; + const path = + restoreProjectWorkspacePath(p) + || p.actualPath?.trim() + || p.relativePath?.trim() + || p.storagePath?.trim() + || ''; + return usableName(folderNameFromCwd(path)) || p.title; } /** Restored address for display. Click-to-open still requires a verified actualPath. */ diff --git a/src/pages/projects/project-groups.test.ts b/src/pages/projects/project-groups.test.ts index cd10a810..7dfa76b1 100644 --- a/src/pages/projects/project-groups.test.ts +++ b/src/pages/projects/project-groups.test.ts @@ -84,6 +84,7 @@ describe('groupProjectsByPath', () => { it('normalizes Windows slash, case, and trailing separators', () => { expect(normalizeProjectMergePath('C:\\Users\\demo\\app\\')).toBe('c:/users/demo/app'); + expect(normalizeProjectMergePath('C:\\Users\\demo\\app\\.')).toBe('c:/users/demo/app'); expect(normalizeProjectMergePath('\\\\Server\\Share\\App\\')).toBe('//server/share/app'); expect(projectMergeKey(claudeApp)).toBe(projectMergeKey(grokApp)); }); @@ -103,6 +104,7 @@ describe('groupProjectsByPath', () => { }); expect(normalizeProjectMergePath('/work/App')).toBe('/work/App'); expect(normalizeProjectMergePath('/work/app/')).toBe('/work/app'); + expect(normalizeProjectMergePath('/work/app/.')).toBe('/work/app'); expect(projectMergeKey(upper)).not.toBe(projectMergeKey(lower)); expect(groupProjectsByPath([upper, lower], true)).toHaveLength(2); }); diff --git a/src/pages/projects/project-groups.ts b/src/pages/projects/project-groups.ts index 54a6d0b1..b31ab322 100644 --- a/src/pages/projects/project-groups.ts +++ b/src/pages/projects/project-groups.ts @@ -31,7 +31,11 @@ function isWindowsWorkspacePath(path: string): boolean { /** Unify separators and trailing slashes without conflating POSIX case variants. */ export function normalizeProjectMergePath(path: string): string { const slashUnified = path.replace(/\\/g, '/'); - const normalized = slashUnified.replace(/\/+$/, '') || '/'; + let normalized = slashUnified.replace(/\/+$/, '') || '/'; + // Explorer / PathBuf often keep a trailing `/.` current-dir segment. + while (normalized.endsWith('/.') && normalized.length > 2) { + normalized = normalized.slice(0, -2).replace(/\/+$/, '') || '/'; + } return isWindowsWorkspacePath(path) ? normalized.toLowerCase() : normalized; } From cf1ffb1e7689901e9e7545f50dabeac025277ecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=83=E6=B0=94?= Date: Sun, 20 Sep 2026 22:56:35 +0800 Subject: [PATCH 09/13] =?UTF-8?q?=E5=AF=B9=E8=AF=9D=EF=BC=9A=E9=A1=B6?= =?UTF-8?q?=E6=A0=8F=E5=8E=BB=E6=8E=89=E6=8E=A5=E6=B3=95=EF=BC=8C=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E7=BB=A7=E7=BB=AD=E6=8C=89=E5=AF=B9=E6=96=B9=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 工作区顶栏不再显示这次对话怎么接,会话设置改短。历史在对话继续:对上对方会话则复用,找不到则新开一条;同一次跳转只创建一次。 --- .../src/services/chat_service.rs | 3 +- .../src/services/chat_service/tests.rs | 42 +++++++++++ docs/STATUS.md | 2 +- docs/concepts/chat-and-agents.md | 2 +- docs/concepts/chat-session-identity.md | 4 +- docs/reference/chat-session-options.md | 4 +- docs/ui/chat-experience-bar.md | 4 +- src/lib/api/chat.test.ts | 32 +++++++++ src/lib/i18n/locales/en.ts | 10 +-- src/lib/i18n/locales/zh.ts | 10 +-- src/pages/chat/ChatSessionHeader.tsx | 27 +------- src/pages/chat/ChatSettingsDialog.tsx | 69 ++++++++++--------- src/pages/chat/chat-connect-model.ts | 6 -- src/pages/chat/chat-layout.test.ts | 17 +++++ src/pages/chat/chat-session-switch.test.ts | 8 +++ src/pages/chat/index.tsx | 2 - src/pages/chat/use-chat-page-sessions.ts | 18 +++-- 17 files changed, 168 insertions(+), 92 deletions(-) diff --git a/crates/agenthub-core/src/services/chat_service.rs b/crates/agenthub-core/src/services/chat_service.rs index 6fdd1fe8..b1a59f4b 100644 --- a/crates/agenthub-core/src/services/chat_service.rs +++ b/crates/agenthub-core/src/services/chat_service.rs @@ -128,7 +128,8 @@ impl ChatService { /// Open or create an AgentHub conversation keyed by official session id. /// History comes from the store (or is imported once). Missing cwd is kept - /// for display and never fails this call. + /// for display and never fails this call. Same Agent and working directory + /// without that session id still create a new row. pub fn open_from_session( &self, agent_id: AgentId, diff --git a/crates/agenthub-core/src/services/chat_service/tests.rs b/crates/agenthub-core/src/services/chat_service/tests.rs index 197064b3..a705ef35 100644 --- a/crates/agenthub-core/src/services/chat_service/tests.rs +++ b/crates/agenthub-core/src/services/chat_service/tests.rs @@ -1715,6 +1715,48 @@ fn rebind_missing_cwd_allowed_after_session_starts() { assert_eq!(chat.list_messages(&again.id).unwrap().len(), 2); } +#[test] +fn open_from_session_creates_when_native_id_is_new() { + let dir = tempdir().unwrap(); + let db = Database::open(&dir.path().join("t.db")).unwrap(); + let run = Arc::new(RunService::with_runner( + deterministic_registry(), + Arc::new(RecordingProcessRunner::new()), + )); + let chat = ChatService::new(db, run); + let existing = chat + .create_conversation(vec![AgentId::Claude], Some("/work/app".into())) + .unwrap(); + + let opened = chat + .open_from_session( + AgentId::Claude, + Some("sess-history".into()), + Some("/work/app".into()), + Some("历史里的那场".into()), + vec![ChatHistoryTurn { + role: ChatRole::User, + content: "接着改登录页".into(), + }], + ) + .unwrap(); + assert_ne!(opened.id, existing.id); + assert_eq!(opened.native_session_id.as_deref(), Some("sess-history")); + assert_eq!(chat.list_conversations().unwrap().len(), 2); + + let again = chat + .open_from_session( + AgentId::Claude, + Some("sess-history".into()), + Some("/work/app".into()), + Some("忽略".into()), + vec![], + ) + .unwrap(); + assert_eq!(again.id, opened.id); + assert_eq!(chat.list_conversations().unwrap().len(), 2); +} + #[test] fn rebind_missing_cwd_rejects_clear() { let dir = tempdir().unwrap(); diff --git a/docs/STATUS.md b/docs/STATUS.md index cc186aad..2bd5efa0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -27,7 +27,7 @@ updated: 2026-09-20 - **过程面板**:主列一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行)。点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量;工具名、状态和 JSON 进折叠的「细节」;命令、过程日志、退出码和状态事件仍在「运行详情」。允许 / 拒绝按钮仍在卡片上,不在过程行上造假按钮。右侧栏不随发送自动打开;点 Markdown 仍预览文件。种类约定见 [过程事件](concepts/chat-process-events.md)。斜杠目录和模型目录更新不进过程时间线。Grok / Kiro 若推了当前轮 `plan`,或 Claude 新对话用 TodoWrite / Task 工具更新了任务清单,输入区上方出现计划条,换轮丢掉。ACP 若声明 `terminal`,对方跑的那条命令一张卡片,可停这一条(不是对话页终端)。仍约 80ms 读快照,过程行按序号增量挂现有面板,不另开总线。 - **过程内用量**:新空 Codex 会话仍解析 `thread/tokenUsage/updated` 的当前轮 `last`(累计 `total` / 窗口只留在总览等用量页)。新空 Grok 会话解析 **当前轮**(`turn_completed.usage`);ACP 没有会话累计字段,不把各轮相加冒充累计。Grok / Kiro 的 `context_usage` 有数字才进用量小字(窗口用量),全 0 不画。解析路径已接;真窗 2026-09-09 见过部分轮次 **没有** `turn_completed.usage`,此时界面不画假数字。对话里只在本轮结束后用小字写输入 / 输出(有缓存才写缓存);生成中不画用量。只显示协议里的数字,不估算费用。Kiro 没有 token 累计数据源。 - **`/` 菜单**:立刻执行的动作(新建对话、复制最近回复;换模型/思考/技能要搜到才列出,避免把整份目录摊在 `/` 上)。Grok / Kiro 会话就绪且对方声明了命令时,另列对方斜杠项:选中后当作一轮正常发出(无必填参数则直接发送 `/名字`;必填参数则插入 `/名字 ` 供补全后再发)。Grok 走标准 ACP `available_commands_update`;Kiro 走 `_kiro.dev/commands/available` 的 `commands[]`(不把技能/工具目录摊进 `/`)。目录变了会重拉;未就绪或未声明则不画,不猜菜单。Kiro 就绪会话「对方命令」Linux 真窗已 PASS(修复 tip `daed5ccf`,现行 tip 仍含该修复):列出对方声明的斜杠项,裸 `/` 不摊技能目录;记录见 `/workspace/qa-issues/CHAT-SLASH-PR365-RETEST-daed5ccf.md`。本条只记这次验过的展示,不把选中发送或其它 Agent 写成已验收。有可启动的命令行时,`/` 可列出「启动命令行」(DeepSeek 为「打开网页会话」),在外部打开,不标成对话页能力。 - - **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 顶栏和会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。 + - **本机对接与本会话**:本机持续通道按 Agent 写死:新空 Codex 走 app-server,新空 Grok / Kiro 走 ACP,新空 Claude 走 stream-json;其余与旧会话仍走原发送方式。Cursor 默认软隐藏,不在允许/拒绝之列,也不进持续聊天白名单。**Agents 详情**写这份 Agent 的新对话怎么接(ACP / 持续对话 / 原来的发送方式),不是一份可改的「ACP 总表」。**Chat 会话设置**写这次对话实际在走哪条;点了卡片上的一直允许之后,会话设置里可以关掉「本会话已一直允许」(不能在这里假装打开)。顶栏不写接法。一次对话是否在用持续通道,看这次会话是不是上述新空路径。Kiro 旧对话没有切到 ACP 的入口。会话字段见 [会话身份](concepts/chat-session-identity.md)。 - **新空 Claude 会话(B3 首片)**:走 Claude Code `-p --input-format stream-json --output-format stream-json` 持续通道(同进程多轮、本地图片 base64、模型/思考强度参数);**不支持**生成中补充;本片**不**接可点允许/拒绝(默认 `dontAsk`,危险模式 `bypassPermissions`)。有历史的旧 Claude 会话仍走 print+resume。print 路径在已经出过 assistant 正文后不再把最终 `result` 再拼进气泡(短回复不会同一句写两遍);只有没见过 assistant 文本时才用 `result` 当正文。Linux 真窗短回复已验不双写。见 [Claude B3](archive/chat-claude-b3.md)。 - **对话标题**:新建对话先用首条消息提炼的短句。一轮结束后读对方写在自己会话记录里的标题并改用它:Codex(app-server 汇总的 `sqlite/*.db` 里 `local_thread_catalog.display_title`,退回 `session_index.jsonl` 的 `thread_name`)、Grok(`summary.json` 的 `generated_title`)、Kiro(`sessions/cli/.json` 的 `title`)、DSH(会话日志的 `session/title` 行)。Claude 没有标题来源,保持首条消息推导。手动改过名字的对话不再被覆盖(不新增「谁起的名字」列,也不做迁移);连续通道取运行时线程 id,旧会话取 `native_session_id`。AgentHub 不进协议里要标题。实机核对:Grok 与 Codex 的真实会话都能取到;Codex 的 `session_index.jsonl` 只收 IDE / 桌面端自己建的会话,所以优先读 `local_thread_catalog`;Kiro 常见只有 1–2 字的占位;DSH 普通发送不带会话 id,实际触发不到;Claude 无来源。 - **停止**:点停止后按钮保持「正在停止」并禁用,直到这一轮真正结束。运行时已经是 `cancelling` 时同样显示「正在停止」。取消请求落空时恢复可点。停止横幅标题「已停止」;`error=cancelled` 不把英文 `cancelled` 写在旁边,改用「已按你的要求停止。可恢复草稿后重发。」 diff --git a/docs/concepts/chat-and-agents.md b/docs/concepts/chat-and-agents.md index 2fbe290b..d982934f 100644 --- a/docs/concepts/chat-and-agents.md +++ b/docs/concepts/chat-and-agents.md @@ -36,7 +36,7 @@ Kiro 会话设置里的「帮我批准 / 完全访问权限」是启动时的 `- 本机有哪些持续通道,是按 Agent 写死的名单,不是用户可改的「ACP 总表」。界面上分两层: - **Agents 详情「新对话」**:这份 Agent 开新对话时走 ACP / 持续对话 / 原来的发送方式。隐藏 Agent 后 Chat 不会用它开新对话。 -- **Chat 顶栏和会话设置「这次对话怎么接」**:这场对话实际在走哪条。旧 Kiro 等仍走原来的发送方式时,这里会和「新对话」不一致。 +- **Chat 会话设置「这次对话」**:这场对话实际在走哪条。旧 Kiro 等仍走原来的发送方式时,这里会和「新对话」不一致。顶栏不写接法。 | 本机对接 | 用在哪次会话 | | --- | --- | diff --git a/docs/concepts/chat-session-identity.md b/docs/concepts/chat-session-identity.md index 542fde5b..efa9775e 100644 --- a/docs/concepts/chat-session-identity.md +++ b/docs/concepts/chat-session-identity.md @@ -5,7 +5,7 @@ status: current owner: maintainers audience: chat and core contributors source-of-truth: Conversation / ChatService / chat_runtime thread_id -updated: 2026-09-15 +updated: 2026-09-20 --- # Chat 会话身份 @@ -31,7 +31,7 @@ updated: 2026-09-15 | --- | --- | | 对话页「新建对话」 | 插入新行:`native_session_id` 为空,直到对方给出会话 id。 | | 点开历史列表里已有行 | 续同一 `conversations.id`。持续通道用运行时线程;旧会话用已存的 `native_session_id` 解释标题和续聊。 | -| 按对方会话 id 打开(`open_from_session`) | 先用 `native_session_id` 查找:命中则续该行(空记录可补导入历史);未命中再新建并写入该 id。 | +| 按对方会话 id 打开(`open_from_session`) | 先用 `native_session_id` 查找:命中则续该行(空记录可补导入历史);未命中再新建并写入该 id。同一 Agent、同一工作目录但对方会话不同,仍是新开一条。 | | 默认空会话 `ensure_default_conversation` | 避免初始化重复插行;显式新建仍始终插入。 | | CLI `agenthub run` | 不复用对话页会话表。 | diff --git a/docs/reference/chat-session-options.md b/docs/reference/chat-session-options.md index 5adde4fc..bffe9444 100644 --- a/docs/reference/chat-session-options.md +++ b/docs/reference/chat-session-options.md @@ -4,7 +4,7 @@ description: Runtime Options 的 seed / 探测来源与 fail-closed 边界。 type: reference status: current owner: maintainers -updated: 2026-09-17 +updated: 2026-09-20 --- # Chat 会话选项目录 @@ -29,7 +29,7 @@ Chat「模型 / 思考 / 扩展 / 斜杠原生命令」等来自会话 **Options | Claude | `claude_fallback_catalog`(seed) | 无活探测权威时仅用 seed | | Codex(及其他走 app-server 探测的路径) | `model/list`、`skills/list`、`plugin/installed` | 探测失败 → 空/缓存策略;不伪装厂商未声明的项 | -原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。Grok 走标准 ACP `available_commands_update`;Kiro 在 `session/new` 之后用厂商通知 `_kiro.dev/commands/available` 的 `commands[]`(不要把 `prompts` / 技能、`tools`、`mcpServers` 摊进 `/`)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在 Chat 顶栏和会话设置「这次对话怎么接」,不进过程时间线。 +原生命令目录(斜杠)由持续通道在会话就绪后注入 `native_commands`;未就绪或目录空 → UI fail-closed(不假装有对方命令)。Grok 走标准 ACP `available_commands_update`;Kiro 在 `session/new` 之后用厂商通知 `_kiro.dev/commands/available` 的 `commands[]`(不要把 `prompts` / 技能、`tools`、`mcpServers` 摊进 `/`)。立刻执行的动作(如新建对话)与对方命令分来源,见 STATUS / Chat 概念页。`transport` 会画在会话设置「这次对话」,不进过程时间线。 ## 纪律 diff --git a/docs/ui/chat-experience-bar.md b/docs/ui/chat-experience-bar.md index 508e531c..496de596 100644 --- a/docs/ui/chat-experience-bar.md +++ b/docs/ui/chat-experience-bar.md @@ -3,7 +3,7 @@ title: Chat 体验标杆 type: ui status: current owner: maintainers -updated: 2026-09-16 +updated: 2026-09-20 --- # Chat 体验标杆 @@ -17,7 +17,7 @@ updated: 2026-09-16 | 流式 | 正文随生成出现。过程是「正在读取 / 正在修改 / 正在执行」,细节可展开。 | 新空 Codex / Grok / Kiro / Claude 持续聊天:活动会话约 80ms 读快照,正文用同一次读取的完整 currentMessage,不用字符串猜增量,也不把整段回复拆开假装逐字打出。首字前显示「正在想」,正文出现后「正在写」并带光标。主列只留一行过程摘要(正在读取 / 正在修改 / 正在执行,完成则已读取 / 已修改 / 已执行);点开后在右侧栏看你说了什么、思考、工具行、等待允许或拒绝、本轮用量和运行详情。工具名、状态和 JSON 进折叠的「细节」。思考带计时。允许 / 拒绝按钮仍在卡片上。右侧栏不随发送自动打开,点 Markdown 时仍预览文件。有历史的 Claude 与其他旧会话仍是一次性发送。 | | 用量 | 本轮结束后用小字写下输入 / 输出;生成中不画。没有数据不画假进度条。 | Codex 持续聊天仍解析 `thread/tokenUsage/updated` 的当前轮 last;Grok 解析 `turn_completed.usage`。对话里只在回复结束后用 muted 小字写本轮输入 / 输出(有缓存才写缓存)。不写累计、不对窗口做 n / 窗口。Kiro 无 token 累计数据源。没有数字就不画。 | | 输入区 Enter / 排队 / 停止 | `Enter` 发送,`Shift+Enter` 换行。生成中能补充(不打断当前动作)或排队到下一轮,两种动作分得清。停止先「正在停止」,确认后再「已停止」。中文输入法组字时 `Enter` 不发送。 | 空会话不把 Enter / 换行写在输入区下方,改到输入框和发送按钮的悬停提示。有消息后仍在输入区下写当前快捷键。`Enter` 发送,组字时不发。生成中 Codex 为补充,Grok / Kiro / Claude 为排队(Claude 无中途补充)。右下角只留一个圆形按钮:空闲是发送(无字则禁用);生成中有字且能补充/排队则仍是发送,清空后同一位置改成停止(方块图标,不并排「停止」二字)。点了停止之后(或运行时已是正在取消)保持「正在停止」并禁用,直到这一轮结束;取消没发出去则恢复可点。停止横幅标题「已停止」,不把英文 `cancelled` 写在旁边,用「已按你的要求停止。可恢复草稿后重发。」发送后焦点留在输入框。`Esc` 仍走同一条停止(对话框/菜单/预览先吃掉 Esc);快捷键一览与悬停浮层写着 Esc。 | - | 审批 | 卡片是「允许」「拒绝」;「一直允许」写清管到这场对话还是本轮。改文件能看见路径和将要改的内容。 | 卡片先写种类再写标题;允许 / 拒绝;带了该选项才出一直允许,范围写在按钮旁边:「仅当前这次对话,不保存」,并写明不是会话设置里的自动批准 / 完全访问权限。点了之后三家都在这场对话里记住后续同类确认(Codex 跨轮,含另一条工作目录外路径;Grok / Kiro 同一条 ACP 进程可跨轮),对话里出现「本会话已一直允许」,会话设置可以关掉记住、不能假装打开。没有该选项不画按钮。文件卡标题「修改文件」,等宽字显示路径;协议里已有 `diff` / `content` / 前后片段时直接展示,没有这些字段就写「暂无改动预览」,不编一段假 diff。Kiro「完全访问权限」是启动时的会话设置。顶栏和会话设置写这次对话怎么接(ACP / 持续对话 / 原来的发送方式);Agents 详情写这份 Agent 的新对话怎么接。 | + | 审批 | 卡片是「允许」「拒绝」;「一直允许」写清管到这场对话还是本轮。改文件能看见路径和将要改的内容。 | 卡片先写种类再写标题;允许 / 拒绝;带了该选项才出一直允许,范围写在按钮旁边:「仅当前这次对话,不保存」,并写明不是会话设置里的自动批准 / 完全访问权限。点了之后三家都在这场对话里记住后续同类确认(Codex 跨轮,含另一条工作目录外路径;Grok / Kiro 同一条 ACP 进程可跨轮),对话里出现「本会话已一直允许」,会话设置可以关掉记住、不能假装打开。没有该选项不画按钮。文件卡标题「修改文件」,等宽字显示路径;协议里已有 `diff` / `content` / 前后片段时直接展示,没有这些字段就写「暂无改动预览」,不编一段假 diff。Kiro「完全访问权限」是启动时的会话设置。会话设置写这次对话(ACP / 持续对话 / 原来的发送方式),顶栏不写;Agents 详情写这份 Agent 的新对话怎么接。 | | 模型与思考 | 输入区旁显示当前模型和思考强度;选项来自这份登录真实可用的列表;换模型后强度跟着变;生成中不改正在跑的一轮。 | Codex / Grok 可持续聊天里可换模型和思考强度,拒绝后保留原值。菜单用可读名(如 GPT 5.3 Codex Spark),当前项打勾。思考强度旁有短说明(可能更慢)。Kiro 开始后要换需新建对话。`Ctrl+Shift+I`(macOS 也认 `Cmd+Shift+I`)打开模型菜单。 | | 附件 | 发送前能看见已加的图片或文件,可移除。支持选图、粘贴、拖入;工作目录里的文件能点名带上。 | 新空 Codex / Grok / Kiro / Claude 持续聊天已接本地图片(选图 / 粘贴 / 拖入,最多 8 张、单张 10MB);已选图片在输入区旁显示可移除芯片。有历史的旧 Claude 仍是 print+resume,**没有**图片按钮。普通文件和 `@` 提文件尚未接入(协议未验证,不把路径拼进提示词冒充附件)。 | | 用于本次技能 | 当前 Agent 可调用的技能能选进这一条,选中后看得到。对方做不到时不画可点的假入口。 | Codex **不画**工具条技能按钮:技能由 Codex 自动选用,或输入 `/` 在动态菜单里选「用于本次」。Grok 无此能力,也不画假按钮。 | diff --git a/src/lib/api/chat.test.ts b/src/lib/api/chat.test.ts index 3aacc2e4..6e74023a 100644 --- a/src/lib/api/chat.test.ts +++ b/src/lib/api/chat.test.ts @@ -156,6 +156,38 @@ describe('chat API (browser mock)', () => { expect(again.cwd).toBe('C:\\Users\\demo\\app'); }); + it('opens a new conversation when the official session is not already in Chat', async () => { + const createdP = createConversation(['claude'], 'D:\\demo\\chen\\2026\\AgentHub'); + await vi.runAllTimersAsync(); + const created = await createdP; + + const openP = openConversationFromSession({ + agentId: 'claude', + sessionId: 'sess-history', + cwd: 'D:\\demo\\chen\\2026\\AgentHub', + title: '历史里的那场', + history: [{ role: 'user', content: '接着改登录页' }], + }); + await vi.runAllTimersAsync(); + const opened = await openP; + expect(opened.id).not.toBe(created.id); + expect(opened.nativeSessionId).toBe('sess-history'); + + const againP = openConversationFromSession({ + agentId: 'claude', + sessionId: 'sess-history', + cwd: 'D:\\demo\\chen\\2026\\AgentHub', + title: '忽略', + history: [], + }); + await vi.runAllTimersAsync(); + expect((await againP).id).toBe(opened.id); + + const listP = listConversations(); + await vi.runAllTimersAsync(); + expect((await listP).map((row) => row.id).sort()).toEqual([created.id, opened.id].sort()); + }); + it('create / list / update / delete conversation', async () => { const createP = createConversation(['claude'], 'D:\\demo'); await vi.runAllTimersAsync(); diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 4948d9bb..e1b5d9a7 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -2856,9 +2856,9 @@ export const en = { sessionRemembered: 'Always allow is on for this chat', sessionRememberedHint: 'A new chat will ask again. This is not auto-approve in session settings.', sessionRememberedHintKiro: 'A new chat will ask again. This is not full access in session settings.', - sessionRememberedOff: 'Off. Turn it on from a card — this switch will not fake an allow.', - sessionRememberedOnHint: 'Turn off to ask again in this chat. This is not auto-approve in session settings.', - sessionRememberedOnHintKiro: 'Turn off to ask again in this chat. This is not full access in session settings.', + sessionRememberedOff: 'Turn on from a card', + sessionRememberedOnHint: 'Turn off to ask again', + sessionRememberedOnHintKiro: 'Turn off to ask again', sessionRememberedClear: 'Stop remembering', sessionRememberedClearFailed: "Couldn't turn off always allow for this chat", replyFailed: "Couldn't send allow or deny", @@ -3048,7 +3048,7 @@ export const en = { acp: "ACP", continuous: "Continuous chat", legacy: "Original send path", - sessionTitle: "How this chat connects", + sessionTitle: "This chat", sessionHintAcp: "This chat uses ACP. Always allow on a card only remembers this conversation — not start-of-chat auto-approve.", sessionHintContinuous: "This chat uses continuous chat. Always allow on a card only remembers this conversation — not start-of-chat auto-approve.", sessionHintLegacy: "This chat uses the original send path. There is no allow/deny card.", @@ -3059,7 +3059,7 @@ export const en = { }, settings: { title: "Session settings", - description: "Working directory, how this chat connects, and auto-approve", + description: "Working folder and auto-approve", cwd: "Working directory", cwdPlaceholder: "Choose a local folder, or paste a full path", pickDir: "Choose folder", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 9e19d266..c836c80a 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -2836,9 +2836,9 @@ export const zh = { sessionRemembered: '本会话已一直允许', sessionRememberedHint: '新对话再问。不是会话设置里的自动批准。', sessionRememberedHintKiro: '新对话再问。不是会话设置里的完全访问权限。', - sessionRememberedOff: '关。卡片上点了才会开,不会在这里假装打开。', - sessionRememberedOnHint: '关掉后这次对话再问。不是会话设置里的自动批准。', - sessionRememberedOnHintKiro: '关掉后这次对话再问。不是会话设置里的完全访问权限。', + sessionRememberedOff: '卡片上点了才会开', + sessionRememberedOnHint: '关了这次对话再问', + sessionRememberedOnHintKiro: '关了这次对话再问', sessionRememberedClear: '停止记住', sessionRememberedClearFailed: '没法关掉本会话一直允许', replyFailed: '没法回传允许或拒绝', @@ -3028,7 +3028,7 @@ export const zh = { acp: "ACP", continuous: "持续对话", legacy: "原来的发送方式", - sessionTitle: "这次对话怎么接", + sessionTitle: "这次对话", sessionHintAcp: "这次对话走 ACP。卡片上的一直允许只记这一次,不是启动时的自动批准。", sessionHintContinuous: "这次对话走持续对话。卡片上的一直允许只记这一次,不是启动时的自动批准。", sessionHintLegacy: "这次对话走原来的发送方式,没有允许/拒绝卡片。", @@ -3039,7 +3039,7 @@ export const zh = { }, settings: { title: "会话设置", - description: "工作目录、这次对话怎么接、自动批准", + description: "工作目录和自动批准", cwd: "工作目录", cwdPlaceholder: "选择本机目录,或粘贴完整路径", pickDir: "选择目录", diff --git a/src/pages/chat/ChatSessionHeader.tsx b/src/pages/chat/ChatSessionHeader.tsx index 4b903059..1348764c 100644 --- a/src/pages/chat/ChatSessionHeader.tsx +++ b/src/pages/chat/ChatSessionHeader.tsx @@ -28,12 +28,8 @@ import type { Conversation } from '@/lib/types'; import { cn } from '@/lib/utils'; import { sessionSwitchNeighbors } from './chat-session-switch'; import { isKiroChatAgent } from './chat-kiro-model'; -import { - chatConnectLabelKey, - sessionChatConnectKind, -} from './chat-connect-model'; import { sessionAllowAlwaysActive } from './chat-runtime-model'; -import type { RuntimeChannel, RuntimeSnapshot } from '@/lib/api/chat'; +import type { RuntimeSnapshot } from '@/lib/api/chat'; import { autoApproveActive, autoApproveEffect, @@ -57,8 +53,6 @@ export function ChatSessionHeader({ onOpenSettings, onPickWorkingDirectory, runtimeLocked = false, - transport = null, - runtimeEnabled = false, runtime = null, }: { active: Conversation | null; @@ -72,8 +66,6 @@ export function ChatSessionHeader({ onOpenSettings: () => void; onPickWorkingDirectory: () => void; runtimeLocked?: boolean; - transport?: RuntimeChannel | null; - runtimeEnabled?: boolean; runtime?: Pick | null; }) { const { t } = useI18n(); @@ -96,11 +88,6 @@ export function ChatSessionHeader({ const selectedAgent = active?.agentIds[0] ?? null; const approveOn = autoApproveActive(Boolean(active?.allowDangerous), selectedAgent); const kiroPermissions = isKiroChatAgent(selectedAgent); - const connectKind = sessionChatConnectKind({ - agentId: selectedAgent, - transport, - runtimeEnabled, - }); const sessionAlways = sessionAllowAlwaysActive(runtime); async function commit() { @@ -235,18 +222,6 @@ export function ChatSessionHeader({ - - - {active.nativeSessionId && (
-
-

{t('chat.connect.sessionTitle')}

-

{t(chatConnectLabelKey(connectKind))}

-

{t(sessionChatConnectHintKey(connectKind))}

- {agentConnectKind !== connectKind ? ( -

- {t('chat.connect.agentTitle')} - {' · '} - {t(chatConnectLabelKey(agentConnectKind))} -

- ) : null} +
+ {t('chat.connect.sessionTitle')} +

+ {t(chatConnectLabelKey(connectKind))} + {agentConnectKind !== connectKind ? ( + + {t('chat.connect.agentTitle')} + {' · '} + {t(chatConnectLabelKey(agentConnectKind))} + + ) : null} +

{connectKind !== 'legacy' ? ( ) : null} {kiroPermissions ? ( -
- {t('chat.kiro.permissionTitle')} +
+ {t('chat.kiro.permissionTitle')} {permissionLocked ? (

{t('chat.kiro.settingsLocked')}

) : null} -
) : (