fix(web): single-flight goal clear

This commit is contained in:
kingwl
2026-07-31 20:54:23 +08:00
parent 5c4b701afe
commit e73cdd6b7d
11 changed files with 148 additions and 34 deletions

View File

@@ -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: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448

View File

@@ -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 store, no refresh chain, and no 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 RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
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.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
@@ -17,4 +17,3 @@ None beyond the goal mutation's own context event, which appends to the log tail
## Known Limitations and Deferred Work
- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it.
- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR.

View File

@@ -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 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
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 一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
@@ -17,4 +17,3 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input
## Known Limitations and Deferred Work
- **只反映持久 phase** —— 投影值有意省略进程本地的 activationarmed/disarmed条带无法区分 active-but-disarmed 与 armed 状态resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
- **暂缺 keyless 快照** —— 组装应用级 transcriptboot → 投影 → GoalBar推迟到落地 PR 记录的评审后收口批次。

View File

@@ -8,7 +8,7 @@
* the injected face.
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
@@ -35,6 +35,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [clearedGoalId, setClearedGoalId] = useState<GoalSnapshot['id'] | null>(null)
const pendingRef = useRef(false)
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
// state: without the reset a surviving draft's Enter would write over the NEW goal.
@@ -42,32 +44,37 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
useEffect(() => {
setEditing(false)
setActionError(null)
setClearedGoalId(null)
}, [goalId])
// React state disables the controls on the next render; the ref closes the
// same-render window so rapid clicks cannot submit the same CAS twice.
const runAction = useCallback(async (action: () => Promise<GoalActionResult>): Promise<GoalActionResult | undefined> => {
if (pendingRef.current) return undefined
pendingRef.current = true
setPending(true)
setActionError(null)
const result = await action()
pendingRef.current = false
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
return result
}, [])
const handleEdit = useCallback(async () => {
const trimmed = draft.trim()
if (trimmed === '') return
setPending(true)
setActionError(null)
const result = await onEdit(trimmed)
setPending(false)
if (result.ok) {
setEditing(false)
} else {
setActionError(`${result.error.message} (${result.error.code})`)
}
}, [draft, onEdit])
const result = await runAction(() => onEdit(trimmed))
if (result?.ok) setEditing(false)
}, [draft, onEdit, runAction])
const runAction = useCallback(async (action: () => Promise<GoalActionResult>) => {
setPending(true)
setActionError(null)
const result = await action()
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
}, [])
const handleClear = useCallback(async (clearedId: GoalSnapshot['id']) => {
const result = await runAction(onClear)
if (result?.ok) setClearedGoalId(clearedId)
}, [onClear, runAction])
// Loading, absent, and complete goals have no strip at all.
if (goal === undefined || goal === null || goal.phase === 'complete') return null
if (goal === undefined || goal === null || goal.phase === 'complete' || goal.id === clearedGoalId) return null
if (editing) {
return (
@@ -142,7 +149,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
>
<IconEditOutline16 />
</button>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title={t('action.clear')} aria-label={t('action.clear')}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} title={t('action.clear')} aria-label={t('action.clear')}>
<IconTrashOutline16 />
</button>
</div>

View File

@@ -3,13 +3,13 @@
// inline edit form, and resume/clear icon actions — driven purely through
// props, no wire. Loading, absent, and complete goals render nothing.
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { GoalBar } from '../src/client/GoalBar.tsx'
import type { GoalBarActions } from '../src/client/slots.ts'
import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
@@ -61,6 +61,27 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1)
})
it('single-flights rapid clear clicks and hides the committed goal before its projection catches up', async () => {
const actions = makeActions()
let resolveClear!: (result: GoalActionResult) => void
actions.onClear.mockImplementation(() => new Promise((resolve) => { resolveClear = resolve }))
const { container, rerender } = render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
const clear = screen.getByRole<HTMLButtonElement>('button', { name: '清除目标' })
act(() => {
clear.click()
clear.click()
})
expect(actions.onClear).toHaveBeenCalledTimes(1)
expect(clear.disabled).toBe(true)
await act(async () => { resolveClear({ ok: true }) })
expect(container.firstChild).toBeNull()
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
expect(screen.getByText('Next goal')).toBeTruthy()
})
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
@@ -180,5 +201,7 @@ describe('GoalBar', () => {
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
expect(screen.getByText('Ship the redesign')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) })
})
})