diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 2a4107e128..1707a6478f 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -27,11 +27,12 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-interactions', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -// One golden per interactive end-state: what the user is left looking at -// after cancel, after a non-retryable failure (pins the FIXME(web-error-surface) -// gap as a reviewable artifact: NO error copy in the tree), and after retry -// recovery — three genuinely different terminal surfaces of one fixture. +// One golden pins the stable mid-turn loading state; the other three capture +// what the user is left looking at after cancel, after a non-retryable failure +// (pins the FIXME(web-error-surface) gap as a reviewable artifact: NO error +// copy in the tree), and after retry recovery. const CANCEL_EXPECTED = join(SNAPSHOT_DIR, 'cancel.expected.md') +const LOADING_EXPECTED = join(SNAPSHOT_DIR, 'loading.expected.md') const ERROR_EXPECTED = join(SNAPSHOT_DIR, 'error-auth.expected.md') const RETRY_EXPECTED = join(SNAPSHOT_DIR, 'retry.expected.md') const MODE = webSnapshotMode() @@ -133,6 +134,12 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // The marker IS the synchronization: the stream is provably parked in the // hang (prefix chunks delivered to the loop) before the stop click. await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true) + await expect.poll( + () => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(), + { timeout: 10_000 }, + ).toBe(true) + const loadingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) + await compareOrRefreshGolden(LOADING_EXPECTED, loadingSnapshot, MODE) await page.getByRole('button', { name: 'Stop generating' }).click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') @@ -231,7 +238,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'cancel.expected.md', 'error-auth.expected.md', 'retry.expected.md', + 'session.jsonl', 'cancel.expected.md', 'loading.expected.md', 'error-auth.expected.md', 'retry.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/live-interactions/loading.expected.md b/apps/web/tests/snapshots/live-interactions/loading.expected.md new file mode 100644 index 0000000000..ae44167759 --- /dev/null +++ b/apps/web/tests/snapshots/live-interactions/loading.expected.md @@ -0,0 +1,23 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with a one-sentence description" [disabled] + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with a one-sentence description of event sourcing, then stop. {{clock}} +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- button "Edit": + - img +- paragraph: partial +- status: Deep diving... +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Full access"': Full access +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md index f09469c98c..1780e53e48 100644 --- a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - button "2 queued messages" - textbox "Message the agent" - button "Commands": diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index cd211013c8..1811d4d213 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - button "2 queued messages" [disabled] [expanded] - list: - listitem: diff --git a/apps/web/tests/snapshots/queue-actions/ui.expected.md b/apps/web/tests/snapshots/queue-actions/ui.expected.md index 197e1cd622..5786d9d88f 100644 --- a/apps/web/tests/snapshots/queue-actions/ui.expected.md +++ b/apps/web/tests/snapshots/queue-actions/ui.expected.md @@ -12,6 +12,7 @@ - button "Edit": - img - paragraph: partial +- status: Deep diving... - list: - listitem: - text: Edited queue item diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 31c5b2b1dc..a8816f93d7 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -19,6 +19,7 @@ - img - img - text: Ask question waiting +- status: Deep diving... - region "Ready to continue?": - text: Checkpoint - heading "Ready to continue?" [level=2] diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 28068bdc5f..cffb1cc453 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 7c6e36409efd5f2f9224e85a9cbd3a5e515833c1 -README.zh.md: 7661826153bc44ff47a660fa49a4ffd902d93bdd +README.md: b183aab56a10c3a22eeab31f8b1b0e6b5219beba +README.zh.md: af6427774a048de324d57bc9856f77547e9a03cc diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7c6e36409e..b183aab56a 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). +Conversation domain: skeleton (header/tabs/composer/empty state), chat view (grouped step-summary flow, streaming tail isolation, an animated left-to-right gradient `Deep diving...` turn status, per-tool row slot with a bash sample registrant and the todo row), composer dock (session stats sticky with the input), input dock (hairline-separated queue rows plus the todo plan strip), minimal details panel, scope-addressed ConversationService. Contract: api-contracts v3 §7 plus the slot terminal design (store seat / props shares). The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header occupies the top as ordinary column chrome; beneath it a scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7661826153..af6427774a 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 +会话领域:骨架(标题栏/标签页/编辑器/空状态)、聊天视图(分组步骤摘要流、流式尾部隔离、带从左到右动态渐变的 `Deep diving...` 轮次状态、逐工具行 slot 及一个 bash 示例注册方与 todo 行)、编辑器 dock(与输入区一同 sticky 的会话统计行)、输入区 dock(带发丝分界线的队列行加 todo 计划条)、最小详情面板、按 scope 寻址的 ConversationService。契约:api-contracts v3 §7 加 slot 终端设计(store seat/props share)。 常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段会话标题栏以普通列 chrome 占据顶部;其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index 42b5384dfc..55270946ba 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -66,33 +66,45 @@ border-left: 1px solid var(--dsw-alias-border-l2); } -/* Turn loader: one row of four 2.5px pixels (StateDot blue) chasing left to - right with a stepped trail — flat keyframe holds, no tweening. Phase - offsets come from per-rect animation-delay (index * -250ms) set inline - by the component. */ -.turnDots { +/* Turn activity keeps the former loader's one-line footprint. A pale + brand-blue band sweeps from left to right; reduced-motion keeps it static. */ +.turnStatus { align-self: flex-start; flex: none; - display: flex; + display: inline-flex; align-items: center; - /* One message line box: the dots center inside the text line height. */ height: 26px; - /* Same pin as StateDot: ongoing blue has no alias token (business-primary - is the 500 step, not this 450). */ - color: var(--dsw-static-deepseek-450); + font: var(--dsw-font-s-strong-14); + white-space: nowrap; + background: linear-gradient( + 90deg, + var(--dsw-static-deepseek-500) 0%, + var(--dsw-static-deepseek-500) 40%, + var(--dsw-static-deepseek-200) 50%, + var(--dsw-static-deepseek-500) 60%, + var(--dsw-static-deepseek-500) 100% + ); + background-position: 100% 0; + background-size: 250% 100%; + background-clip: text; + color: transparent; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: dsh-turn-status-shimmer 1.8s linear infinite; } -.turnDotCell { - fill: currentColor; - opacity: 0.15; - animation: dsh-turn-dots-chase 1s infinite; +@keyframes dsh-turn-status-shimmer { + to { + background-position: 0 0; + } } -@keyframes dsh-turn-dots-chase { - 0%, 24.9% { opacity: 1; } - 25%, 49.9% { opacity: 0.6; } - 50%, 74.9% { opacity: 0.35; } - 75%, 100% { opacity: 0.15; } +@media (prefers-reduced-motion: reduce) { + .turnStatus { + background-position: 0 0; + background-size: 100% 100%; + animation: none; + } } .hint { diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index d509a2e521..4a8e715bcf 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -204,36 +204,11 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: { ) }) -/** Turn loader: one row of four 2.5px pixels (half a notch above the StateDot - * 2px cell, same blue) chasing left to right with a stepped trail — flat - * keyframe holds, no tweening, no rotation. Phase offsets come from - * per-rect animation-delay. */ -const LOADER_CELLS = [0, 5, 10, 15] as const - -function TurnDots() { +/** Turn-level model activity label retained across first-token, tool, and streaming phases. */ +function TurnStatus() { return ( - /* The wrapper is a 26px line box (message line height) so the loader - occupies one text line and centers the dots inside it. */ - {!atBottom && (
diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 0da60c036d..5bdaa43c51 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -107,6 +107,10 @@ border-radius: 8px; } +.row + .row { + box-shadow: inset 0 1px 0 var(--dsw-alias-border-l1); +} + .preview, .editor { flex: 1 1 auto; diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index ca81d225a9..f691e25442 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Profiler } from 'react' -import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { act, cleanup, fireEvent, render, within } from '@testing-library/react' import type { AssistantMessageNode, CommandNode, ConversationNode, ConversationSnapshot, ModelRetryNode, RunningToolCall, SessionId, SessionListState, ToolResultNode, @@ -254,16 +254,16 @@ describe('ChatView', () => { } as const satisfies ConversationNode const h = makeHarness({ nodes: [user(1, 'try'), retryNode], running: true }) const view = render() - const disclosure = view.container.querySelector('details') - expect(disclosure?.dataset.active).toBe('true') - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') + const disclosure = view.container.querySelector('details') as HTMLDetailsElement + expect(disclosure.dataset.active).toBe('true') + expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(1/2) · 1s') act(() => { h.set({ nodes: [user(1, 'try'), retryNode, nextRetry] }) }) - expect(view.getAllByRole('status')).toHaveLength(1) + expect(within(disclosure).getAllByRole('status')).toHaveLength(1) expect(view.container.querySelector('details')).toBe(disclosure) - expect(view.getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') + expect(within(disclosure).getByRole('status').textContent).toBe('正在重试模型请求(2/2) · 1s') act(() => { h.set({ @@ -277,14 +277,15 @@ describe('ChatView', () => { running: false, }) }) - expect(disclosure?.dataset.active).toBeUndefined() - expect(view.getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') + expect(disclosure.dataset.active).toBeUndefined() + expect(within(disclosure).getByRole('status').textContent).toBe('已重试模型请求(2/2) · 1s') act(() => { h.set({ nodes: [user(1, 'try'), { ...retry(6), retryState: 'cancelled' }], running: true }) }) - expect(disclosure?.dataset.active).toBeUndefined() - expect(view.getByRole('status').textContent).toContain('重试已取消') + const cancelledDisclosure = view.container.querySelector('details') as HTMLDetailsElement + expect(cancelledDisclosure.dataset.active).toBeUndefined() + expect(within(cancelledDisclosure).getByRole('status').textContent).toContain('重试已取消') }) it('the expanded row Inspect pill hands the call id to inspectCall', () => { @@ -448,6 +449,7 @@ describe('ChatView', () => { const view = render() expect(view.container.querySelector('[data-state="running"]')).not.toBeNull() expect(view.getByText('cmd-r1')).toBeTruthy() + expect(view.getByRole('status').textContent).toBe('Deep diving...') }) it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {