fix(web): single-flight goal clear
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 packages/client/ui-goal/README.md
|
||||
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
|
||||
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
|
||||
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
|
||||
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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** —— 投影值有意省略进程本地的 activation(armed/disarmed),条带无法区分 active-but-disarmed 与 armed 状态;resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
|
||||
- **暂缺 keyless 快照** —— 组装应用级 transcript(boot → 投影 → GoalBar)推迟到落地 PR 记录的评审后收口批次。
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) })
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user