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

@@ -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>