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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* ios(work chat): drop the now-unused text signature helper
Every caller reads a stored digest instead.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
* 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 <noreply@anthropic.com>
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