fix(chats): resolve task approvals optimistically before the API returns#67
Merged
chicoxyzzy merged 1 commit intomasterfrom May 9, 2026
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Improves Hecate Chat task-approval UX by applying an optimistic local state update so the approval banner row disappears immediately on click, then reconciling with the backend response.
Changes:
- Apply the task-approval “resolved” patch to
activeAgentChatSessionbefore calling the resolve API, with rollback on failure. - Add tests covering (1) optimistic UI update before the network resolves and (2) rollback behavior when the API rejects.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| ui/src/app/useRuntimeConsole.ts | Moves the optimistic “resolve” patch ahead of the API call and adds rollback logic. |
| ui/src/app/useRuntimeConsole.test.tsx | Adds two tests to pin optimistic-update timing and rollback behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The Hecate Chat task-approval banner cleared a row only AFTER the
network round-trip completed: `await resolveTaskApprovalRequest(...)`
then the local patch. On slow links the row sat there post-click
looking unresponsive, and a double-click could fire a duplicate
POST through the busy-disabled buttons (since `disabled` only
flipped on after the busy state propagated through React's render
cycle).
Move the optimistic patch BEFORE the API call. Capture the
pre-resolve session synchronously from closure (same pattern
deleteProvider already uses for its rollback at line 1265) so
a genuine failure can restore the original state and let the
operator retry.
CONCURRENCY-SAFE ROLLBACK
The rollback can't just be `setActiveAgentChatSession(snapshot)` —
two real concurrency hazards:
1. Operator may have navigated to a different session while
the request was in flight. Replacing the active session
would yank them back to the originating chat.
2. A stream `session_update` or a refresh may have applied
newer messages / activities on top of the optimistic
state. Wholesale replacement drops them.
Both are addressed by a functional updater that:
• bails when `current.id !== snapshot.id` — operator
navigated away, leave them be.
• restores ONLY the specific approval activity from the
snapshot, leaving every other field untouched. Stream
updates and refresh-driven additions survive.
The rollback predicate matches the activity by `approval_id`
(or the projected `task:approval:<id>` id), NOT by the optional
`Activity.id` — matching by id alone could (a) fail to restore
when the current row has no id and (b) wrongly match the first
id-less row in the snapshot if both happen to be undefined.
FAILURE HANDLING
The "/not pending/i" error branch was dishonest about reality.
Server returning "approval already resolved" does NOT guarantee
the resolution matches the operator's chosen decision — another
tab might have approved while this one tried to reject, the run
might have timed out into auto-rejection, or the run might have
been cancelled. The previous code refreshed the session to pull
server-truth, but on refresh failure (network blip, gateway
transient) the optimistic patch stayed on screen, potentially
showing a decision the server never made.
Restructure: refresh first; on success return true (server data
wins). On refresh failure, fall through to the same surgical
rollback the generic catch path uses, plus an error notice
telling the operator to reload. The row reflects "still pending"
rather than a silently-wrong final state.
Extracted the rollback into a closure-scoped
`rollbackOptimisticApproval` helper so both the
generic-failure path AND the not-pending+refresh-failed path
share the same behavior structurally rather than by discipline.
WHY CAPTURING SNAPSHOT INSIDE THE STATE-UPDATER WOULD NOT WORK
React invokes useState updaters asynchronously, and may invoke
them twice under StrictMode. A closure variable assigned inside
the updater is either still null when the catch runs or already
holds the patched state. The `deleteProvider` synchronous-from-
closure pattern is the right shape.
TESTS
Five tests in useRuntimeConsole.test.tsx cover every code path:
- optimistically marks the row resolved before the API call
returns (deferred Promise; assert state mutated while fetch
is still in flight)
- rolls back the optimistic patch when the API rejects (500)
- rollback restores the right activity when activity.id is
absent (id-less thinking row + id-less approval row in same
message; only approval restored)
- rollback does not clobber a session the operator switched
to mid-flight (navigate a1 → a2 while resolve hangs; API
rejects; active session must stay a2)
- rolls back when the server says 'not pending' and the
refresh also fails (409 + 503 on follow-up refresh)
UI suite clean: 652 passes.
WHAT'S NOT IN THIS PR
The "resolved approvals should not be clickable" half of the
original ask was already covered (existing test at
ChatView.test.tsx:1167 pins the behavior — `status === "approved"`
doesn't render the banner, the resolve buttons are gone). The
remaining gap was the click-to-disappear latency, which this PR
closes.
6753042 to
9ede401
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Hecate Chat task-approval banner cleared a row only AFTER the network round-trip completed (`useRuntimeConsole.ts:1602` ➝ `await resolveTaskApprovalRequest(...)` ➝ patch). On slow links the row sat there post-click looking unresponsive, and a double-click could fire a duplicate POST through the busy-disabled buttons (since `disabled` only flipped on after the busy state propagated through React's render cycle).
Move the optimistic patch BEFORE the API call:
```
snapshot = activeAgentChatSession // sync from closure, like deleteProvider
patch the local state // banner row disappears immediately
try { await api(); maybe refresh } catch { restore from snapshot }
```
Capturing the snapshot inside the state-updater would not work: React invokes useState updaters asynchronously, and may invoke them twice under StrictMode, so a closure variable assigned inside the updater is either still null when the catch runs or already holds the patched state. Reading from the destructured `activeAgentChatSession` closure is the same synchronous-snapshot pattern `deleteProvider` uses (`useRuntimeConsole.ts:1265`).
Concurrency-safe rollback
Failure handling
Test plan
Five new tests in `useRuntimeConsole.test.tsx` cover every code path:
Plus:
What's NOT in this PR
The "resolved approvals should not be clickable" half of the original ask was already covered (existing test at `ChatView.test.tsx:1167` pins the behavior — `status === "approved"` doesn't render the banner, the resolve buttons are gone). The remaining gap was the click-to-disappear latency, which this PR closes.