Fix Claude SDK turn lifecycle and steering - #791
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
📝 WalkthroughWalkthroughChangesClaude chat lifecycle
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
5a25775 to
2589040
Compare
|
@copilot review but do not make fixes |
|
@codex review |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx (1)
1080-1118: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winInterrupt/queue menu options aren't gated on whether the corresponding dispatch callback is actually available.
The caret menu unconditionally renders
inline/queue/interrupt, butsubmitActiveTurnDraftsilently no-ops viaonSendSteerInterrupt?.()when that callback is absent. TodayAgentChatPanealways wires both callbacks together for Claude, so this isn't user-visible yet, but the component itself doesn't defend against a future caller providing onlyonSendSteerNow, which would leave a dead "Interrupt & send" option in the menu.Consider filtering the mapped options (or accepting a
supportedModesprop) so the menu only ever offers actions the composer can actually dispatch.Also applies to: 973-983
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` around lines 1080 - 1118, Gate the mode options rendered by the caret menu around the existing mode-selection mapping and dispatch callbacks, so queue and interrupt are included only when their corresponding handlers are available, while inline remains available through onSendSteerNow. Ensure onModeChange and submitActiveTurnDraft cannot expose or dispatch an unsupported mode, preserving the current behavior when all callbacks are provided.apps/desktop/src/main/services/chat/agentChatService.test.ts (1)
11258-11265: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicate
eventmember in the predicate's type literal.
event: Extract<AgentChatEventEnvelope["event"], { type: "done" }>;is declared twice inside the same type-literal annotation (lines 11260/11261 duplicate). Identical-type duplicate members in a type literal aren't a hard TypeScript error, but this is clearly a copy-paste leftover and should be trimmed.🧹 Proposed cleanup
(event): event is AgentChatEventEnvelope & { event: Extract<AgentChatEventEnvelope["event"], { type: "done" }>; - event: Extract<AgentChatEventEnvelope["event"], { type: "done" }>; } => event.sessionId === session.id🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/desktop/src/main/services/chat/agentChatService.test.ts` around lines 11258 - 11265, Remove the duplicate event member from the type literal in the waitForEvent predicate for idleDone, leaving one event property typed with Extract<AgentChatEventEnvelope["event"], { type: "done" }>.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/desktop/src/main/services/chat/agentChatService.test.ts`:
- Around line 11258-11265: Remove the duplicate event member from the type
literal in the waitForEvent predicate for idleDone, leaving one event property
typed with Extract<AgentChatEventEnvelope["event"], { type: "done" }>.
In `@apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx`:
- Around line 1080-1118: Gate the mode options rendered by the caret menu around
the existing mode-selection mapping and dispatch callbacks, so queue and
interrupt are included only when their corresponding handlers are available,
while inline remains available through onSendSteerNow. Ensure onModeChange and
submitActiveTurnDraft cannot expose or dispatch an unsupported mode, preserving
the current behavior when all callbacks are provided.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bbce22b5-30e2-4a6a-a9ee-b6da91e05997
⛔ Files ignored due to path filters (6)
apps/ade-cli/package-lock.jsonis excluded by!**/package-lock.json,!**/package-lock.jsonapps/desktop/package-lock.jsonis excluded by!**/package-lock.json,!**/package-lock.jsondocs/features/chat/README.mdis excluded by!docs/**docs/features/chat/composer-and-ui.mdis excluded by!docs/**docs/features/chat/transcript-and-turns.mdis excluded by!docs/**docs/features/sync-and-multi-device/remote-commands.mdis excluded by!docs/**
📒 Files selected for processing (19)
apps/ade-cli/package.jsonapps/ade-cli/src/services/sync/syncRemoteCommandService.test.tsapps/ade-cli/src/services/sync/syncRemoteCommandService.tsapps/desktop/package.jsonapps/desktop/src/main/services/chat/agentChatService.test.tsapps/desktop/src/main/services/chat/agentChatService.tsapps/desktop/src/main/services/chat/claudeQueryLifecycle.test.tsapps/desktop/src/main/services/ipc/registerIpc.tsapps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsxapps/desktop/src/renderer/components/chat/AgentChatComposer.tsxapps/desktop/src/renderer/components/chat/AgentChatPane.test.tsxapps/desktop/src/renderer/components/chat/AgentChatPane.tsxapps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsxapps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsxapps/desktop/src/shared/chatScheduledWork.test.tsapps/desktop/src/shared/chatScheduledWork.tsapps/desktop/src/shared/types/chat.tsapps/ios/ADE/Views/Work/WorkTimelineHelpers.swiftapps/ios/ADETests/ADETests.swift
|
Iteration 2: fixed the valid CodeRabbit component-contract nit by hiding unsupported Interrupt actions and falling a removed handler back to inline. The duplicate-event nit was stale on the current head. Also hardened the shard-8 Cursor assertion against unrelated async test leakage while still detecting a real duplicate of the test prompt. Focused tests and desktop typecheck pass. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: f291f7243f
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cd2f88c478
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5eac9095fc
ℹ️ 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".
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e29d6e6f18
ℹ️ 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 (requireQueued && runtime.kind === "claude" && runtime.dispatchingSteerIds.has(steerId)) { | ||
| throw new Error("This message is already being dispatched."); | ||
| } |
There was a problem hiding this comment.
Block cancels while Claude steer dispatch is in flight
When a Claude dispatch is still waiting on the initial-input gate or building attachments, dispatchSteer has added the id to runtime.dispatchingSteerIds but leaves it in pendingSteers until onAccepted. The Remove button calls cancelSteer without requireQueued, so this guard is skipped, the item is spliced and a “cancelled” notice is emitted, but the in-flight dispatchClaudeSteerMessage still pushes it and emits an inline message. Please reject or no-op all cancels for ids in dispatchingSteerIds, otherwise users can be told a queued message was cancelled when it is actually delivered.
Useful? React with 👍 / 👎.
| steerId, | ||
| deliveryState: "queued", |
There was a problem hiding this comment.
Make failed fallback requeues visible again
In the idle/restored fallback, ADE emits a Delivering your queued message... notice before executePreparedSendMessage; the renderer treats that notice as resolving the steer id in deriveRuntimeState. If the send then throws and this catch requeues using the same steerId, the later queued user_message is ignored, so the message remains in runtime.pendingSteers but disappears from the staged UI. Use a new steer id or emit an event the renderer can use to unresolve the id when requeueing.
Useful? React with 👍 / 👎.
| if (nextIds.has(taskId) || !runtime.seenBackgroundTaskIds.has(taskId)) continue; | ||
| emitClaudeBackgroundTaskUpdate(managed, runtime, { | ||
| taskId, | ||
| status: "completed", | ||
| ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), | ||
| }); |
There was a problem hiding this comment.
Background Subagent Level Drops
When a native background agent or workflow disappears from background_tasks_changed before its task_notification, this branch skips terminalizing it because no Background row added the id to seenBackgroundTaskIds. The id is then removed from liveBackgroundTaskIds, so workload tracking can go idle while the Subagents row stays running forever.
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/desktop/src/main/services/chat/agentChatService.ts
Line: 11075-11080
Comment:
**Background Subagent Level Drops**
When a native background agent or workflow disappears from `background_tasks_changed` before its `task_notification`, this branch skips terminalizing it because no Background row added the id to `seenBackgroundTaskIds`. The id is then removed from `liveBackgroundTaskIds`, so workload tracking can go idle while the Subagents row stays running forever.
How can I resolve this? If you propose a fix, please make it concise.* ios(work chat): make transcript scroll corrections defer to the reader Four scroll-correctness defects in the Work chat transcript: - A prepend correction whose probe described a different row than the armed anchor fell through with a zero row shift, which reduces the correction to the reader's own scroll delta and applies it a second time. It now bails out and waits for a usable measurement. - Programmatic scroll writes only checked the drag gesture, which ends at finger-up rather than at the end of the fling. They now also defer to the scroll phase (tracking/interacting/decelerating), so a pin or a correction never fights a fling for the offset. A correction deferred this way stays armed and applies once the fling settles — the measurement isolates the insertion from the reader's scrolling, so it restores the same position. - A second prepend inside an open correction window was dropped, leaving the first insertion uncorrected. Overlapping prepends now keep the existing anchor, whose row was pushed down by both insertions, and only extend its window. - The opening pin fired once and disarmed, so hydration landing after the retry ladder grew the content under an offset nobody re-pinned. It now stays armed until the content size has been quiet for 600ms or the reader drags deliberately (16pt, up from the 2pt stickiness deadband). Chats also open at the tail via defaultScrollAnchor(.bottom, for: .initialOffset), short transcripts render top-anchored like desktop, and a one-entry chat skips the force-pin entirely. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): derive transcript scroll distances from one geometry sample The content-top and content-bottom probes each laid out a GeometryReader and pushed a value through the preference reduce/observe machinery on every frame of every scroll, to report two numbers the scroll view already publishes. Both are gone; distance-from-top and distance-from-bottom now come off the existing `onScrollGeometryChange` sample. The per-frame observer is now strictly O(1) work. The tail scan in `resolvePendingInitialBottomPinAfterLayout` moved onto a second observer keyed to content SIZE, which by construction cannot fire while the reader is only scrolling — it also drives the short-transcript top-anchor flip. `workChatShouldRequestOlderHistory` now takes `distanceFromTop` (grows downward) instead of the probe's `topY` (grew upward, negative). Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): stabilize row identity, budgets, and per-refresh text cost Row identity: markdown block ids were content-derived, so every streaming delta and every "Show more" step handed the LazyVStack a new identity for a row that was still the same row — reuse was impossible by construction. Ids are now position-stable (`markdown-block-<index>`) and the content digest moved to its own field, used only for change detection. Per-refresh text work, all of it on the main thread and proportional to the whole visible transcript: - The preview cache recomputed `markdown.utf8.count` + `markdown.hashValue` on every lookup, so a cache HIT still cost O(message). It now keys off the digest the (off-main) snapshot fold stamps on each message, and holds one preview per line budget instead of only the initial one. - The presentation signature hashed every message's full markdown, every preview's full text, every monospaced slice, and rebuilt each markdown block's `cacheKey` (a full copy of the block's text) to hash that too. All four now read stored digests plus the preview's shape. - `workAssistantMessagePreview` copied the whole message to normalize CRLF even when there was no CR to normalize. - `workToolResultTruncate` counted graphemes over the whole result on every body pass; it now pre-filters on stored UTF-8 length. Row views: WorkChatMessageBubble, WorkToolCardView, WorkEventCardView, WorkAdeCardView, WorkCommandCardView and WorkFileChangeCardView are Equatable and rendered through `.equatable()`. WorkToolCardView's navigation-reference extraction (which concatenates a tool's arguments and result) moved inside the view, so a collapsed row no longer pays for it. "Show more" is now deterministic: - A message's budget may grow but never shrink. The newest assistant message renders tail-anchored under a generous budget; when a newer message arrived it flipped to head-anchoring and dropped back to 48 lines, so a message the reader had just read in full grew a "Show more" behind their back. The budget it already rendered under is now its floor. - The bubble's private `@State` budget is gone. Both show-more paths write the transcript's shared budget map, so expansion survives LazyVStack recycling and the two paths cannot disagree. - A tap no longer re-pins the transcript to its bottom (which threw the reader to the end of the chat for asking to see more of a message in the middle of it). The message flips to head-anchoring and the tapped row is held in place. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): de-nest the transcript's inner box scrollers WorkStructuredOutputBlock, WorkDiffOutputBlock and WorkInlineDiffPreview each put a vertical ScrollView inside the transcript's own vertical ScrollView, then capped it with a maxHeight. Nested same-axis scrollers compete for every drag that starts on them, and these ones only ever clipped — no gesture reached past their cap from inside the box anyway. They are clipped fixed-height content now; the diff blocks keep their horizontal scroller. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): cover the scroll and budget contracts with tests Extracts the two prepend-anchor decisions (arm, correct) into pure functions so the policy is assertable rather than reachable only through a live ScrollView, then covers: - probe/anchor row mismatch produces no correction - overlapping prepends keep the anchor that accumulates both insertions - a correction waits out the reader without spending an attempt - the correction isolates an insertion from the reader's own scrolling - programmatic scrolls defer to the whole interaction, and `.animating` (our own animation) is not the reader's - a short transcript renders from the top - a message rendered fully as the tail is never truncated afterwards, and "Show more" steps from the budget it is actually rendering under - markdown block ids are index-based and survive content edits, including across streaming deltas Also documents the new contracts in the iOS companion doc. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): drop the now-unused text signature helper Every caller reads a stored digest instead. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): add a full-screen viewer for boxed output Phase 1 de-nested the transcript's inner scrollers, so tool results, command output and diffs now clip at a fixed height with nothing to scroll — whatever sits past the cut is unreachable in place. This is where it goes. One screen serves every kind of box: monospaced, line-numbered, lazy (the text can be a 100k-character tool result, and one Text that long with a gutter would lay the whole thing out before drawing a row), with a wrap toggle, in-text search that counts occurrences rather than lines and steps through them, Copy all, and the system share sheet. Diffs keep their add/remove tinting and code keeps its syntax highlighting, except on a line the search matched — two sets of competing colours on one line reads as noise. Also lands the pieces the boxes need: - `WorkOutputViewerModel` in the environment, so a surface owns one presentation host instead of every transcript row carrying a `fullScreenCover` it almost never fires. - `workOutputBoxOverflows`, which decides whether a box is clipping and the viewer is worth offering, without scanning a long result to find out. - `workTruncatedOutputAffordance`, the hybrid ladder as one decision. - `WorkCodeBlockSource`, which locates a rendered code block inside the message it was sliced from so Copy can reach the whole thing. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): copy the whole output, and open it, from every box Copy controls in the transcript were handing over previews of their own content. A fenced code block copied the slice the transcript happened to be rendering; the tool-result box copied its 500-character truncation. Both look like a working Copy button and both lose data silently. - Code blocks resolve against the message they were sliced from, by ordinal: from the front for a head-anchored preview, from the back for a tail-anchored one (which also carries a synthetic opening fence, and is exactly the case that copied a fragment). Resolution runs at tap time, not per render pass. - The tool-result box takes the untruncated result as `copyText` while it keeps displaying the slice. - The diff boxes, which had no Copy at all, get one — plus the file path, so the viewer they open can be titled with it. Every box that is clipping now offers the viewer: from its header, and by tapping the clipped region itself. Copy and Open both take 44pt targets. The transcript's expand ladder becomes hybrid. The first "Show more" still expands downward in place; anything still bounded after that step opens the viewer rather than paginating a reader through a thousand more lines four dozen at a time. For the result box that step is mandatory reasoning, not taste: it clips at 180pt, so a second in-place expansion would add text nobody can see. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): cover full-content copy and the expand ladder Runs the real render path — preview slicer, timeline entries, per-block render models — rather than the resolver alone, so the plumbing is what is asserted: - a head-anchored slice's code blocks copy the whole block, and the last one really was partial (otherwise the test proves nothing) - a tail-anchored slice does too, through the synthetic opening fence, with ordinals counted from the end; the fence block copies the full block and not the fragment on screen - an ordinal that cannot be located falls back to what is on screen instead of copying some other block - the tool-result box shows a slice and copies the whole result, in both states - the hybrid ladder: show more once, then the viewer, and nothing at all when the whole box is visible - `workOutputBoxOverflows` counts wrapped lines for a wrapping box but only hard breaks for a diff, which scrolls horizontally instead - viewer search counts occurrences rather than lines, and steps wrap both ways Also documents the ladder and the copy contract in the iOS companion doc. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): keep scroll-back alive after a dropped history page One failed older-history page latched `olderHistoryLoadError`, and both scroll-back gates refused to fire while it was set. Because the host answered `unavailable` for this session's event history (the runtime behind it was unreachable), the very first page request failed — and from then on the transcript would not page back at all, including through the ~25 timeline entries the phone had already buffered and could have shown with no network. Reading it as a user, the transcript simply stops: drag up, hit a wall, and nothing above it ever loads. Two changes, both scoping the failure to the thing that actually failed: - Buffered entries bypass the error at both gates. They are already on the device and cost nothing to reveal, so a dropped host page has no business hiding them. This matters most in `workChatShouldContinueAutomaticOlderHistory`: a transcript still shorter than the viewport cannot be scrolled at all, so there is no gesture left that could re-arm anything. - Scrolling back down past the re-arm distance retires the failure. A dropped page is nearly always a transient host timeout, and keeping it until someone finds the retry row means the next approach to the top silently does nothing. The retry gesture is now the one the reader already makes, and it cannot spin: a fresh attempt still costs a full round trip past the re-arm distance. Verified on the simulator against the live 954-event "Close PR3 DAW ingestion lane" chat with the host history page failing: scroll-back now walks from the 10:15 tail down to 08:25 messages, the failure row stays visible and tappable at the top, and Latest returns to the bottom. Before the change the same chat stopped dead ~16 minutes back and never moved again. * ios(work chat): send the active-turn mode with the steer, not after it Picking "Send during turn" or "Interrupt & send" used to stage the message first and then promote it with a second chat.dispatchSteer round-trip. Every active-turn send therefore flashed through the staged strip on its way out, and the branch that resends as a steer after the host rejects a plain send ("turn already active") never made that second call at all — it captured useSteer as false, so the promotion block was unreachable and the mode the user chose was silently discarded. The mode now rides chat.steer itself as dispatchMode, which the host has accepted and validated since #791, and is resolved once before the send so both the direct steer and the active-turn resend carry it. A host that honors it dispatches in the same round-trip and answers queued:false, so nothing is written to the queue and no optimistic staged entry is created. manualSteerDispatchModes now carries the chat.dispatchSteer host gate itself rather than having each call site remember to apply it, so the send path and the staged strip's buttons read one list. A brain old enough to advertise chat.dispatchSteer but too old to accept dispatchMode answers queued:true; that case falls back to the legacy promotion instead of dropping the choice. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): open up the queued strip and remember how you send The staged strip was built when every active-turn send passed through it, so it defended itself with an accordion: a "1 staged" header you had to expand before Send now / Interrupt / Edit / Cancel were reachable. Now that only an explicit "Send after turn" produces a row, that ceremony guards nothing. One queued message is a single compact card — waiting glyph, one truncated line of the message, its disposition beneath it, and the four actions as visible icon-only buttons whose touch areas stay 44pt tall. Only a pile-up keeps a header, and it is now a slim "N queued" label rather than a control. While a turn is running the clock glyph breathes and the line reads "sends when turn ends"; on an idle session it sits still and reads "after turn". The timestamp is gone — a queued message is always "a moment ago". The pulse goes through ADEMotion.pulse, so Reduce Motion draws the glyph at full strength. The active-turn send mode is also a working habit rather than a per-turn decision, so it is remembered per chat in WorkActiveSendModeStore (App Group defaults, the same bounded JSON map the composer drafts use). A turn starting or ending no longer resets it; a provider change only snaps it back when the new provider cannot honor what was remembered. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): cover atomic send modes and the queued-strip rule Pins the wire contract (dispatchMode is absent for a staged steer, and is the desktop's exact "inline"/"interrupt" spelling otherwise), the mode mapping including the empty-list case that both a queue-only provider and an un-upgraded host resolve to, the rule that only a "queued" delivery state produces a strip entry, and the per-chat send-mode round-trip with its blank-session-id guard. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): fold every finished turn down to one line A chat you reopen used to be a wall: every tool card, plan and CI card from every past turn rendered exactly as wide open as it did while it was running. Now the turn in flight reads as it always has, and the moment it ends its cards fold to a single 44pt row — glyph, short summary, right-aligned count chips, chevron. History opens collapsed, because nothing is streaming. Expansion moves out of the rows and into one WorkCardExpansionState held above the list. Per-row @State was losing itself twice over: a LazyVStack recycle silently shut whatever the reader had opened, and no row can collapse its siblings when a turn ends. The state stores only the reader's *disagreement* with a card's own default, which is what lets "keep this shut while it runs" survive the next streaming delta and a manual expand outlive the sweep. Also collapses two composer chips into one. The Subagents capsule opened the very same sheet as Chat Info, and between them they carried enough text to squeeze the PR chip's label into an ellipsis inside a row pinned to 34pt while its capsules asked for 44. One Chat Info chip now counts the whole sheet, the PR chip is icon plus CI glyph with its number and state moved into the accessibility label, and the strip scrolls so a future chip can never truncate its neighbours. Long-press any collapsed CI, plan, tool-cluster, command or diff row to peek at the full card in a context-menu preview, without moving the transcript. Two defects found on the way: a titleless ade_card inherits the raw wire variant as its title, so the collapsed row would have shown "pr_ci" to a reader; and the diagnostics DisclosureGroup binding toggled on any write, including SwiftUI re-sending the value it already had. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): cover the collapse rules and the collapsed one-liners Pins the parts of auto-collapse that have no UI to lean on: which rows belong to the turn in flight (everything after the last turn-end marker, and nothing at all once the transcript ends on one), what a turn ending does to both kinds of manual override, and that an expand and a collapse of the same id never hash alike — the render signature is the only reason a toggled row redraws. Then the text itself: "CI · PR #490" with 18✓ and 3✕, a zero count drawing no chip, "Plan · Run the suite" with 4/7 counted the same way the expanded checklist counts it, the spoken forms, and the refusal to ever print a raw variant slug. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): give a finished turn's tool calls their row back A Claude turn whose whole body was one Read and one approved shell command rendered no trace of either: user bubble, "Thought", "Answered", the reply, and a turn-end hairline. The work itself was gone. Both calls fold into one normalized tool cluster, and the transcript was throwing every cluster away before it ever reached the list. That rule was written when a cluster had no compact form and N stacked tool cards ate the phone viewport, so the only route back to the calls was an 8pt chevron on the turn-end marker at 55% opacity. A finished cluster is now a single 44pt row in the same one-liner grammar the changed-files panel uses right beside it — which also means the transcript had been drawing one kind of cluster and swallowing the other. The filter moves out of the view into workPresentedTimelineEntries, so what reaches the visible timeline is something a test can hold. Also: WorkTurnActivitySheet still built the calls panel the old way, with the member expansion that used to be the panel's own @State. It defaults to an empty set and a no-op, so every call in the sheet drew permanently shut and tapping one did nothing — in the one surface whose whole job is showing the turn's work. Co-Authored-By: Claude Fable 5 <[email protected]> * ios(work chat): bound streaming render work * ios(work): refresh stable transcript overlap payloads * ios(work chat): bound streaming preview work * ios(work chat): preserve split markdown fences * ios(work): harden streaming timeline correctness * ios(work): finish streaming chat surface overhaul --------- Co-authored-by: Claude Fable 5 <[email protected]>
Summary
Validation
Summary by CodeRabbit
Greptile Summary
This PR updates Claude query lifecycle, background level handling, steering dispatch, and subagent transcript routing.
Confidence Score: 4/5
The background native-agent lifecycle path still needs a fix before merging.
Omitted SDK background levels now close Background rows, but native background agents and workflows can still be dropped from workload tracking without a terminal Subagents event.
apps/desktop/src/main/services/chat/agentChatService.ts
What T-Rex did
Important Files Changed
Prompt To Fix All With AI
Reviews (6): Last reviewed commit: "ship: iteration 5 — address #3565467757" | Re-trigger Greptile