fix(web): single-flight goal clear
This commit is contained in:
@@ -12,7 +12,7 @@ The web UI had no goal surface at all: the goal stack shipped with model tools,
|
||||
|
||||
`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome.
|
||||
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it.
|
||||
Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. Every mutation first acquires a synchronous component-local single-flight latch because React's pending-state render cannot close the same-frame click window. A successful clear also suppresses that exact goal id immediately while the authoritative null projection catches up, so an acknowledged tombstone cannot leave a stale clear control that submits `GOAL_NOT_FOUND`; a failure releases the latch and remains retryable. An effect keyed on the goal's id resets this transient state and drops the edit form when the goal's identity changes, so neither a cleared marker nor a surviving draft can affect the replacement goal.
|
||||
|
||||
`GoalBarActions` lives in ui-goal's slot contract (`packages/client/ui-goal/src/client/slots.ts`) and carries exactly the rendered verbs: `onEdit`/`onPause`/`onResume`/`onClear`. Each callback asynchronously returns an explicit success/failure result so `GoalBar` owns its transitions and error display. `apply.ts` wires them to the runtime session methods; the runtime session resolves the current goal's compare-and-set ref internally, so the UI passes no ref.
|
||||
|
||||
@@ -22,7 +22,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
|
||||
`packages/client/ui-goal/tests/goalbar.spec.tsx` pins the behavior through props alone: loading/absent/complete render nothing, the active strip renders label/objective and fires clear, rapid same-frame clear clicks dispatch once and a successful clear hides before projection convergence, the edit form prefills, rejects empty, saves on Enter, cancels on Esc, and resets when the goal's identity changes, the active strip fires pause, the paused strip fires resume, and the blocked strip exposes the reason tooltip. Component failure-path cases prove that a failed edit preserves its draft and that edit/resume/clear errors remain visible and retryable in the bar. The skeleton specs mount `ConversationRoot` with and without `goalActions`; the undefined case is seeded with an active goal, so the missing gate — not the missing goal — is what hides the strip. Runtime session specs pin the folded-error results, the live-only in-flight-plus-trailing refetch, and the stale-read guard. A keyless real-browser smoke boots the assembled application through `boot → RPC → runtime → GoalBar` and records an inline snapshot of the rendered label, objective, and actions.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -34,6 +34,7 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc
|
||||
## Consequences
|
||||
|
||||
- Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface.
|
||||
- Goal mutations are single-flight within the component; a successful clear hides its exact goal immediately while projection delivery converges, preventing duplicate CAS errors without making transient UI state authoritative.
|
||||
- The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads).
|
||||
- Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools).
|
||||
- `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job.
|
||||
|
||||
Reference in New Issue
Block a user