Merge pull request #1147 from deepseek-harness/codex/todo-goal-queue-layout
fix(web): align composer context stack
This commit is contained in:
@@ -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/bug-fix/2026-07-30-composer-context-stack-order.md
|
||||
2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66
|
||||
2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488
|
||||
2026-07-30-composer-context-stack-order.md: 150225ce27fa8459703170622cef754b7694e2fc
|
||||
2026-07-30-composer-context-stack-order.zh.md: f13ffc3c1136707a86ba5636a5b69304a132df01
|
||||
@@ -10,7 +10,7 @@ Goal, Todo, and Queue contribute independently to the same `conversation.input.d
|
||||
|
||||
## Decision
|
||||
|
||||
The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order.
|
||||
The [Todo-first alignment decision](2026-08-02-todo-first-composer-context-order.md) owns the current ascending order. This note retains the stack contract around that order: numeric gaps leave room for future entries to declare their intended position without relying on plugin activation order, and the composer bar follows the list.
|
||||
|
||||
`ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap.
|
||||
|
||||
@@ -18,7 +18,7 @@ The order and overlap are separate contracts. Registration order establishes sem
|
||||
|
||||
## Verification
|
||||
|
||||
Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: Goal–Todo, Todo–Queue, and Queue–Composer.
|
||||
Registration tests pin all three order values. The keyless Queue browser scenario renders Todo, Goal, and Queue together, pins their accessibility order, and checks their visible card edges; focused Goal and Queue scenarios cover their independent states.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -30,4 +30,4 @@ Registration tests pin all three order values. Browser screenshots cover the ful
|
||||
|
||||
## Consequences
|
||||
|
||||
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.
|
||||
The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Todo `0`, Goal `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary.
|
||||
@@ -10,7 +10,7 @@ Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表
|
||||
|
||||
## 决策
|
||||
|
||||
composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。
|
||||
[Todo 优先的对齐决策](2026-08-02-todo-first-composer-context-order.md)规定当前的升序排列。本记录保留围绕该顺序的堆栈契约:数值间隔使未来条目可以声明预期位置,不必依赖插件激活顺序;composer bar 位于列表之后。
|
||||
|
||||
`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。
|
||||
|
||||
@@ -18,7 +18,7 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo
|
||||
|
||||
## 验证
|
||||
|
||||
注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。
|
||||
注册测试固定了三个顺序值。无密钥 Queue 浏览器场景同时渲染 Todo、Goal 和 Queue,固定它们的无障碍顺序,并检查其可见卡片边缘;聚焦的 Goal 与 Queue 场景覆盖各自的独立状态。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
@@ -30,4 +30,4 @@ composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo
|
||||
|
||||
## 后果
|
||||
|
||||
所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。
|
||||
所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Todo `0`、Goal `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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/bug-fix/2026-08-02-todo-first-composer-context-order.md
|
||||
2026-08-02-todo-first-composer-context-order.md: 26962426c763c6911fc61acab09cdb96725922b1
|
||||
2026-08-02-todo-first-composer-context-order.zh.md: a81401ffeb8df5855b584a5727e24067d9fb1e70
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Todo-first composer context order
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-02-todo-first-composer-context-order.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The composer context stack rendered Goal before Todo even though the Harness design orders the current task plan before its ongoing goal and pending Queue. Todo also used the Queue wrapper's 776px width as its visible card width, while Goal and the Queue panel rendered on the shared 752px card column. The result inverted the intended information hierarchy and left Todo wider than both adjacent panels.
|
||||
|
||||
## Decision
|
||||
|
||||
The `conversation.input.dock` list uses one ascending product order: Todo at `0`, Goal at `10`, and Queue at `20`, followed by the composer bar outside the list. Registration order remains the semantic source of truth; the renderer does not hardcode known component ids or repair their order with CSS.
|
||||
|
||||
Todo, Goal, and the visible Queue panel share the 752px card column inside the 800px composer cap. Queue retains a 776px wrapper with 12px transparent inset on each side because that wrapper owns the composer overlap. Todo is a standalone card rather than a wrapper, so its responsive width and maximum width subtract both inset layers directly. Goal uses the same responsive column and caps its inner bar at 752px, preserving matching edges below the desktop cap.
|
||||
|
||||
The [composer stack contract](2026-07-30-composer-context-stack-order.md) continues to own inter-card spacing and Queue's exclusive overlap with the composer. This decision supersedes only that note's Goal-first order.
|
||||
|
||||
## Verification
|
||||
|
||||
Todo and Goal registration tests pin orders `0` and `10`; Queue remains pinned at `20`. The keyless Queue browser scenario renders all three panels concurrently, records their Todo–Goal–Queue accessibility order, and compares their visible bounding boxes at the 1680px desktop baseline and a 640px sub-cap viewport before exercising Queue mutations.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Reorder the known panels inside `ConversationRoot`.** Rejected because `conversation.input.dock` is an extensible ordered list; a hardcoded component inventory would make plugin activation order and rendered order disagree.
|
||||
|
||||
**Use CSS `order` to move Todo visually.** Rejected because accessibility and keyboard order must match the visual hierarchy, and the slot ledger already owns semantic order.
|
||||
|
||||
**Keep Todo at the Queue wrapper width.** Rejected because the Queue wrapper's transparent inset is layout infrastructure for its composer overlap, not part of the visible panel column.
|
||||
|
||||
## Consequences
|
||||
|
||||
The standing task plan appears before the ongoing goal, pending Queue work remains closest to the composer, and all three visible cards share one horizontal edge. Future input-dock plugins choose an explicit position relative to Todo `0`, Goal `10`, and Queue `20`; only Queue owns the terminal wrapper overlap.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Todo 优先的 composer 上下文顺序
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-02-todo-first-composer-context-order.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
composer 上下文堆栈将 Goal 渲染在 Todo 之前,但 Harness 设计稿把当前任务计划排在进行中的目标和待处理 Queue 之前。Todo 还把 Queue 包装层的 776px 宽度用作自身的可见卡片宽度,而 Goal 和 Queue 面板则渲染在共享的 752px 卡片列上。结果既颠倒了预期的信息层级,也让 Todo 比相邻两个面板更宽。
|
||||
|
||||
## 决策
|
||||
|
||||
`conversation.input.dock` 列表采用统一的产品顺序,升序依次为 Todo `0`、Goal `10`、Queue `20`,随后是位于列表外的 composer bar。注册顺序仍是语义真源;渲染器不会硬编码已知组件 id,也不会使用 CSS 修正它们的顺序。
|
||||
|
||||
Todo、Goal 与可见的 Queue 面板共用 800px composer 宽度上限内的 752px 卡片列。Queue 保留 776px 包装层,并在两侧各留 12px 透明内缩,因为该包装层负责与 composer 重叠。Todo 是独立卡片,而非包装层,因此其响应式宽度和最大宽度都会直接扣除两层内缩。Goal 使用相同的响应式卡片列,并将内层横条的宽度上限设为 752px,从而在低于桌面宽度上限时也保持边缘对齐。
|
||||
|
||||
[composer 堆栈契约](2026-07-30-composer-context-stack-order.md)继续规定卡片间距,以及仅限 Queue 与 composer 重叠。本决策只取代该记录中 Goal 优先的顺序。
|
||||
|
||||
## 验证
|
||||
|
||||
Todo 与 Goal 的注册测试分别固定顺序 `0` 和 `10`;Queue 仍固定为 `20`。无密钥 Queue 浏览器场景同时渲染三个面板,记录 Todo–Goal–Queue 的无障碍顺序,并在 1680px 桌面基线和低于宽度上限的 640px 视口下比较其可见边界框,随后再执行 Queue 变更。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**在 `ConversationRoot` 内重新排列已知面板。** 不予采纳,因为 `conversation.input.dock` 是可扩展的有序列表;硬编码的组件清单会使插件激活顺序与渲染顺序不一致。
|
||||
|
||||
**使用 CSS `order` 移动 Todo 的视觉位置。** 不予采纳,因为无障碍顺序和键盘顺序必须与视觉层级一致,而 slot 账本已经负责语义顺序。
|
||||
|
||||
**让 Todo 保持 Queue 包装层的宽度。** 不予采纳,因为 Queue 包装层的透明内缩是其与 composer 重叠所需的布局基础设施,不属于可见面板列。
|
||||
|
||||
## 后果
|
||||
|
||||
当前有效的任务计划显示在进行中的目标之前,待处理 Queue 工作仍最靠近 composer,三张可见卡片共用相同的横向边缘。未来的 input-dock 插件必须相对于 Todo `0`、Goal `10` 和 Queue `20` 选择明确位置;仅 Queue 负责末端包装层与 composer 的重叠。
|
||||
@@ -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-22-docked-web-goal-bar.md
|
||||
2026-07-22-docked-web-goal-bar.md: 30f1d45e80cb2759175948f5683b499720ab50f0
|
||||
2026-07-22-docked-web-goal-bar.zh.md: 4c8481e64d9e5177a962f10ab1d043e761d07545
|
||||
2026-07-22-docked-web-goal-bar.md: 21f200f165acdf370a5896acb8f54afe33a4ee99
|
||||
2026-07-22-docked-web-goal-bar.zh.md: eb8e3edda54207b06e25812a38f969e74f342c34
|
||||
@@ -10,7 +10,7 @@ The web UI had no goal surface at all: the goal stack shipped with model tools,
|
||||
|
||||
## Decision
|
||||
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered second in the composer's input-dock list, after Todo and before Queue. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. Every mutation first acquires a synchronous component-local single-flight latch because React's pending-state render cannot close the same-frame click window. A successful clear also suppresses that exact goal id immediately while the authoritative null projection catches up, so an acknowledged tombstone cannot leave a stale clear control that submits `GOAL_NOT_FOUND`; a failure releases the latch and remains retryable. An effect keyed on the goal's id resets this transient state and drops the edit form when the goal's identity changes, so neither a cleared marker nor a surviving draft can affect the replacement goal.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T
|
||||
|
||||
## 决策
|
||||
|
||||
`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
|
||||
`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第二个条目,位于 Todo 之后、Queue 之前。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。
|
||||
|
||||
可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。每次变更都会先取得一个同步的组件内 single-flight 锁,因为 React 的 pending 状态渲染无法关闭同一帧内的点击窗口。清除成功后还会立即抑制该 goal id,直到权威的 null 投影追上,因此已确认的墓碑不会留下陈旧的清除控件并再次提交 `GOAL_NOT_FOUND`;失败则释放锁,并且仍可重试。一个以目标 id 为键的 effect 会在目标身份变化时重置瞬态状态并丢弃编辑表单,因此无论已清除标记还是存留草稿,都不会影响替换目标。
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/queue-actions', import.m
|
||||
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 LAYOUT_EXPECTED = join(SNAPSHOT_DIR, 'layout.expected.md')
|
||||
const PRESERVED_EXPECTED = join(SNAPSHOT_DIR, 'preserved.expected.md')
|
||||
const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
@@ -172,10 +173,95 @@ describe('web e2e: queue row actions', () => {
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count()).toBe(0)
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('orders Todo before Goal and Queue on one responsive card column', async () => {
|
||||
overrideDir = await mkdtemp(join(tmpdir(), 'dsh-web-context-layout-'))
|
||||
const readyFile = join(overrideDir, '.hang-ready')
|
||||
const overridePath = join(overrideDir, 'replay.override.json')
|
||||
await writeFile(overridePath, JSON.stringify([{ kind: 'hang', readyFile } satisfies ReplayEntry]))
|
||||
|
||||
const sessionEvents: SessionEvent[] = []
|
||||
scaffold = await launchWebScaffold({ replayFixture: FIXTURE, replayOverride: overridePath })
|
||||
scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) })
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-context-layout'))
|
||||
|
||||
const input = page.locator('textarea').first()
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill('/goal Keep the composer context panels aligned')
|
||||
await input.press('Enter')
|
||||
await expect.poll(() => existsSync(readyFile), { timeout: 15_000 }).toBe(true)
|
||||
await page.locator('[data-goal-bar]').waitFor({ timeout: 10_000 })
|
||||
|
||||
const sessions = scaffold.ctx.sessions.list()
|
||||
expect(sessions).toHaveLength(1)
|
||||
sessions[0]!.append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'Confirm the panel order', status: 'completed' },
|
||||
{ content: 'Align the panel widths', status: 'in_progress' },
|
||||
],
|
||||
})
|
||||
await page.locator('[data-testid="todo-panel"]').waitFor({ timeout: 10_000 })
|
||||
|
||||
for (const text of ['Layout queue first', 'Layout queue second']) {
|
||||
await input.fill(text)
|
||||
await input.press('Enter')
|
||||
}
|
||||
const queueHeader = page.getByRole('button', { name: '2 queued messages' })
|
||||
await expect.poll(() => queueHeader.getAttribute('aria-expanded'), { timeout: 10_000 })
|
||||
.toBe('false')
|
||||
|
||||
const layoutSnapshot = await captureStableAria(
|
||||
page,
|
||||
'[class*="centerCol"]',
|
||||
scaffold.workspaceCwd,
|
||||
)
|
||||
await compareOrRefreshGolden(LAYOUT_EXPECTED, layoutSnapshot, MODE)
|
||||
|
||||
const expectAlignedContextPanels = async () => {
|
||||
const queuePanelBox = await page.locator('[data-queue-dock] > div').boundingBox()
|
||||
const todoBox = await page.locator('[data-testid="todo-panel"]').boundingBox()
|
||||
const goalBox = await page.locator('[data-goal-bar] > div').boundingBox()
|
||||
expect(queuePanelBox).not.toBeNull()
|
||||
expect(todoBox).not.toBeNull()
|
||||
expect(goalBox).not.toBeNull()
|
||||
expect(todoBox!.y).toBeLessThan(goalBox!.y)
|
||||
expect(goalBox!.y).toBeLessThan(queuePanelBox!.y)
|
||||
expect(todoBox!.x).toBeCloseTo(goalBox!.x, 1)
|
||||
expect(todoBox!.x).toBeCloseTo(queuePanelBox!.x, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(goalBox!.width, 1)
|
||||
expect(todoBox!.width).toBeCloseTo(queuePanelBox!.width, 1)
|
||||
}
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 640, height: 1000 })
|
||||
await expectAlignedContextPanels()
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
|
||||
await queueHeader.click()
|
||||
const removeButtons = page.getByRole('button', { name: 'Remove queued message' })
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(2)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => removeButtons.count(), { timeout: 10_000 }).toBe(1)
|
||||
await removeButtons.first().click()
|
||||
await expect.poll(() => page.locator('[data-queue-dock]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Clear goal' }).click()
|
||||
await expect.poll(() => page.locator('[data-goal-bar]').count(), { timeout: 10_000 }).toBe(0)
|
||||
await page.getByRole('button', { name: 'Stop generating' }).click()
|
||||
await settled
|
||||
|
||||
expect(turnEndReasons(sessionEvents)).toEqual(['aborted'])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 120_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(
|
||||
SNAPSHOT_DIR,
|
||||
['collapsed.expected.md', 'editing.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
['collapsed.expected.md', 'editing.expected.md', 'layout.expected.md', 'preserved.expected.md', 'ui.expected.md'],
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "workspace" [disabled]
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- 'button "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"':
|
||||
- img
|
||||
- img
|
||||
- text: "goal Goal created Status: active Objective: Keep the composer context panels aligned Rounds: 0/256 Activation: armed Commands: /goal edit <objective>, /goal pause, /goal clear"
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- button "Context injection":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection
|
||||
- paragraph: partial
|
||||
- status: Deep diving...
|
||||
- region "To-dos":
|
||||
- button "To-dos 1/2 tasks · 1 in progress"
|
||||
- img
|
||||
- text: Ongoing Goal Keep the composer context panels aligned
|
||||
- button "Pause goal":
|
||||
- img
|
||||
- button "Edit goal":
|
||||
- img
|
||||
- button "Clear goal":
|
||||
- img
|
||||
- button "2 queued messages"
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "Stop generating"
|
||||
@@ -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: 65e4542e622713e2cd120906926fc0601ef280bd
|
||||
README.zh.md: 4828ad214bb5ec0a9921307dd01dcc282910b330
|
||||
README.md: d92202e6dc2b1003db0cdf97910746a0de133357
|
||||
README.zh.md: a75ee75a5fb2b3d8e5278283b6b6da15bf9c02cd
|
||||
@@ -32,7 +32,7 @@ A `grep`/`glob` call declaring the `search` render intent renders its result inl
|
||||
|
||||
Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '<tool>', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); the bash sample is the third-party-posture exemplar. Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders).
|
||||
|
||||
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 (`<done>/<total> 已完成 · <active item>` 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: -1` — above the queue rows — 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 `"<done>/<total> tasks · <n> 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.
|
||||
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 (`<done>/<total> 已完成 · <active item>` 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: 0` — before 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 `"<done>/<total> tasks · <n> 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 `"<n> 条排队消息"` 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.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
|
||||
审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,其中 kebab-case 预设名渲染为 Title Case 标签;普通安全预设会立即经输入栏注入的 `command` 回调提交 `/permission <preset>`,而 `danger-full-access` 在界面中显示为 `Full access`,选择后先打开页面内的 Modal 风险确认。用户勾选确认项前启用按钮始终不可用;取消、Escape、关闭按钮与点击遮罩都不会提交命令。
|
||||
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 0` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 与 Queue 之前),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · <n> in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。
|
||||
|
||||
`QueueDock` 是 `order: 20` 的末端 input-dock 条目。队列为空时隐藏;只有一个待处理项时直接渲染该行;存在两个或更多待处理项时,默认收起为 `"<n> 条排队消息"` 表头,其按钮可展开或收起完整列表。表头暴露 `aria-expanded` 和 `aria-controls`;展开后的列表以 180px 为高度上限,并可滚动。存在进行中的编辑或变更时,列表行会保持可见;队列清空后,下一次出现队列时会恢复默认收起状态。每条可见行仍是单行预览,并提供针对精确单次入队项的编辑和删除操作。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* Todo strip in the composer context stack (Figma 9:959): tip surface,
|
||||
14px radius, status icons + secondary item labels. It shares the composer
|
||||
card geometry and adds the dock inset on both sides. */
|
||||
/* Todo strip in the composer context stack (Figma 1236:32276): tip surface,
|
||||
14px radius, status icons + secondary item labels. Its visible card aligns
|
||||
with the GoalBar and the Queue panel inside their shared dock column. */
|
||||
|
||||
.root {
|
||||
box-sizing: border-box;
|
||||
@@ -12,11 +12,15 @@
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
max-width: calc(
|
||||
var(--dsh-composer-card-max-width) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
|
||||
@@ -138,10 +138,10 @@ export const todoDockEntry = {
|
||||
name: 'conversation-todo-dock',
|
||||
inject: ['slots', 'conversation'],
|
||||
/**
|
||||
* Register the plan strip between the goal and queue entries (order 10).
|
||||
* Register the plan strip before the goal and queue entries (order 0).
|
||||
* @param ctx - registrant context (disposal rides ctx.effect inside slots.register).
|
||||
*/
|
||||
apply(ctx: Context): void {
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
},
|
||||
}
|
||||
@@ -99,12 +99,12 @@ describe('TodoDock', () => {
|
||||
expect(screen.queryByTestId('todo-panel')).toBeNull()
|
||||
})
|
||||
|
||||
it('registers between the goal and queue entries', () => {
|
||||
it('registers before the goal and queue entries', () => {
|
||||
expect(todoDockEntry.name).toBe('conversation-todo-dock')
|
||||
expect(todoDockEntry.inject).toEqual(['slots', 'conversation'])
|
||||
const register = vi.fn()
|
||||
todoDockEntry.apply({ slots: { register } } as never)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10, locale: NS }, TodoDock)
|
||||
expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 0, locale: NS }, TodoDock)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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-goal/README.md
|
||||
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
|
||||
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448
|
||||
README.md: 3da9d97c801a0a742de2601e5261c09ba193cf33
|
||||
README.zh.md: c2474fc6ef8d0c990da4b4eaff79d56baf3180cf
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第二张独立卡片(order 10,位于 Todo 之后、Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
|
||||
|
||||
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
/* GoalBar: the first standalone card in the composer context stack (Figma
|
||||
9:939). Its 752px column matches Todo and the Queue panel. */
|
||||
/* GoalBar: the second standalone card in the composer context stack (Figma
|
||||
1236:32276). Its 752px column matches Todo and the Queue panel. */
|
||||
|
||||
.dock {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
padding: 0 44px;
|
||||
width: calc(
|
||||
100% -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-side-clearance) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset) -
|
||||
var(--dsh-composer-dock-inset)
|
||||
);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.bar {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function apply(ctx: ClientContext): void {
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.dock',
|
||||
id: 'goal',
|
||||
order: 0,
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId): GoalBarActions => ({
|
||||
onEdit: async (objective) => {
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('ui-goal browser plugin', () => {
|
||||
it('registers the GoalBar dock entry with the documented id and order', async () => {
|
||||
const b = bench()
|
||||
await b.fiber.await()
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 0, locale: 'goal' })
|
||||
expect(b.entry()).toMatchObject({ id: 'goal', order: 10, locale: 'goal' })
|
||||
expect(b.entry()?.inject).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ async function inspectChild(
|
||||
return { kind: 'diagnostic', id: childId, reason: 'corrupt' }
|
||||
}
|
||||
// The length-one branch proves this exact-read sequence exists.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
// oxlint-disable-next-line typescript/no-non-null-assertion
|
||||
const seq = descriptorSeqs[0]!
|
||||
const window = await runListingQuery(
|
||||
() => query.readEvent({ sessionId: childId, seq }, signal),
|
||||
|
||||
Reference in New Issue
Block a user