diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml index 3524737277..d9b4a04671 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.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 .agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md -2026-07-29-addressable-queue-operations.md: 28570be7a2a520fd8293a73526302122740823ca -2026-07-29-addressable-queue-operations.zh.md: eb62d45c6572cd993fedf982d6c86e2ff19787a6 +2026-07-29-addressable-queue-operations.md: 7a08b889c958e583dc430d33a1855fe3725f3d48 +2026-07-29-addressable-queue-operations.zh.md: 701b028c7494fd7cb608d05a5d170c9075b155d7 diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md index 28570be7a2..7a08b889c9 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.md @@ -18,7 +18,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M **Queue addresses require a live Agent.** `session.updateQueue` queries only the mounted Agent registry and never resumes a cold session: an `InboxItemId` is process-local and cannot name work after restart or disposal. A missing Agent and a driver-claimed occurrence both return `queue-item-not-found`. -**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" Queued"` header that expands or collapses the complete list. The header exposes `aria-expanded`. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. +**Web actions address Queue only.** The Host excludes pending steering from `session/queue`; steering retains its existing durable transcript path after consumption. QueueDock hides while empty, renders one pending occurrence directly, and defaults two or more occurrences to a collapsed `" 条排队消息"` header that expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Visible rows expose edit and delete, but no send-now control. The UI derives queue row and mutation types from the runtime `SessionFace` contract rather than importing the connection plugin, so plugin cooperation continues through services and snapshots. Edit is available only when all content blocks are text; the editor cannot silently drop non-text blocks. An editing row exposes only save and cancel, with Enter and Escape as their keyboard equivalents. Delete removes the exact occurrence. ## Alternatives considered @@ -34,7 +34,7 @@ The Web queue rendered pending messages but could not edit or delete one row. `M ## Verification -AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios expand the queue and drive its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. +AgentLoop contract tests hold prompt admission while editing and removing exact queued occurrences, reject mutations of steering occurrences, and verify the resulting independent turn and terminal lifecycle events. Host schema and proxy tests cover queued-only authoritative snapshots, synchronous re-entrant mutation order, reconnect, cold-Agent rejection, typed not-found errors, and the RPC transport. Client runtime and QueueDock tests cover non-optimistic projection, single-row presentation, default multi-row collapse, interaction-forced visibility, reset after emptying, expansion, text-only editing, save and cancel affordances, removal, retirement races, and disabled mixed-content editing. Keyless browser scenarios capture the default collapsed header before expanding the queue and driving its exposed edit and delete actions through the built Web composition and real HTTP/SSE wire. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md index eb62d45c65..701b028c74 100644 --- a/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-addressable-queue-operations.zh.md @@ -18,7 +18,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 **Queue 寻址要求 Agent 存活。** `session.updateQueue` 只查询已挂载的 Agent 注册表,绝不恢复冷会话:`InboxItemId` 属于进程本地标识,无法在重启或资源释放后继续指向工作。Agent 缺失和单次入队项已被驱动器认领这两种情况都返回 `queue-item-not-found`。 -**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" Queued"` 表头。表头暴露 `aria-expanded`。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 +**Web 操作只面向 Queue。** Host 从 `session/queue` 中排除待处理 steering;steering 消费后仍沿用既有的持久 transcript(文本记录)路径。QueueDock 在队列为空时隐藏,只有一个待处理项时直接渲染该行,存在两个或更多待处理项时则默认收起为可展开或收起完整列表的 `" 条排队消息"` 表头。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。可见行暴露编辑和删除操作,不提供立即发送控件。UI 从运行时 `SessionFace` 契约派生队列行与变更类型,而不是导入连接插件,因此插件仍通过服务和快照协作。仅当所有内容块都是文本时才提供编辑功能;编辑器不能静默丢弃非文本块。编辑中的行只展示保存和取消操作,对应的键盘操作分别是 Enter 和 Escape。删除会移除对应的精确入队项。 ## 考虑过的替代方案 @@ -34,7 +34,7 @@ Web 队列能够渲染待处理消息,但无法编辑或删除其中某一行 ## 验证 -AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 +AgentLoop 契约测试会在编辑和移除精确 queued 入队项时阻塞提示词接纳,拒绝对 steering 入队项的变更,并验证所得独立轮次及终态生命周期事件。Host schema 与代理测试覆盖仅含 queued 项的权威快照、同步可重入变更顺序、重连、拒绝冷 Agent、类型化 not-found 错误和 RPC 传输。客户端运行时和 QueueDock 测试覆盖非乐观投影、单行呈现、多行默认收起、交互期间强制保持可见、清空后重置、展开、仅文本编辑、保存与取消入口、移除、退役竞态,以及禁用混合内容编辑。无密钥浏览器场景会先捕获默认收起的表头,再展开队列,并通过构建后的 Web 组合和真实 HTTP/SSE 协议操作其公开的编辑和删除。 ## 后果 diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index 025747c37d..1c2ca271aa 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -20,6 +20,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.meta.url)) const FIXTURE = fileURLToPath(new URL('./snapshots/live-interactions/session.jsonl', import.meta.url)) +const COLLAPSED_EXPECTED = join(SNAPSHOT_DIR, 'collapsed.expected.md') const EDITING_EXPECTED = join(SNAPSHOT_DIR, 'editing.expected.md') const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') const MODE = webSnapshotMode() @@ -80,9 +81,15 @@ describe('web e2e: queue row actions', () => { await input.fill(text) await input.press('Enter') } - const queueHeader = page.getByRole('button', { name: '2 Queued' }) + const queueHeader = page.getByRole('button', { name: '2 条排队消息' }) await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 }) .toBe('false') + const collapsedSnapshot = await captureStableAria( + page, + '[class*="centerCol"]', + scaffold.workspaceCwd, + ) + await compareOrRefreshGolden(COLLAPSED_EXPECTED, collapsedSnapshot, MODE) await queueHeader.click() await expect.poll( () => page.getByRole('button', { name: '删除排队消息' }).count(), @@ -116,6 +123,9 @@ describe('web e2e: queue row actions', () => { }, 120_000) it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['editing.expected.md', 'ui.expected.md']) + await assertFixtureInventory( + SNAPSHOT_DIR, + ['collapsed.expected.md', 'editing.expected.md', 'ui.expected.md'], + ) }) }) diff --git a/apps/web/tests/snapshots/queue-actions/collapsed.expected.md b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md new file mode 100644 index 0000000000..cdbf6fc64b --- /dev/null +++ b/apps/web/tests/snapshots/queue-actions/collapsed.expected.md @@ -0,0 +1,24 @@ +- 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 "复制": + - img +- button "在新对话中分支": + - img +- button "编辑": + - img +- paragraph: partial +- button "2 条排队消息" +- textbox "Message the agent" +- button "Add attachment": + - img +- 'button "Access mode, current: Danger Full Access"': Danger Full Access +- button "Plan mode off, press to turn on": Plan off +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "Stop generating" diff --git a/apps/web/tests/snapshots/queue-actions/editing.expected.md b/apps/web/tests/snapshots/queue-actions/editing.expected.md index 0bd0f3f472..cf287b5006 100644 --- a/apps/web/tests/snapshots/queue-actions/editing.expected.md +++ b/apps/web/tests/snapshots/queue-actions/editing.expected.md @@ -12,7 +12,7 @@ - button "编辑": - img - paragraph: partial -- button "2 Queued" [expanded] +- button "2 条排队消息" [disabled] [expanded] - list: - listitem: - text: Queue item to remove diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index ff60af0eed..157999ceef 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: 18c8d86cb8dc375815afab8cc5275691bfdd3d86 -README.zh.md: 5b69be13980ecc25302e28b9ecda32d57f11b782 +README.md: 602befcc7b0ff49701b4b447fd80da0237034fdc +README.zh.md: fa24deaa9638d4397ebce4cca072814257fa2bc3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 18c8d86cb8..602befcc7b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.to The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. -`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" Queued"` header whose button expands or collapses the complete list. The header exposes `aria-expanded`; each visible row remains a single-line preview with its exact-occurrence edit and delete actions. +`QueueDock` is the terminal input-dock entry at `order: 20`. It hides while empty, renders one pending row directly, and defaults two or more rows to a collapsed `" 条排队消息"` header whose button expands or collapses the complete list. The header exposes `aria-expanded` and `aria-controls`; the expanded list scrolls within a 180px height bound. An active edit or mutation keeps its rows visible, and emptying the queue restores the collapsed default for the next queue. Each visible row remains a single-line preview with its exact-occurrence edit and delete actions. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 5b69be1398..fa24deaa96 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -18,7 +18,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 -`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" Queued"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded`;每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 +`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `" 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 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 a45c8c02c2..46cc018179 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -20,6 +20,8 @@ padding-top: 2px; border-radius: 14px 14px 0 0; background: var(--dsw-specific-tip); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } .panel::after { @@ -53,6 +55,10 @@ outline-offset: -2px; } +.header:disabled { + cursor: default; +} + .count { flex: 1 1 auto; min-width: 0; @@ -72,6 +78,8 @@ } .list { + max-height: 180px; + overflow-y: auto; margin: 0; padding: 0; list-style: none; diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index d20e25d8d2..67de5d0796 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -4,7 +4,7 @@ // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. import type { Context } from 'cordis' -import { useEffect, useState } from 'react' +import { useEffect, useId, useState } from 'react' import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { @@ -32,13 +32,19 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { const [editing, setEditing] = useState<{ id: QueueItemId; text: string } | null>(null) const [busy, setBusy] = useState(null) const [collapsed, setCollapsed] = useState(true) + const listId = useId() useEffect(() => { + if (queue.length === 0 && !collapsed) setCollapsed(true) if (editing !== null && !queue.some(row => row.id === editing.id)) setEditing(null) - }, [editing, queue]) + }, [collapsed, editing, queue]) if (queue.length === 0) return null + const interactionActive = editing !== null || busy !== null + const expanded = !collapsed || interactionActive + const listVisible = queue.length === 1 || expanded + const applyAction = async ( itemId: QueueItemId, action: QueueAction, @@ -72,103 +78,103 @@ export function QueueDock({ useSession, updateQueue, notify }: QueueDockProps) { )} - {(queue.length === 1 || !collapsed) && ( -
    - {queue.map(row => ( -
  • + - )} + : ( + <> + + + + )} + +
  • + ))} +
) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 42749ec329..8a8e01cc91 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -82,12 +82,13 @@ describe('QueueDock', () => { const single = snapshotWith([row('i-1', 'one')]) const source = liveSession(single) const view = render() - expect(view.queryByRole('button', { name: '1 Queued' })).toBeNull() + expect(view.queryByRole('button', { name: '1 条排队消息' })).toBeNull() expect(view.getByText('one')).toBeTruthy() act(() => { source.push(snapshotWith([row('i-1', 'one'), row('i-2', 'two')])) }) - const header = view.getByRole('button', { name: '2 Queued' }) + const header = view.getByRole('button', { name: '2 条排队消息' }) expect(header.getAttribute('aria-expanded')).toBe('false') + expect(document.getElementById(header.getAttribute('aria-controls')!)).toBeTruthy() expect(view.queryByText('one')).toBeNull() expect(view.queryByText('two')).toBeNull() @@ -101,6 +102,74 @@ describe('QueueDock', () => { expect(view.queryByText('one')).toBeNull() }) + it('keeps an active single-row editor visible when another item arrives', () => { + const single = snapshotWith([row('i-edit', 'before')]) + const source = liveSession(single) + const view = render() + + fireEvent.click(view.getByLabelText('编辑排队消息')) + fireEvent.change(view.getByLabelText('编辑排队消息'), { target: { value: 'draft' } }) + act(() => { + source.push(snapshotWith([row('i-edit', 'before'), row('i-2', 'second')])) + }) + + const header = view.getByRole('button', { name: '2 条排队消息' }) + expect(header).toHaveProperty('disabled', true) + expect(header.getAttribute('aria-expanded')).toBe('true') + expect(view.getByRole('textbox', { name: '编辑排队消息' })).toHaveProperty('value', 'draft') + expect(view.getByText('second')).toBeTruthy() + + fireEvent.click(view.getByLabelText('取消编辑')) + expect(header).toHaveProperty('disabled', false) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('second')).toBeNull() + }) + + it('keeps an in-flight row action visible when another item arrives', async () => { + const single = snapshotWith([row('i-remove', 'remove me')]) + const source = liveSession(single) + let finishUpdate: (() => void) | undefined + const updateQueue = vi.fn(() => new Promise((resolve) => { finishUpdate = resolve })) + const view = render( + , + ) + + fireEvent.click(view.getByLabelText('删除排队消息')) + act(() => { + source.push(snapshotWith([row('i-remove', 'remove me'), row('i-2', 'second')])) + }) + + const header = view.getByRole('button', { name: '2 条排队消息' }) + expect(header).toHaveProperty('disabled', true) + expect(header.getAttribute('aria-expanded')).toBe('true') + expect(view.getByText('remove me')).toBeTruthy() + expect(view.getByText('second')).toBeTruthy() + + act(() => { finishUpdate?.() }) + await waitFor(() => { + expect(header).toHaveProperty('disabled', false) + expect(header.getAttribute('aria-expanded')).toBe('false') + }) + }) + + it('defaults a new multi-row queue to collapsed after the prior queue empties', () => { + const first = snapshotWith([row('i-1', 'one'), row('i-2', 'two')]) + const source = liveSession(first) + const view = render() + fireEvent.click(view.getByRole('button', { name: '2 条排队消息' })) + expect(view.getByText('one')).toBeTruthy() + + act(() => { source.push(snapshotWith([])) }) + expect(view.container.innerHTML).toBe('') + act(() => { + source.push(snapshotWith([row('i-3', 'three'), row('i-4', 'four')])) + }) + + const header = view.getByRole('button', { name: '2 条排队消息' }) + expect(header.getAttribute('aria-expanded')).toBe('false') + expect(view.queryByText('three')).toBeNull() + }) + it('renders active actions and disables editing for mixed-content rows', () => { const snap = snapshotWith([ row('i-1', '第一条排队消息'), @@ -108,7 +177,7 @@ describe('QueueDock', () => { ]) const source = liveSession(snap) const { container, getByRole } = render() - fireEvent.click(getByRole('button', { name: '2 Queued' })) + fireEvent.click(getByRole('button', { name: '2 条排队消息' })) expect([...container.querySelectorAll('li')].map(item => item.textContent)) .toEqual(['第一条排队消息', 'image [image]']) expect(container.querySelectorAll('button')).toHaveLength(5) @@ -190,7 +259,7 @@ describe('QueueDock', () => { , ) - fireEvent.click(getByRole('button', { name: '2 Queued' })) + fireEvent.click(getByRole('button', { name: '2 条排队消息' })) fireEvent.click(getAllByLabelText('删除排队消息')[0]!) await waitFor(() => { expect(updateQueue).toHaveBeenCalledWith(iid('i-1'), { kind: 'remove' })