🧩 refactor: Resolve Tool-Card State Once - #14934
Conversation
Each tool card derived its state several times over — the visible label from one expression, the `aria-live` announcement from another, the icon and shimmer from a third, and since #14906 the follow-scroll from a fourth. Nothing tied them together; they agreed only because each was written to agree. Thirteen of the seventeen review findings on #14873 were instances of one derivation being updated and another left behind, and #14892 added more. `resolveToolCallPhase` is now the single source: one function encoding the precedence rules, each of which a specific review finding established, returning `running | completed | cancelled | failed`. Everything the card shows reads that value. `ProgressText` takes `phase` in place of the `error` + `errorSuffix` pair, which encoded three terminal states in two booleans — `error` meant cancelled, a present `errorSuffix` meant failed — and made every consumer reconstruct the distinction. That shape is precisely what let a duration render beside "failed" (Codex round 1 on #14892). Two things fell out once the state had one home, both dead code rather than deletions of behaviour: - `progress` left `ProgressText` entirely; the phase already carries everything it was used to decide. - The `useProgress` mask went with it. Passing 1 in still matters — it stops the 200ms interval — but masking the output no longer does, because the phase treats an explicit close as terminal outright. The "both halves are load-bearing" subtlety is now one half. Scope: the nine cards that render the shared `ProgressText`. The three with bespoke layouts (`WebSearch`, `SubagentCall`, `OpenAIImageGen`) still resolve their own state and are the natural follow-up — they can adopt the resolver without adopting the component. Refactor-only. 4891/4891 client tests pass unchanged, including the suites that encode the cancelled/failed precedence in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
|
@codex review Worth a reviewer's attention specifically:
Scope note: Generated by Claude Code |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 702b43901a
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (!isSubmitting && progress < 1) { | ||
| return 'cancelled'; |
There was a problem hiding this comment.
Avoid inferring cancellation from animated progress
For a live legacy call without runStepStatus, the final SSE update sets the reported progress to 1, but useProgress deliberately continues returning its previous value and then 0.99 for roughly 200 ms. If submission ends during that window, this condition classifies the successful call as cancelled; both ToolCall and RetrievalCall now pass that animated value here, whereas their previous cancellation predicates used initialProgress and therefore recognized the final update immediately. This can briefly render and announce “Cancelled” for a successfully completed tool call, so the heuristic needs to distinguish reported completion from display animation.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed and fixed in a74e9ad. Verified against the hook rather than taken on trust — useProgress does exactly what you describe:
} else if (initialProgress >= 1 && progress < 1) {
setProgress(0.99);
timeout = setTimeout(() => { setProgress(1); }, 200);
}The input is now split: reportedProgress is what the stream said and drives the cancellation inference; displayProgress is the animated value and drives running vs completed, so the label and shimmer still follow the animation instead of snapping. Both are required, so a future caller has to decide which one they mean.
One correction to the framing, in my favour and against it. You're right that ToolCall and RetrievalCall were immune and my change exposed them — but useToolCallState already had this bug: its predicate read rawProgress, not initialProgress. So every card the hook backs (BashCall, ExecuteCode, ReadFileCall, SkillCall, FileAuthoringCall) could already flash "Cancelled" on a successful call before this PR. Consolidating is what made the divergence visible; the fix now covers all of them.
Three tests pin the window: reported-complete mid-settle is running, genuinely unfinished is still cancelled, and it settles to completed with no cancelled frame in between. 4894/4894 client tests pass.
Generated by Claude Code
`useProgress` holds below 1 for ~200ms after a call reports completion: it emits the previous value, then `0.99`, then `1` on a timeout. The resolver read that animated value for its cancellation inference, so a successful call whose submission ended inside that window rendered — and announced — as "Cancelled". The input is now split. `reportedProgress` is what the stream said and drives the inference; `displayProgress` is the animated value and drives `running` vs `completed`, so the label and shimmer still follow the animation rather than snapping. This restores `ToolCall` and `RetrievalCall`, whose previous predicates used `initialProgress` and were immune, and additionally fixes `useToolCallState`, which inferred from `rawProgress` and therefore carried the bug already — every card the hook backs was exposed to it before this PR. Three tests cover the window: a reported-complete call mid-settle is `running`, a genuinely unfinished one is still `cancelled`, and the card settles to `completed` without a cancelled frame in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
|
@codex review Round 1 addressed in The finding was correct and the fix is a split input: Worth flagging because it sharpens the PR's own argument: Also note the earlier 4894/4894 client tests across 408 suites; typecheck, eslint and import-sort clean. Generated by Claude Code |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
`isFailedPhase` and `isRunningPhase` had no callers — every consumer compares the phase directly, which reads better than a wrapper. An unused abstraction is the thing this PR argues against, so it should not ship one. The comment above the hook's resolver call still described "the raw progress the legacy heuristic was written against", which stopped being true when the input split into reported and display progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
* 🧩 refactor: Resolve Tool-Card State Once (AI-1810) Each tool card derived its state several times over — the visible label from one expression, the `aria-live` announcement from another, the icon and shimmer from a third, and since danny-avila#14906 the follow-scroll from a fourth. Nothing tied them together; they agreed only because each was written to agree. Thirteen of the seventeen review findings on danny-avila#14873 were instances of one derivation being updated and another left behind, and danny-avila#14892 added more. `resolveToolCallPhase` is now the single source: one function encoding the precedence rules, each of which a specific review finding established, returning `running | completed | cancelled | failed`. Everything the card shows reads that value. `ProgressText` takes `phase` in place of the `error` + `errorSuffix` pair, which encoded three terminal states in two booleans — `error` meant cancelled, a present `errorSuffix` meant failed — and made every consumer reconstruct the distinction. That shape is precisely what let a duration render beside "failed" (Codex round 1 on danny-avila#14892). Two things fell out once the state had one home, both dead code rather than deletions of behaviour: - `progress` left `ProgressText` entirely; the phase already carries everything it was used to decide. - The `useProgress` mask went with it. Passing 1 in still matters — it stops the 200ms interval — but masking the output no longer does, because the phase treats an explicit close as terminal outright. The "both halves are load-bearing" subtlety is now one half. Scope: the nine cards that render the shared `ProgressText`. The three with bespoke layouts (`WebSearch`, `SubagentCall`, `OpenAIImageGen`) still resolve their own state and are the natural follow-up — they can adopt the resolver without adopting the component. Refactor-only. 4891/4891 client tests pass unchanged, including the suites that encode the cancelled/failed precedence in both directions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 * 🐛 fix: Infer Cancellation From Reported Progress, Not The Animation `useProgress` holds below 1 for ~200ms after a call reports completion: it emits the previous value, then `0.99`, then `1` on a timeout. The resolver read that animated value for its cancellation inference, so a successful call whose submission ended inside that window rendered — and announced — as "Cancelled". The input is now split. `reportedProgress` is what the stream said and drives the inference; `displayProgress` is the animated value and drives `running` vs `completed`, so the label and shimmer still follow the animation rather than snapping. This restores `ToolCall` and `RetrievalCall`, whose previous predicates used `initialProgress` and were immune, and additionally fixes `useToolCallState`, which inferred from `rawProgress` and therefore carried the bug already — every card the hook backs was exposed to it before this PR. Three tests cover the window: a reported-complete call mid-settle is `running`, a genuinely unfinished one is still `cancelled`, and the card settles to `completed` without a cancelled frame in between. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 * 🧹 chore: Drop Unused Phase Predicates; Correct A Stale Comment `isFailedPhase` and `isRunningPhase` had no callers — every consumer compares the phase directly, which reads better than a wrapper. An unused abstraction is the thing this PR argues against, so it should not ship one. The comment above the hook's resolver call still described "the raw progress the legacy heuristic was written against", which stopped being true when the input split into reported and display progress. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5 --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Closes the structural issue behind three review cycles' worth of findings (AI-1810).
Each tool card derived its state several times over: the visible label from one expression, the
aria-liveannouncement from another, the icon and shimmer from a third — and since #14906, the follow-scroll from a fourth. Nothing tied them together. They agreed only because each was written to agree, and they drifted the moment a new input arrived.The evidence is unusually direct. 13 of the 17 Codex findings on #14873 were one derivation updated and another left behind. #14892 added more, including a duration rendering beside "failed". And in #14892's own review, the one place that did have a single derivation — label and announcement sharing an expression — produced zero findings, and its bug fix was one line instead of seven.
The change
resolveToolCallPhaseis the single source of truth: one pure function returningrunning | completed | cancelled | failed, encoding precedence rules that were each established by a specific past finding —running, whateverprogresssays.Those two opposite precedence rules have been collapsed into one another twice under review, so they are pinned side by side in a test.
ProgressTextnow takesphaseinstead of theerror+errorSuffixpair. That pair encoded three terminal states in two booleans —errormeant cancelled, a presenterrorSuffixmeant failed — and left every consumer to reconstruct the distinction. It is exactly the shape that produced the round-1 finding on #14892.Two deletions that fell out
Both are dead code the consolidation exposed, not behaviour removed:
progressleftProgressTextentirely. The phase already carries everything it decided. Eight call sites stop passing it.useProgressmask went with it. Passing1in still matters (it stops the 200ms interval); masking the output no longer does, because the phase treats an explicit close as terminal outright. The "both halves are load-bearing" subtlety documented in 🧩 refactor: Extend Step Status To Remaining Cards; Separate Cancelled From Failed #14873 is now one half.Scope
The nine cards rendering the shared
ProgressText. The three with bespoke layouts —WebSearch,SubagentCall,OpenAIImageGen— still resolve their own state and are the natural follow-up; they can adopt the resolver without adopting the component, which is the point of keeping the resolver a plain function.CodeAnalyzeis the legacy assistants-endpoint card: it never receives run-step metadata, so it genuinely has two states and maps them directly rather than through the resolver, whose heuristic needs signals it does not have. Its announcement and icon now read that same value.Behaviour
Refactor-only, with one alignment worth naming:
FileAuthoringCall,BashCallandExecuteCode's follow-scroll usedprogress < 1 && !cancelled, missing the!hasErrorterm its neighbouring icon expression had. It now readsphase === 'running'like everything else. No user-visible change today (a closed failed step already hasprogress >= 1), but it is the divergence AI-1810 was filed about, so it is unified rather than preserved.Testing
client/src/utils/__tests__/toolCallPhase.spec.ts— 12 new tests over the resolver: every explicit-status branch, every legacy-heuristic branch, both precedence directions, and a test asserting the two opposite rules side by side so a future edit cannot quietly unify them.4891/4891client tests pass across 408 suites, including theOpenAIImageGenand card suites that encode the cancelled/failed precedence in both directions.ProgressTextmocks re-implemented the old prop logic, andFileAuthoringCall'suseToolCallStatemock used the old positional signature.npx tsc --noEmitclean over the touched tree; eslint and import-sort clean on all 16 files.Change Type
Checklist
🤖 Generated with Claude Code
https://claude.ai/code/session_014vLhxCFMYkCaTsoFTiAjJ5
Generated by Claude Code