fix(web): single-flight goal clear
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user