From 83644b6b37290714ec80acf6b3127163fbc8adc0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 24 Oct 2026 17:17:54 +0800 Subject: [PATCH 1/2] fix(ui-goal): offer resume for a blocked goal The goal service and /goal resume accept blocked goals, and editing keeps the phase blocked, but the Web goal bar showed only edit and clear, so a Web user could not continue a blocked goal after editing it. Refs #1425 --- apps/web/tests/goal-bar.e2e.ts | 29 ++++++++++++++++++- packages/client/ui-goal/README.i18n.yaml | 8 ++--- packages/client/ui-goal/README.md | 3 +- packages/client/ui-goal/README.zh.md | 3 +- .../client/ui-goal/src/client/GoalBar.tsx | 6 ++-- .../ui-goal/tests/goalbar.client.spec.tsx | 15 ++++++++++ 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index a93c9d7c13..953613af8d 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -2,7 +2,7 @@ // The command creates a real projected goal in a real Host session. The // goldens pin the active and disarmed strips, while the clear gesture proves // the acknowledged tombstone leaves neither stale chrome nor a -// duplicate-mutation error. +// duplicate-mutation error. A blocked goal stays resumable after an edit. import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -108,6 +108,33 @@ describe('web e2e: goal bar clear convergence', () => { expect(tripwire.warnings).toEqual([]) }, 60_000) + it('resumes a blocked goal after its objective is edited', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-blocked-resume')) + const input = page.locator('[data-composer-input]') + await input.fill('/goal retry after a blocker') + await input.press('Enter') + const bar = page.locator('[data-goal-bar]') + await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 }) + + const agent = scaffold.ctx.agents.list()[0]! + const active = scaffold.ctx.goals.get(agent)! + scaffold.ctx.goals.block(agent, active, { code: 'stalled', message: 'No progress in 3 rounds' }) + await bar.getByText('Blocked Goal').waitFor({ timeout: 10_000 }) + + await bar.getByRole('button', { name: 'Edit goal' }).click() + await bar.getByRole('textbox', { name: 'Goal objective' }).fill('retry with the edited plan') + await bar.getByRole('button', { name: 'Save goal' }).click() + await bar.getByText('retry with the edited plan').waitFor({ timeout: 10_000 }) + expect(scaffold.ctx.goals.get(agent)?.phase).toBe('blocked') + + await bar.getByRole('button', { name: 'Resume goal' }).click() + await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 }) + expect(scaffold.ctx.goals.get(agent)).toMatchObject({ + phase: 'active', activation: 'armed', objective: 'retry with the edited plan', + }) + expect(tripwire.pageErrors).toEqual([]) + }, 60_000) + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'inactive.expected.md']) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 0d819456e1..8f4a64352a 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-goal/use-this-package: - en: 4f6e6353d793a0de - zh: cde6e470f7dd9057 + en: 7f6620a84a087d07 + zh: 80071126cc64b3e5 /deepseek-ai-dsh-client-ui-goal/use-this-package/the-command-input-bubble: en: 05f0360b6c6dd3e9 zh: d14c3f15dc4cea5b @@ -36,8 +36,8 @@ en: 68c9a2a057acb6cf zh: baba87876e432666 /deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work: - en: bbb97ea1b94eae0f - zh: 8e6b0e0516746dcf + en: aef5fed87717b8fa + zh: d66463310dafcc87 /deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index ecdd666241..243fdff641 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -25,7 +25,7 @@ The Web GUI goal surface shows both the durable goal state and its current proce ## Use this package -Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed or paused goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up. +Mount this plugin alongside `ui-conversation` and the goal domain package; the strip then appears as the second card in the composer-context stack (after Todo, before Queue) whenever the session has a goal. Todo and Goal use the same panel elevation above the composer. An armed active goal offers pause; an active-but-disarmed, paused, or blocked goal offers resume; edit rewrites the objective in the ui-primitives `InlineEditor`, so multi-line objectives survive editing; clear removes the goal and suppresses the strip until the projection catches up. ### The command-input bubble @@ -79,6 +79,7 @@ None unless the queued goal context is admitted. An admitted context extends the These limits define the current goal surface. They are current package constraints, not a goal-domain comparison or a task backlog. - **Preset-independent host state** — switching an active session to `minimal` leaves its host-owned goal intact. `/goal` and goal tools disappear, while this strip can still edit, pause, resume, or clear the goal. +- **No round-budget control** — the strip cannot raise `maxGoalRounds`. Resuming a goal that used every round, such as one blocked by its round limit, fails with an inline error. ### Dev Note diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index e3c8ef955e..87fd742f0b 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -25,7 +25,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 ## 使用本包 -与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed 或 paused 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。 +与 `ui-conversation` 及 goal 领域包一起挂载本插件;只要会话存在目标,条带就会作为 composer 上下文堆栈的第二张卡片出现(位于 Todo 之后、Queue 之前)。Todo 与 Goal 使用相同的面板 elevation,使其位于 composer 层级之上。已 armed 的 active goal 提供暂停动作;active-but-disarmed、paused 或 blocked 的 goal 提供恢复;编辑在 ui-primitives 的 `InlineEditor` 中重写目标文本,多行目标编辑后仍保留换行;清除移除目标,并在投影追上之前抑制条带。 ### 指令输入气泡 @@ -79,6 +79,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 这些限制界定了当前 goal 界面。它们是当前包约束,不是 goal 领域对比或任务积压。 - **Host 状态与 preset 无关**——把活跃会话切换到 `minimal` 后,Host 拥有的 goal 仍会保留。`/goal` 与 goal 工具会消失,但该条带仍可编辑、暂停、恢复或清除 goal。 +- **无轮次预算控制**——该条带无法提高 `maxGoalRounds`。恢复已用完全部轮次的 goal(例如因轮次上限而受阻的 goal)会失败,并显示行内错误。 ### 开发备注 diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index a5358f39ac..0dcf836790 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -1,8 +1,8 @@ /** * GoalBar: the goal indicator docked above the message composer (input dock * strip). A present goal shows a goal glyph, a phase label, the truncated - * objective, and icon actions — resume when active-disarmed or paused, edit - * (inline form in the same strip), and clear. Goal creation lives on the + * objective, and icon actions — resume when active-disarmed, paused, or + * blocked, edit (inline form in the same strip), and clear. Goal creation lives on the * `/goal` command, not here: loading (undefined), no goal (null), and complete * goals render nothing. Durable state arrives as the projected whole snapshot; * process-local activation arrives through the injected activation hook. @@ -128,7 +128,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined const label = goal.phase === 'active' ? activeLabel(activation, t) : t(PHASE_LABELS[goal.phase]) - const showResume = goal.phase === 'paused' + const showResume = goal.phase === 'paused' || goal.phase === 'blocked' || (goal.phase === 'active' && activation === 'disarmed') return (
diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index 2d018de60b..7ce90a8525 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -207,6 +207,21 @@ describe('GoalBar', () => { expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds') }) + it('blocked goal: offers resume after the objective is edited', async () => { + const actions = makeActions() + const { rerender } = render() + fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) + fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'Ship the fix' } }) + fireEvent.click(screen.getByRole('button', { name: '保存目标' })) + await waitFor(() => { expect(screen.queryByRole('textbox')).toBeNull() }) + expect(actions.onEdit).toHaveBeenCalledWith('Ship the fix') + + rerender() + fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) + expect(actions.onResume).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: '暂停目标' })).toBeNull() + }) + it('blocked goal without a reason carries no tooltip', () => { const actions = makeActions() render() From 685b4fc5b3cc9b8407bc7a53e53ec91a15a046a0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Sat, 24 Oct 2026 18:01:29 +0800 Subject: [PATCH 2/2] test(ui-goal): pin the blocked goal strip and simplify resume visibility --- .../tests/expected/goal-bar/blocked.expected.md | 4 ++++ apps/web/tests/goal-bar.e2e.ts | 17 +++++++++++++++-- packages/client/ui-goal/README.i18n.yaml | 4 ++-- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- packages/client/ui-goal/src/client/GoalBar.tsx | 9 ++++----- .../ui-goal/tests/goalbar.client.spec.tsx | 13 +++---------- 7 files changed, 30 insertions(+), 21 deletions(-) create mode 100644 apps/web/tests/expected/goal-bar/blocked.expected.md diff --git a/apps/web/tests/expected/goal-bar/blocked.expected.md b/apps/web/tests/expected/goal-bar/blocked.expected.md new file mode 100644 index 0000000000..dbfcf5563b --- /dev/null +++ b/apps/web/tests/expected/goal-bar/blocked.expected.md @@ -0,0 +1,4 @@ +- text: Blocked Goal retry with the edited plan +- button "Resume goal" +- button "Edit goal" +- button "Clear goal" diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index 953613af8d..132d44569b 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -18,6 +18,7 @@ import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureS const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url)) const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md') const INACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'inactive.expected.md') +const BLOCKED_EXPECTED = join(SNAPSHOT_DIR, 'blocked.expected.md') const OVERLAY = fileURLToPath(new URL('./goal-bar.overlay.yml', import.meta.url)) const MODE = webSnapshotMode() @@ -110,22 +111,33 @@ describe('web e2e: goal bar clear convergence', () => { it('resumes a blocked goal after its objective is edited', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-blocked-resume')) + // `/goal ` refuses to replace an unfinished goal, so start from none. + for (const existing of scaffold.ctx.agents.list()) { + const current = scaffold.ctx.goals.get(existing) + if (current !== undefined) scaffold.ctx.goals.clear(existing, current) + } const input = page.locator('[data-composer-input]') await input.fill('/goal retry after a blocker') await input.press('Enter') const bar = page.locator('[data-goal-bar]') await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 }) - const agent = scaffold.ctx.agents.list()[0]! + const agents = scaffold.ctx.agents.list() + expect(agents).toHaveLength(1) + const agent = agents[0]! const active = scaffold.ctx.goals.get(agent)! scaffold.ctx.goals.block(agent, active, { code: 'stalled', message: 'No progress in 3 rounds' }) await bar.getByText('Blocked Goal').waitFor({ timeout: 10_000 }) + expect(await bar.getByRole('button', { name: 'Resume goal' }).count()).toBe(1) await bar.getByRole('button', { name: 'Edit goal' }).click() await bar.getByRole('textbox', { name: 'Goal objective' }).fill('retry with the edited plan') await bar.getByRole('button', { name: 'Save goal' }).click() + await bar.getByRole('button', { name: 'Edit goal' }).waitFor({ timeout: 10_000 }) await bar.getByText('retry with the edited plan').waitFor({ timeout: 10_000 }) expect(scaffold.ctx.goals.get(agent)?.phase).toBe('blocked') + const blocked = await captureStableAria(page, '[data-goal-bar]', scaffold.workspaceCwd) + await compareOrRefreshGolden(BLOCKED_EXPECTED, blocked, MODE) await bar.getByRole('button', { name: 'Resume goal' }).click() await bar.getByRole('button', { name: 'Pause goal' }).waitFor({ timeout: 10_000 }) @@ -133,9 +145,10 @@ describe('web e2e: goal bar clear convergence', () => { phase: 'active', activation: 'armed', objective: 'retry with the edited plan', }) expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) }, 60_000) it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { - await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'inactive.expected.md']) + await assertFixtureInventory(SNAPSHOT_DIR, ['active.expected.md', 'blocked.expected.md', 'inactive.expected.md']) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 8f4a64352a..815b11e50f 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -36,8 +36,8 @@ en: 68c9a2a057acb6cf zh: baba87876e432666 /deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work: - en: aef5fed87717b8fa - zh: d66463310dafcc87 + en: 7b69fe29ac31722e + zh: eef4e78fa12b4ff3 /deepseek-ai-dsh-client-ui-goal/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index 243fdff641..986e5c6811 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -79,7 +79,7 @@ None unless the queued goal context is admitted. An admitted context extends the These limits define the current goal surface. They are current package constraints, not a goal-domain comparison or a task backlog. - **Preset-independent host state** — switching an active session to `minimal` leaves its host-owned goal intact. `/goal` and goal tools disappear, while this strip can still edit, pause, resume, or clear the goal. -- **No round-budget control** — the strip cannot raise `maxGoalRounds`. Resuming a goal that used every round, such as one blocked by its round limit, fails with an inline error. +- **No round-budget control** — the strip cannot raise `maxGoalRounds`. Resuming a goal that used every round, such as one blocked by its round limit, fails with an inline error; ask the agent to raise the goal's round limit first. ### Dev Note diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index 87fd742f0b..36e47153d9 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -79,7 +79,7 @@ Web GUI 的 goal 界面同时显示持久 goal 状态及当前的进程本地激 这些限制界定了当前 goal 界面。它们是当前包约束,不是 goal 领域对比或任务积压。 - **Host 状态与 preset 无关**——把活跃会话切换到 `minimal` 后,Host 拥有的 goal 仍会保留。`/goal` 与 goal 工具会消失,但该条带仍可编辑、暂停、恢复或清除 goal。 -- **无轮次预算控制**——该条带无法提高 `maxGoalRounds`。恢复已用完全部轮次的 goal(例如因轮次上限而受阻的 goal)会失败,并显示行内错误。 +- **无轮次预算控制**——该条带无法提高 `maxGoalRounds`。恢复已用完全部轮次的 goal(例如因轮次上限而受阻的 goal)会失败,并显示行内错误;需先请 agent 提高该 goal 的轮次上限。 ### 开发备注 diff --git a/packages/client/ui-goal/src/client/GoalBar.tsx b/packages/client/ui-goal/src/client/GoalBar.tsx index 0dcf836790..120538c3c4 100644 --- a/packages/client/ui-goal/src/client/GoalBar.tsx +++ b/packages/client/ui-goal/src/client/GoalBar.tsx @@ -2,9 +2,9 @@ * GoalBar: the goal indicator docked above the message composer (input dock * strip). A present goal shows a goal glyph, a phase label, the truncated * objective, and icon actions — resume when active-disarmed, paused, or - * blocked, edit (inline form in the same strip), and clear. Goal creation lives on the - * `/goal` command, not here: loading (undefined), no goal (null), and complete - * goals render nothing. Durable state arrives as the projected whole snapshot; + * blocked, edit (inline form in the same strip), and clear. Goal creation + * lives on the `/goal` command, not here: loading (undefined), no goal + * (null), and complete goals render nothing. Durable state arrives as the projected whole snapshot; * process-local activation arrives through the injected activation hook. */ @@ -128,8 +128,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear, const title = goal.phase === 'blocked' ? goal.blockedReason?.message : undefined const label = goal.phase === 'active' ? activeLabel(activation, t) : t(PHASE_LABELS[goal.phase]) - const showResume = goal.phase === 'paused' || goal.phase === 'blocked' - || (goal.phase === 'active' && activation === 'disarmed') + const showResume = goal.phase !== 'active' || activation === 'disarmed' return (
diff --git a/packages/client/ui-goal/tests/goalbar.client.spec.tsx b/packages/client/ui-goal/tests/goalbar.client.spec.tsx index 7ce90a8525..d14f2c854a 100644 --- a/packages/client/ui-goal/tests/goalbar.client.spec.tsx +++ b/packages/client/ui-goal/tests/goalbar.client.spec.tsx @@ -207,19 +207,12 @@ describe('GoalBar', () => { expect(screen.getByText('受阻的目标').closest('[title]')?.getAttribute('title')).toBe('No progress in 3 rounds') }) - it('blocked goal: offers resume after the objective is edited', async () => { + it('blocked goal: offers resume and no pause', () => { const actions = makeActions() - const { rerender } = render() - fireEvent.click(screen.getByRole('button', { name: '编辑目标' })) - fireEvent.change(screen.getByRole('textbox', { name: '目标内容' }), { target: { value: 'Ship the fix' } }) - fireEvent.click(screen.getByRole('button', { name: '保存目标' })) - await waitFor(() => { expect(screen.queryByRole('textbox')).toBeNull() }) - expect(actions.onEdit).toHaveBeenCalledWith('Ship the fix') - - rerender() + render() + expect(screen.queryByRole('button', { name: '暂停目标' })).toBeNull() fireEvent.click(screen.getByRole('button', { name: '恢复目标' })) expect(actions.onResume).toHaveBeenCalledTimes(1) - expect(screen.queryByRole('button', { name: '暂停目标' })).toBeNull() }) it('blocked goal without a reason carries no tooltip', () => {