From d47e113e8625085242fdc9fe0553383e6a4254e6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:57:51 -0400 Subject: [PATCH 01/26] fix(chat): a delivered steer no longer reappears as queued on iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: a message sent during an active Claude turn looked right for ~10s on the phone, then moved into the queued-steer strip and vanished from the thread. Cause: the host writes a steered message twice — `deliveryState: "queued"` when staged and non-queued when the provider consumes it — and both rows reached the phone. iOS's idle rebuild filters the live event stream with `workChatEventIncludedInIdleCanonicalEventTranscript`, which keeps queued user rows and drops every other user row, so the stale queued row outlived the row that graduated it; `shouldSkipBackfillPlainUserMessage` then suppressed the canonical fallback bubble on the strength of that same stale row, and `pruneResolvedQueuedSteerEnvelopes` ran too late to help. Change and boundary: `workChatIdleCanonicalEventTranscript` prunes resolved queued steers before the idle filter runs, in both refresh paths of WorkSessionDestinationView, and `preferredWorkTranscript` prunes the merged live transcript before the fallback backfill. Host side, `transcriptEntriesFromEnvelopes` skips a queued `user_message` when a non-queued row with the same steerId exists in the batch, so `chat.getTranscript` and `ade chat read` stop returning the text twice. A still-pending queued steer has no graduating row and is untouched by either prune; the desktop renderer already never drew queued rows in-thread. Verification: npm --prefix apps/desktop run typecheck (clean); npx vitest run chatTranscriptEntries.test.ts chatTranscriptHistoryPager.test.ts sessionNaming.test.ts (64 passed); agentChatService.test.ts (1150 passed). iOS tests not run: only 23 GB free on / (policy floor is 30 GB). Co-Authored-By: Claude Opus 5 --- .../chat/chatTranscriptEntries.test.ts | 38 +++++++++ .../services/chat/chatTranscriptEntries.ts | 31 +++++++ .../Work/WorkErrorAndMessageHelpers.swift | 19 ++++- .../Work/WorkSessionDestinationView.swift | 8 +- apps/ios/ADETests/ADETests.swift | 81 +++++++++++++++++++ docs/features/chat/README.md | 2 +- 6 files changed, 171 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts index b9dcf0e473..7c79d8dc9b 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts @@ -158,6 +158,44 @@ describe("transcriptEntriesFromEnvelopes", () => { ]); }); + it("emits a graduated steer once, not as queued plus delivered", () => { + // Regression: the host writes a steered message twice — `queued` when it is + // staged and `inline` when the SDK consumes it. Emitting both showed the + // text twice in `ade chat read` and made iOS resurrect the queued strip + // entry after the bubble had already landed. + const entries = transcriptEntriesFromEnvelopes(SESSION, [ + envelope({ type: "user_message", text: "also run the linter", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued" }), + envelope({ type: "text", text: "working", messageId: "msg-a", turnId: "turn-1" }), + envelope({ type: "user_message", text: "also run the linter", turnId: "turn-1", steerId: "steer-1", deliveryState: "inline" }), + ]); + + expect(entries.map((entry) => [entry.role, entry.text])).toEqual([ + ["assistant", "working"], + ["user", "also run the linter"], + ]); + }); + + it("keeps a still-pending queued steer", () => { + const entries = transcriptEntriesFromEnvelopes(SESSION, [ + envelope({ type: "user_message", text: "queue me", turnId: "turn-1", steerId: "steer-2", deliveryState: "queued" }), + ]); + + expect(entries.map((entry) => [entry.role, entry.text])).toEqual([["user", "queue me"]]); + }); + + it("keeps a queued steer when only another steer graduated", () => { + const entries = transcriptEntriesFromEnvelopes(SESSION, [ + envelope({ type: "user_message", text: "first", turnId: "turn-1", steerId: "steer-a", deliveryState: "queued" }), + envelope({ type: "user_message", text: "second", turnId: "turn-1", steerId: "steer-b", deliveryState: "queued" }), + envelope({ type: "user_message", text: "first", turnId: "turn-1", steerId: "steer-a", deliveryState: "inline" }), + ]); + + expect(entries.map((entry) => [entry.role, entry.text])).toEqual([ + ["user", "second"], + ["user", "first"], + ]); + }); + it("preserves message and item identity on entries", () => { const entries = transcriptEntriesFromEnvelopes(SESSION, [ envelope({ type: "text", text: "Stable identified message.", messageId: "message-1", itemId: "item-1", turnId: "turn-ids" }), diff --git a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts index a49550fa78..4a7955760f 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts @@ -116,11 +116,38 @@ export type TranscriptEntriesOptions = { onEntrySourceOffset?: (offset: number | null) => void; }; +/** + * Steer ids that already have a non-queued `user_message` in this batch. + * + * The host writes a steered message twice: once as `deliveryState: "queued"` + * when it is staged, and again when the provider consumes it. Both are real + * transcript history, but they are one message — emitting both makes + * `ade chat read` (and every client that merges this canonical text against the + * live event stream) show the text twice. A still-pending queued steer has no + * graduating row and is kept, because it is the only record of that message. + */ +function graduatedSteerIds( + sessionId: string, + envelopes: readonly AgentChatEventEnvelope[], +): ReadonlySet { + const graduated = new Set(); + for (const entry of envelopes) { + if (entry.sessionId !== sessionId) continue; + if (entry.event.type !== "user_message") continue; + const steerId = entry.event.steerId?.trim(); + if (!steerId) continue; + if (entry.event.deliveryState === "queued") continue; + graduated.add(steerId); + } + return graduated; +} + export function transcriptEntriesFromEnvelopes( sessionId: string, envelopes: readonly AgentChatEventEnvelope[], options?: TranscriptEntriesOptions, ): AgentChatTranscriptEntry[] { + const graduated = graduatedSteerIds(sessionId, envelopes); type TranscriptDraftEntry = AgentChatTranscriptEntry & Partial; const entries: TranscriptDraftEntry[] = []; const sourceOffsetByDraft = new WeakMap(); @@ -173,6 +200,10 @@ export function transcriptEntriesFromEnvelopes( flushAssistantDraft(); openStreamKey = null; assistantDraftsByKey.clear(); + const steerId = entry.event.steerId?.trim(); + // A queued steer whose delivered twin is in the same batch is the same + // message; the delivered row carries it. + if (entry.event.deliveryState === "queued" && steerId && graduated.has(steerId)) continue; const text = entry.event.text.trim(); if (!text.length) continue; const displayText = typeof entry.event.displayText === "string" && entry.event.displayText.trim().length > 0 diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 9ff9e4cd7c..c04c3eeac8 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -982,7 +982,13 @@ func preferredWorkTranscript( ) -> [WorkChatEnvelope] { if !eventTranscript.isEmpty { let base = isFallbackOnlyWorkTranscript(current) ? [] : current - let merged = mergeWorkChatTranscripts(base: base, live: eventTranscript) + // Prune before the backfill, not only after it. A queued row whose steer + // has already graduated in the same live stream still looks pending to + // `shouldSkipBackfillPlainUserMessage`, which would then drop the canonical + // fallback bubble for a message that was actually delivered. + let merged = pruneResolvedQueuedSteerEnvelopes( + mergeWorkChatTranscripts(base: base, live: eventTranscript) + ) // The live event stream may be missing tail envelopes after a disconnect // or when the host didn't replay the full snapshot on re-subscribe. The // `chat.getTranscript` fallback always contains the canonical user / @@ -1032,6 +1038,17 @@ func preferredWorkTranscript( return pruned } +/// The live event stream an idle session hands to `preferredWorkTranscript`. +/// Resolved queued steers are pruned *first*: the filter below keeps queued +/// rows and drops every other user row, so a stale queued row left in place +/// outlives the delivered row that graduated it and reappears in the staged +/// strip while its bubble is suppressed in the thread. +func workChatIdleCanonicalEventTranscript(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvelope] { + pruneResolvedQueuedSteerEnvelopes(transcript).filter { envelope in + workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) + } +} + /// When an idle session prefers the canonical text transcript, keep tool / /// notice / queued-steer envelopes from the live event stream but drop the /// plain user/assistant/status rows the fallback already owns. diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index faf3daa7ec..145e7eb22d 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -2585,9 +2585,7 @@ struct WorkSessionDestinationView: View { ) let canonicalEventTranscript: [WorkChatEnvelope] if shouldPreferFallbackTranscript { - canonicalEventTranscript = eventTranscript.filter { envelope in - workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) - } + canonicalEventTranscript = workChatIdleCanonicalEventTranscript(eventTranscript) } else { canonicalEventTranscript = eventTranscript } @@ -3197,9 +3195,7 @@ struct WorkSessionDestinationView: View { ) let canonicalLiveTranscript: [WorkChatEnvelope] if shouldPreferFallbackTranscript { - canonicalLiveTranscript = liveTranscript.filter { envelope in - workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) - } + canonicalLiveTranscript = workChatIdleCanonicalEventTranscript(liveTranscript) } else { canonicalLiveTranscript = liveTranscript } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ddf05d5374..c12f34db4d 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -21052,6 +21052,87 @@ final class ADETests: XCTestCase { XCTAssertEqual(derivePendingWorkSteers(from: preferred).map(\.id), ["steer-1"]) } + func testGraduatedQueuedSteerRendersOnceAndLeavesNoPendingSteer() { + // Regression (chat 67757bac, 2026-09-19): the host writes a steered message + // twice — `queued` when staged, `inline` when the SDK consumes it — and the + // canonical text transcript returned both as plain user rows. The idle + // rebuild kept only the stale queued row, so the bubble vanished from the + // thread and the message reappeared in the staged strip ~10s after it had + // already been delivered. + let live = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-09-19T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "also run the linter", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-09-19T00:00:03.000Z", + sequence: 3, + event: .userMessage(text: "also run the linter", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "inline", processed: nil) + ), + ] + // What `chat.getTranscript` really returns today: both rows flattened to + // plain user entries with no steer metadata. + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-09-19T00:00:01.000Z", + sequence: nil, + event: .userMessage(text: "also run the linter", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-09-19T00:00:03.000Z", + sequence: nil, + event: .userMessage(text: "also run the linter", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + ), + ] + + for current in [[WorkChatEnvelope](), live] { + let preferred = preferredWorkTranscript( + current: current, + fallback: fallback, + eventTranscript: live + ) + XCTAssertEqual(buildWorkChatMessages(from: preferred).map(\.markdown), ["also run the linter"]) + XCTAssertTrue(derivePendingWorkSteers(from: preferred).isEmpty) + } + + // The idle rebuild path: prune runs before the canonical filter, so the + // stale queued row never survives into the merge. + XCTAssertTrue(workChatShouldPreferFallbackTranscript( + fallbackTranscript: { fallback }, + sessionStatus: "idle", + liveTranscript: live + )) + let canonical = workChatIdleCanonicalEventTranscript(live) + XCTAssertTrue(canonical.isEmpty) + for current in [[WorkChatEnvelope](), live] { + let idle = preferredWorkTranscript( + current: current, + fallback: fallback, + eventTranscript: canonical + ) + XCTAssertEqual(buildWorkChatMessages(from: idle).map(\.markdown), ["also run the linter"]) + XCTAssertTrue(derivePendingWorkSteers(from: idle).isEmpty) + } + } + + func testIdleCanonicalEventTranscriptKeepsStillPendingQueuedSteer() { + let live = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-09-19T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "stage me", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + ] + let canonical = workChatIdleCanonicalEventTranscript(live) + XCTAssertEqual(derivePendingWorkSteers(from: canonical).map(\.id), ["steer-1"]) + } + func testWorkTimelineHidesLocalEchoWhenQueuedSteerCoversSameText() { let transcript = [ WorkChatEnvelope( diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index de9780cd37..68c2577d84 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -66,7 +66,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/slashCommandPromptExpansion.ts` | Provider-routed slash command prompt expansion. Given a provider, cwd, and slash command input, resolves the command's markdown body into prompt text through the appropriate provider-specific resolution path (Claude, Codex, or Cursor). Built-in and runtime-registered commands are skipped so the SDK handles them natively. Consumed by `agentChatService` to expand slash commands before dispatch. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. Each flush carries a reason (`timer`, `identityBreak`, `interleave`) that `services/perf/chatTextProbe.ts` records as a `chatTextFlush` event during a perf run. | | `apps/desktop/src/main/services/perf/chatTextProbe.ts` | Perf-run-only probe over the text coalescer: counts the deltas folded into each flush and emits `chatTextFlush` (chars, deltas coalesced, ms since the previous flush, reason, pid). Zero-cost and allocation-free when `ADE_PERF_RUN_ID` is unset. Lazily calls `initPerfRunFromEnv()` on first flush because the `ade` runtime daemon — which hosts chat sessions in a normal dev session — never loads `main.ts`. | -| `apps/desktop/src/main/services/chat/chatTranscriptEntries.ts` | Flattens an envelope stream into the canonical role-tagged `AgentChatTranscriptEntry[]` behind `chat.getTranscript`, the cursor-paged chat-history reader, and the internal auto-title/handoff transcript reads. Its invariant is that a message's canonical text is the **verbatim** concatenation of its `text` fragments: fragments sharing provider identity (the `messageId`/`itemId` *pair* — Claude sets only `messageId`, Codex sets `messageId` per turn and `itemId` per message, so either alone merges distinct messages) concatenate untouched no matter what interleaves, and only `isTranscriptContentEvent` rows (a deliberate *allowlist* of rendered types) end a run, and only for fragments ADE cannot tie to a provider message. Whitespace-only fragments are real deltas and are dropped only when no run is open to continue. Inventing a `"\n\n"` here spliced separators mid-word and made clients that reconcile canonical text against the live stream (iOS, web) render a message twice. See [Transcript and turns](transcript-and-turns.md#canonical-assistant-text-fragile--read-before-editing). | +| `apps/desktop/src/main/services/chat/chatTranscriptEntries.ts` | Flattens an envelope stream into the canonical role-tagged `AgentChatTranscriptEntry[]` behind `chat.getTranscript`, the cursor-paged chat-history reader, and the internal auto-title/handoff transcript reads. Its invariant is that a message's canonical text is the **verbatim** concatenation of its `text` fragments: fragments sharing provider identity (the `messageId`/`itemId` *pair* — Claude sets only `messageId`, Codex sets `messageId` per turn and `itemId` per message, so either alone merges distinct messages) concatenate untouched no matter what interleaves, and only `isTranscriptContentEvent` rows (a deliberate *allowlist* of rendered types) end a run, and only for fragments ADE cannot tie to a provider message. Whitespace-only fragments are real deltas and are dropped only when no run is open to continue. Inventing a `"\n\n"` here spliced separators mid-word and made clients that reconcile canonical text against the live stream (iOS, web) render a message twice. A steered message is written twice as `user_message` — `deliveryState: "queued"` when it is staged, and again when the provider consumes it — so a queued row whose steer id also has a non-queued row in the same batch is skipped and only the graduating row becomes an entry; a still-pending queued steer is the only record of that message and is kept. See [Transcript and turns](transcript-and-turns.md#canonical-assistant-text-fragile--read-before-editing). | | `apps/desktop/src/main/services/chat/claudeStructuredActivity.ts` | Normalizes Claude server-owned `web_search` / `web_fetch` and MCP content blocks into the same `web_search` and paired `tool_call` / `tool_result` events used by other providers, including deterministic turn-end closure for unfinished blocks. | | `apps/desktop/src/main/services/chat/openCodeStructuredActivity.ts` | Maps OpenCode image `file` parts into compact image-generation events, preserving a local saved path when the URL points at a file. | | `apps/desktop/src/main/services/chat/codexMcpElicitation.ts` | Converts Codex app-server MCP elicitation JSON Schemas into pending-input questions and coerces accepted form answers back to boolean/number/array/object values. Persistent consent is gated by request metadata. | From 6d2b137a68aab0cbc528fad26e13be90064610e0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 17:57:56 -0400 Subject: [PATCH 02/26] feat(ios): tap a staged message to open its detail sheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the queued-steer strip is one truncated line and four unlabelled glyphs, so a staged message cannot be read in full and its options cannot be identified before tapping one. Change and boundary: the row's summary is now a 44pt tap target — the icon buttons keep their own — presenting `WorkQueuedSteerDetailSheet`. The sheet shows the whole message selectable, its attachments, when it was staged, and what happens next, then lists Send now, Interrupt & send / Interrupt & continue (label from `capability.interruptContinues`), Edit and Cancel as full-width rows with a one-line description each. Options the provider does not support are disabled with the reason instead of hidden. The capability comes from `workChatActiveSendCapability`, the same per-provider table the composer's send-mode picker reads, and every action calls the closure the inline buttons already use. The strip's disposition now reads "sends at next step" on a provider that takes a message mid-turn. Verification: swiftc -parse clean on the touched files. iOS tests and build not run: only 23 GB free on / (policy floor is 30 GB). Co-Authored-By: Claude Opus 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 4 + .../Work/WorkChatComposerAndInputViews.swift | 67 +++-- .../ADE/Views/Work/WorkChatSessionView.swift | 16 ++ .../Work/WorkQueuedSteerDetailSheet.swift | 253 ++++++++++++++++++ .../sync-and-multi-device/ios-companion.md | 36 ++- 5 files changed, 358 insertions(+), 18 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index d4e4bc5964..2457e85fee 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -119,6 +119,7 @@ E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000050 /* WorkChatAttachmentTray.swift */; }; E1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift */; }; E1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift */; }; + E1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift */; }; E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000052 /* LaneDeeplinkHelpers.swift */; }; E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000053 /* LaneDetailGitActionsPane.swift */; }; E10000000000000000000047 /* ADEInspectable.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000047 /* ADEInspectable.swift */; }; @@ -442,6 +443,7 @@ D10000000000000000000050 /* WorkChatAttachmentTray.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentTray.swift; path = ADE/Views/Work/WorkChatAttachmentTray.swift; sourceTree = ""; }; D1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerAttachmentStaging.swift; path = ADE/Views/Work/WorkComposerAttachmentStaging.swift; sourceTree = ""; }; D1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentPreviewSheets.swift; path = ADE/Views/Work/WorkChatAttachmentPreviewSheets.swift; sourceTree = ""; }; + D1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkQueuedSteerDetailSheet.swift; path = ADE/Views/Work/WorkQueuedSteerDetailSheet.swift; sourceTree = ""; }; D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = ""; }; D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = ""; }; D10000000000000000000047 /* ADEInspectable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADEInspectable.swift; path = ADE/Debug/ADEInspectorKit/ADEInspectable.swift; sourceTree = ""; }; @@ -975,6 +977,7 @@ D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift */, D1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift */, + D1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D10000000000000000000F03 /* WorkPromptStash.swift */, D10000000000000000000F04 /* WorkContextUsageViews.swift */, @@ -1727,6 +1730,7 @@ E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift in Sources */, E1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift in Sources */, + E1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, E10000000000000000000F03 /* WorkPromptStash.swift in Sources */, diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 7c9d6368b4..2102437bbe 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -594,6 +594,9 @@ struct WorkQueuedSteerStrip: View { @Binding var drafts: [String: String] let busy: Bool let isLive: Bool + /// The provider's active-turn delivery table, so the detail sheet can name + /// every option — including the ones this provider cannot do. + let capability: WorkActiveSendCapability /// Drives the queued card's live indicator: a message waiting behind a /// running turn breathes, one waiting on an idle session sits still. let turnActive: Bool @@ -613,6 +616,7 @@ struct WorkQueuedSteerStrip: View { ForEach(steers) { steer in WorkQueuedSteerRow( steer: steer, + capability: capability, draft: Binding( get: { drafts[steer.id] ?? steer.text }, set: { drafts[steer.id] = $0 } @@ -686,6 +690,7 @@ private struct WorkQueuedSteerWaitingGlyph: View { struct WorkQueuedSteerRow: View { let steer: WorkPendingSteerModel + let capability: WorkActiveSendCapability @Binding var draft: String let isEditing: Bool let busy: Bool @@ -700,6 +705,7 @@ struct WorkQueuedSteerRow: View { @StateObject private var dictationCoordinator = DictationInsertionCoordinator() @State private var isDictating = false + @State private var showingDetail = false private var dictationTargetId: String { "work-steer:\(steer.id)" } @@ -752,21 +758,36 @@ struct WorkQueuedSteerRow: View { // ("Inter-rupt") that the old titleAndIcon chips hit in this // non-scrolling HStack; the tap targets stay 44pt tall regardless. HStack(spacing: 8) { - WorkQueuedSteerWaitingGlyph(waiting: turnActive) - - VStack(alignment: .leading, spacing: 1) { - Text(steer.text) - .font(.system(size: 13.5)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - - Text(dispositionText) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) + // The summary is the tap target for the detail sheet — the icon + // buttons keep their own. A truncated line and four unlabelled + // glyphs are not enough to read a staged message or its options. + Button { + showingDetail = true + } label: { + HStack(spacing: 8) { + WorkQueuedSteerWaitingGlyph(waiting: turnActive) + + VStack(alignment: .leading, spacing: 1) { + Text(steer.text) + .font(.system(size: 13.5)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Text(dispositionText) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .contentShape(Rectangle()) } - .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .accessibilityLabel("Queued message: \(steer.text)") + .accessibilityHint("Opens the full message and its send options") + .accessibilityIdentifier("Work.Chat.StagedStrip.Open") HStack(spacing: 0) { if let onDispatchInline { @@ -824,12 +845,28 @@ struct WorkQueuedSteerRow: View { .stroke(ADEColor.glassBorder, lineWidth: 0.5) ) .accessibilityElement(children: .contain) + .sheet(isPresented: $showingDetail) { + WorkQueuedSteerDetailSheet( + steer: steer, + capability: capability, + turnActive: turnActive, + isLive: isLive, + busy: busy, + onDispatchInline: onDispatchInline, + onDispatchInterrupt: onDispatchInterrupt, + onBeginEdit: onBeginEdit, + onCancel: onCancel + ) + } } /// Says what happens next, not when it was typed: a queued message's /// timestamp is always "a moment ago" and carried no information. private var dispositionText: String { - turnActive ? "sends when turn ends" : "after turn" + guard turnActive else { return "after turn" } + // A provider that takes a message mid-turn is not parking this one until + // the turn ends — Claude picks it up at the next tool step. + return capability.modes.contains(.inline) ? "sends at next step" : "sends when turn ends" } // Icon-only tap target with an accessibility label so the row's actions stay diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 74f0e83dbf..4a5d498ed0 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1036,6 +1036,21 @@ struct WorkChatSessionView: View { ) } + /// The same per-provider table the composer's send-mode picker uses, read + /// once here for the staged-message strip's detail sheet so the two surfaces + /// can never disagree about what this provider can do with a staged message. + var stagedSteerCapability: WorkActiveSendCapability { + workChatActiveSendCapability( + provider: chatSummaryContext.provider, + liveRedirectOnly: liveRedirectOnlySends, + runsInCloud: workChatCursorSessionRunsInCloud( + provider: chatSummaryContext.provider, + cursorRuntime: chatSummaryContext.cursorRuntime, + cursorCloudAgentId: chatSummaryContext.cursorCloudAgentId + ) + ) + } + var primaryPendingInput: WorkPendingInputItem? { pendingInputs.first } @@ -1713,6 +1728,7 @@ struct WorkChatSessionView: View { drafts: $steerEditDrafts, busy: actionInFlight, isLive: isLive, + capability: stagedSteerCapability, turnActive: sessionStatus == "active", onCancel: { steerId in await runSessionAction { diff --git a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift new file mode 100644 index 0000000000..29dbf99a64 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift @@ -0,0 +1,253 @@ +import SwiftUI + +/// Detail for one staged message. +/// +/// The strip row is icon-only, so a long message is truncated to a line and the +/// four actions are unlabelled glyphs. Tapping the row opens this sheet: the +/// whole message, when it was staged, what happens to it next, and every +/// delivery option the provider has — including the ones it does not support, +/// shown disabled with the reason rather than quietly dropped. +struct WorkQueuedSteerDetailSheet: View { + let steer: WorkPendingSteerModel + let capability: WorkActiveSendCapability + /// True while the turn this message is waiting behind is running. + let turnActive: Bool + /// False when the host is unreachable — every action is a host round trip. + let isLive: Bool + let busy: Bool + let onDispatchInline: (@MainActor () async -> Void)? + let onDispatchInterrupt: (@MainActor () async -> Void)? + let onBeginEdit: () -> Void + let onCancel: @MainActor () async -> Void + + @Environment(\.dismiss) private var dismiss + @State private var actionInFlight = false + + private var controlsEnabled: Bool { isLive && !busy && !actionInFlight } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + header + messageBody + if let attachments = steer.attachments, !attachments.isEmpty { + attachmentChips(attachments) + } + VStack(spacing: 8) { + sendNowRow + interruptRow + editRow + cancelRow + } + if !isLive { + Text("Reconnect to this chat to change a staged message.") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + } + .padding(.horizontal, 20) + .padding(.top, 22) + .padding(.bottom, 24) + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(ADEColor.surfaceBackground) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .accessibilityIdentifier("Work.Chat.StagedStrip.DetailSheet") + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(stateTitle) + .font(.headline) + .foregroundStyle(ADEColor.textPrimary) + Text(stateDetail) + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + /// Selectable so a long prompt can be copied back out, and scrollable through + /// the sheet's own ScrollView rather than a nested clipped box. + private var messageBody: some View { + Text(steer.text) + .font(.system(size: 15)) + .foregroundStyle(ADEColor.textPrimary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(ADEColor.raisedBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + .accessibilityIdentifier("Work.Chat.StagedStrip.DetailSheet.Message") + } + + private func attachmentChips(_ attachments: [AgentChatFileRef]) -> some View { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(attachments.enumerated()), id: \.offset) { _, ref in + HStack(spacing: 6) { + Image(systemName: workChatAttachmentIsImage(ref) ? "photo" : "doc") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + Text(workChatAttachmentDisplayName(ref)) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(workChatAttachmentAccessibilityLabel(attachments)) + } + + private var stateTitle: String { + turnActive ? "Queued" : "Waiting to send" + } + + /// Names the provider and says what actually happens next. A Claude steer is + /// picked up at the next tool step, not parked until the turn ends, and the + /// strip's one-line disposition had no room to say so. + private var stateDetail: String { + let staged = "Staged \(relativeTimestamp(steer.timestamp))." + guard turnActive else { + return "\(staged) It sends as soon as \(capability.agentLabel) is ready." + } + if capability.modes.contains(.inline) { + return "\(staged) \(capability.agentLabel) picks it up after the current tool step." + } + return "\(staged) \(capability.agentLabel) can't take a message mid-turn, so it sends when this turn ends." + } + + private var sendNowRow: some View { + optionRow( + systemImage: "arrow.turn.down.right", + tint: ADEColor.accent, + title: "Send now", + description: "\(capability.agentLabel) picks it up after the current tool step.", + unavailableReason: sendNowUnavailableReason, + identifier: "Work.Chat.StagedStrip.DetailSheet.SendNow" + ) { + await onDispatchInline?() + } + } + + private var sendNowUnavailableReason: String? { + if !capability.modes.contains(.inline) { + return "\(capability.agentLabel) can't take a message mid-turn." + } + if onDispatchInline == nil { return "Not available on this session." } + if !turnActive { return "No turn is running." } + return nil + } + + private var interruptRow: some View { + optionRow( + systemImage: "bolt.fill", + tint: ADEColor.warning, + title: capability.interruptContinues ? "Interrupt & continue" : "Interrupt & send", + description: capability.interruptContinues + ? "Stop the current \(capability.agentLabel) turn and continue with this message." + : "Stop \(capability.agentLabel) now and redirect it to this message.", + unavailableReason: interruptUnavailableReason, + identifier: "Work.Chat.StagedStrip.DetailSheet.Interrupt" + ) { + await onDispatchInterrupt?() + } + } + + private var interruptUnavailableReason: String? { + if !capability.modes.contains(.interrupt) { + return "\(capability.agentLabel) can't be interrupted with a message." + } + if onDispatchInterrupt == nil { return "Not available on this session." } + if !turnActive { return "No turn is running." } + return nil + } + + private var editRow: some View { + optionRow( + systemImage: "pencil", + tint: ADEColor.textSecondary, + title: "Edit", + description: "Change the text before it sends.", + unavailableReason: nil, + identifier: "Work.Chat.StagedStrip.DetailSheet.Edit" + ) { + onBeginEdit() + } + } + + private var cancelRow: some View { + optionRow( + systemImage: "xmark", + tint: ADEColor.danger, + title: "Cancel", + description: "Remove it from the queue. The turn keeps running.", + unavailableReason: nil, + identifier: "Work.Chat.StagedStrip.DetailSheet.Cancel" + ) { + await onCancel() + } + } + + /// Full-width row: title, one line of description, and — when the provider + /// cannot do it — the reason in place of that line, disabled rather than + /// hidden so the option set reads the same on every provider. + @ViewBuilder + private func optionRow( + systemImage: String, + tint: Color, + title: String, + description: String, + unavailableReason: String?, + identifier: String, + action: @escaping @MainActor () async -> Void + ) -> some View { + let disabled = unavailableReason != nil || !controlsEnabled + Button { + Task { + actionInFlight = true + await action() + actionInFlight = false + dismiss() + } + } label: { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 20, height: 20) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(unavailableReason ?? description) + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) + .background(ADEColor.raisedBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 0.5) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .disabled(disabled) + .opacity(disabled ? 0.5 : 1) + .accessibilityIdentifier(identifier) + .accessibilityHint(unavailableReason ?? description) + } +} diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 51f50f7ea3..4d98519880 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -3816,9 +3816,24 @@ the stats and shows update guidance. disposition beneath, and **Send now** / **Interrupt** / **Edit** / **Cancel** as visible icon-only buttons with 44pt-tall touch areas. Only a pile-up gets a slim `N queued` count above the rows. While a turn is running the clock - glyph breathes and the disposition reads "sends when turn ends"; on an idle - session it sits still and reads "after turn". The pulse goes through - `ADEMotion.pulse`, so Reduce Motion simply draws the glyph at full strength. + glyph breathes and the disposition reads "sends at next step" on a provider + that takes a message mid-turn and "sends when turn ends" on one that does + not; on an idle session it sits still and reads "after turn". The pulse goes + through `ADEMotion.pulse`, so Reduce Motion simply draws the glyph at full + strength. +- **Tapping a staged row opens its detail sheet.** A truncated line and four + unlabelled glyphs cannot show a long message or say what each option does, so + the row's summary is a 44pt tap target (the icon buttons keep their own) that + presents `WorkQueuedSteerDetailSheet`. The sheet shows the whole message + selectable, its attachments, when it was staged, and what happens to it next, + then lists every delivery option as a full-width row: **Send now**, + **Interrupt & send** / **Interrupt & continue** (the label follows + `capability.interruptContinues`), **Edit**, and **Cancel**. Options this + provider cannot do are disabled with the reason on the row rather than + hidden, so the option set reads the same on every provider. The capability + comes from `workChatActiveSendCapability` — the same per-provider table the + composer's send-mode picker reads — and every action calls the closure the + inline buttons already use, then dismisses. - **The Work context meter treats completed compaction as a usage boundary.** `RemoteModels.swift`, `WorkEventMapping.swift`, and the persisted JSONL parser retain `context_compact.postTokens` plus the automatic `context_usage.state`. @@ -3967,6 +3982,21 @@ the stats and shows update guidance. neither is whole — keeping only the longer one silently dropped part of the answer. Fix disagreements on the host, not with a client rule that has to guess what a sequence-less envelope contains. +- **A graduated steer is pruned before the idle rebuild filters anything.** + The host writes a steered message twice: a `deliveryState: "queued"` row when + it is staged and a non-queued row when the provider consumes it. An idle + session prefers the canonical `chat.getTranscript` text and keeps only tool / + notice / queued-steer envelopes from the live stream, so a stale queued row + that outlived its delivered twin would be the one row the filter kept — the + bubble disappeared from the thread and the message reappeared in the staged + strip after it had already been sent. `workChatIdleCanonicalEventTranscript` + runs `pruneResolvedQueuedSteerEnvelopes` first and filters second, and + `preferredWorkTranscript` prunes the merged live transcript before the + fallback backfill so `shouldSkipBackfillPlainUserMessage` never suppresses a + canonical bubble on the strength of a resolved queued row. A still-pending + queued steer has no graduating row, so both prunes leave it exactly where it + is. The host half of the same fix is in `transcriptEntriesFromEnvelopes`, + which now emits a graduated steer once. - **CLI launcher provider IDs are runtime-validated.** The Work new-session screen sends `provider` strings that `parseCliProvider` matches verbatim against From e9c0238f92b0d28cf727cb5380f3391b8de37724 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:03:43 -0400 Subject: [PATCH 03/26] test(ios): fixture screen for the queued-steer detail sheet `-adePreviewScreen queued-steer` opens `WorkQueuedSteerDetailSheet` over a plain backdrop with a staged Claude message, two attachments and the large detent pinned, so one `simctl io screenshot` shows the whole message and all four delivery options with no brain, no pairing and no network. Verification: xcodebuild build clean; the named queued-steer tests plus WorkSessionCanonicalStateTests re-run green (96 tests, 0 failures) on iPhone 17 Pro / iOS 26. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Views/Work/WorkPreviews.swift | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 220baf748d..5f77584fff 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -1172,6 +1172,7 @@ enum ADEPreviewScreen: String, CaseIterable { case proofEmpty = "proof-empty" case proofViewer = "proof-viewer" case tools = "tools" + case queuedSteerDetail = "queued-steer" /// `-adePreviewScreen `. Matches the shape `simctl launch` and the /// Xcode scheme editor both use for launch arguments. @@ -1226,10 +1227,70 @@ struct ADEPreviewScreenHost: View { previewState: WorkProofPreviewData.toolsState, previewFrame: WorkProofPreviewData.toolsFrame ) + case .queuedSteerDetail: + // Presented as a real sheet rather than rendered full-bleed, so the + // detents and drag indicator the sheet declares are the ones in the + // screenshot. + WorkQueuedSteerPreviewHost() } } } +/// Backdrop that opens `WorkQueuedSteerDetailSheet` on appear, so the fixture +/// screenshot shows the sheet as the phone presents it. +private struct WorkQueuedSteerPreviewHost: View { + @State private var presented = false + + var body: some View { + ADEColor.surfaceBackground + .ignoresSafeArea() + .onAppear { presented = true } + .sheet(isPresented: $presented) { + WorkQueuedSteerDetailSheet( + steer: WorkQueuedSteerPreviewData.steer, + capability: WorkQueuedSteerPreviewData.capability, + turnActive: true, + isLive: true, + busy: false, + onDispatchInline: {}, + onDispatchInterrupt: {}, + onBeginEdit: {}, + onCancel: {} + ) + // The sheet offers [.medium, .large]; the fixture pins the large + // detent so one screenshot shows every delivery option. + .presentationDetents([.large]) + } + } +} + +@MainActor +private enum WorkQueuedSteerPreviewData { + static let steer = WorkPendingSteerModel( + id: "preview-steer-1", + text: """ + Before you touch the transcript merge, re-read how the host writes a steered \ + message twice — `deliveryState: "queued"` when it is staged and non-queued \ + once the provider consumes it. Both rows reach the phone, so the prune has to \ + run before the idle filter, not after it. + + Then add the regression test for a still-pending steer: it has no graduating \ + row and must survive both prunes untouched. + """, + attachments: [ + AgentChatFileRef(path: "apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift", type: "file"), + AgentChatFileRef(path: "docs/features/chat/README.md", type: "file"), + ], + turnId: "preview-turn-1", + timestamp: WorkPreviewData.timestamp + ) + + static let capability = workChatActiveSendCapability( + provider: "claude", + liveRedirectOnly: false + ) +} + #Preview("Proof sheet") { ADEPreviewScreenHost(screen: .proofList) .environmentObject(WorkPreviewData.syncService) @@ -1248,6 +1309,12 @@ struct ADEPreviewScreenHost: View { .preferredColorScheme(.dark) } +#Preview("Queued steer detail") { + ADEPreviewScreenHost(screen: .queuedSteerDetail) + .environmentObject(WorkPreviewData.syncService) + .preferredColorScheme(.dark) +} + #Preview("Work tools sheet") { ADEPreviewScreenHost(screen: .tools) .environmentObject(WorkPreviewData.syncService) From 917f3c5591eab76dc549cf234b1772624a09e6ea Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:12:32 -0400 Subject: [PATCH 04/26] feat(sync): slim mobile chat wire behind a phone-announced capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: a phone downloads the same chat wire as desktop. On a real 16.1 MB / 21,665-event thread, 35% of the bytes are subagent progress — sent twice, because a dot-family mirror is committed next to every underscore event — and 23% are tool results the phone shows at most one screen of. Change and boundary: adds `mobileChatSlimV1`. For peers that announce it the host drops the mirrored `subagent.progress` twin, folds subagent progress to the latest per agent in the chat_subscribe snapshot, coalesces it to at most one send per second per agent live, and caps tool results at ~2 KB with a new `chat_tool_result` request that returns the stored result on demand. Nothing is changed about what is persisted, what agents do, or what desktop, hosted web, the TUI or the CLI receive. Verification: npm --prefix apps/desktop run typecheck, npm --prefix apps/ade-cli run typecheck, 22 new chatMobileSlim tests, 8 new chatToolResultLookup tests, 2 new syncHostService tests, and the existing chatReplayFold (31) / chatEventCompaction (18) / chat event replay buffer (13) blocks green. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.test.ts | 34 ++ .../src/services/sync/syncHostService.ts | 258 ++++++++++- .../chat/chatToolResultLookup.test.ts | 109 +++++ .../services/chat/chatToolResultLookup.ts | 76 ++++ .../desktop/src/shared/chatMobileSlim.test.ts | 261 +++++++++++ apps/desktop/src/shared/chatMobileSlim.ts | 408 ++++++++++++++++++ apps/desktop/src/shared/types/chat.ts | 8 + apps/desktop/src/shared/types/sync.ts | 48 +++ 8 files changed, 1192 insertions(+), 10 deletions(-) create mode 100644 apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts create mode 100644 apps/desktop/src/main/services/chat/chatToolResultLookup.ts create mode 100644 apps/desktop/src/shared/chatMobileSlim.test.ts create mode 100644 apps/desktop/src/shared/chatMobileSlim.ts diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 8d2fe0e2df..dff1dd9a95 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -49,6 +49,7 @@ import { buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, compactChatEventEnvelopeForSync, + compactChatEventEnvelopeForMobileSync, createChatEventReplayBuffer, createSyncHostService, createTerminalInputDedupeLedger, @@ -11588,6 +11589,39 @@ describe("chat_subscribe snapshots", () => { }); +describe("mobile chat wire (mobileChatSlimV1)", () => { + const toolResultEnvelope = (result: unknown) => ({ + sessionId: "session-slim", + timestamp: "2026-09-19T12:00:00.000Z", + sequence: 1, + event: { + type: "tool_result" as const, + tool: "Bash", + result, + itemId: "item-1", + status: "completed" as const, + }, + }); + + it("caps a tool result far below the shared 16 KB wire cap and flags the fetch", () => { + const envelope = toolResultEnvelope("x".repeat(60_000)); + const shared = compactChatEventEnvelopeForSync(envelope) as typeof envelope; + const mobile = compactChatEventEnvelopeForMobileSync(envelope) as typeof envelope; + const sharedBytes = Buffer.byteLength(JSON.stringify(shared.event.result), "utf8"); + const mobileBytes = Buffer.byteLength(JSON.stringify(mobile.event.result), "utf8"); + expect(sharedBytes).toBeGreaterThan(8_000); + expect(mobileBytes).toBeLessThan(3_000); + expect((mobile.event as { resultTruncatedForMobile?: boolean }).resultTruncatedForMobile).toBe(true); + // The shared wire — desktop, hosted web, the TUI — is untouched. + expect((shared.event as { resultTruncatedForMobile?: boolean }).resultTruncatedForMobile).toBeUndefined(); + }); + + it("leaves a small tool result identical on both wires", () => { + const envelope = toolResultEnvelope("exit 0"); + expect(compactChatEventEnvelopeForMobileSync(envelope)).toEqual(compactChatEventEnvelopeForSync(envelope)); + }); +}); + describe("chat event replay buffer (resumable chat streams)", () => { const sessionId = "session-replay"; diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index da8c653432..979bf8aaaa 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -62,6 +62,8 @@ import type { SyncEnvelope, SyncChatEventPayload, SyncChatHistoryRequestPayload, + SyncChatToolResultRequestPayload, + SyncChatToolResultResponsePayload, SyncChatSubscribePayload, SyncChatSubscribeSnapshotPayload, SyncChatUnsubscribePayload, @@ -112,10 +114,19 @@ import { SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, + SYNC_MOBILE_CHAT_SLIM_CAPABILITY, } from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; import { foldChatEventEnvelopesForReplay } from "../../../../desktop/src/shared/chatReplayFold"; +import { + compactChatEventForMobileWire, + createSubagentProgressCoalescer, + foldSubagentProgressForSnapshot, + MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS, + type SubagentProgressCoalescer, +} from "../../../../desktop/src/shared/chatMobileSlim"; import { readTranscriptHistoryPage } from "../../../../desktop/src/main/services/chat/chatTranscriptHistoryPager"; +import { findStoredToolResult } from "../../../../desktop/src/main/services/chat/chatToolResultLookup"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; import type { ProductAnalyticsService } from "../../../../desktop/src/main/services/analytics/productAnalyticsService"; import type { AccountAuthService } from "../account/accountAuthService"; @@ -873,6 +884,11 @@ type PeerState = { nextTerminalSnapshotGeneration: number; subscribedChatSessionIds: Set; hydratingChatSessionIds: Set; + /** + * Per-session live subagent-progress coalescers, for peers that announced + * `mobileChatSlimV1`. Empty for every other client. + */ + subagentProgressCoalescers: Map; chatSubscriptionBindings: Map; chatTranscriptOffsets: Map; // Progress while scanning one JSONL record that exceeded a normal bounded @@ -1509,6 +1525,7 @@ const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set(); - function compactChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope { - const cached = compactedSyncEnvelopes.get(event); + const compactedMobileSyncEnvelopes = new WeakMap(); + function compactChatEventEnvelopeOnce( + event: AgentChatEventEnvelope, + slim: boolean, + ): AgentChatEventEnvelope { + const cache = slim ? compactedMobileSyncEnvelopes : compactedSyncEnvelopes; + const cached = cache.get(event); if (cached) return cached; - const compacted = compactChatEventEnvelopeForSync(event); - compactedSyncEnvelopes.set(event, compacted); + const compacted = slim + ? compactChatEventEnvelopeForMobileSync(event) + : compactChatEventEnvelopeForSync(event); + cache.set(event, compacted); return compacted; } + /** + * Whether this peer asked for the slim mobile chat wire. Desktop, hosted web + * and the TUI never announce it, so their bytes are untouched. + */ + function peerWantsSlimChat(peer: PeerState): boolean { + return peer.metadata?.capabilities?.includes(SYNC_MOBILE_CHAT_SLIM_CAPABILITY) === true; + } + + function chatEventCoalescerFor(peer: PeerState, sessionId: string): SubagentProgressCoalescer { + let coalescer = peer.subagentProgressCoalescers.get(sessionId); + if (!coalescer) { + coalescer = createSubagentProgressCoalescer({ intervalMs: MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS }); + peer.subagentProgressCoalescers.set(sessionId, coalescer); + } + return coalescer; + } + + /** Put one already-decided event on the wire. */ + function deliverChatEvent( + peer: PeerState, + event: AgentChatEventEnvelope, + seq: number | null, + ): boolean { + const syncEvent = compactChatEventEnvelopeOnce(event, peerWantsSlimChat(peer)); + return send(peer.ws, "chat_event", { + ...syncEvent, + ...(seq == null ? {} : { seq }), + } satisfies SyncChatEventPayload); + } + function sendChatEvent(peer: PeerState, event: AgentChatEventEnvelope, seq: number): "sent" | "already-sent" | "failed" { if (chatEventAlreadySent(peer, event)) return "already-sent"; - const syncEvent = compactChatEventEnvelopeOnce(event); - const sent = send(peer.ws, "chat_event", { ...syncEvent, seq } satisfies SyncChatEventPayload); - if (sent) markChatEventSent(peer, event); - return sent ? "sent" : "failed"; + if (!peerWantsSlimChat(peer)) { + const sent = deliverChatEvent(peer, event, seq); + if (sent) markChatEventSent(peer, event); + return sent ? "sent" : "failed"; + } + // Coalescing decides what actually goes out. Mark the offered event + // delivered either way: an event the coalescer dropped as a mirror or + // superseded by a newer one for the same agent has been accounted for, and + // leaving it unmarked would let the transcript pump offer it again on the + // next tick — re-entering the coalescer forever. + const outbound = chatEventCoalescerFor(peer, event.sessionId).admit(event, seq, Date.now()); + markChatEventSent(peer, event); + for (const entry of outbound) { + if (!deliverChatEvent(peer, entry.event, entry.seq)) return "failed"; + } + return "sent"; + } + + /** + * Release subagent progress whose one-second window has closed. Driven by the + * existing poll pump (400 ms) rather than a per-peer timer, so a quiet + * session costs nothing. + */ + function flushDueChatEvents(peer: PeerState): void { + if (peer.subagentProgressCoalescers.size === 0) return; + const nowMs = Date.now(); + for (const coalescer of peer.subagentProgressCoalescers.values()) { + for (const entry of coalescer.flushDue(nowMs)) { + if (!deliverChatEvent(peer, entry.event, entry.seq)) return; + } + } + } + + /** Nothing pending may outlive the subscription that produced it. */ + function discardChatEventCoalescer(peer: PeerState, sessionId: string): void { + peer.subagentProgressCoalescers.delete(sessionId); } async function pumpChatEvents(peer: PeerState): Promise { if (disposed || !peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) return; if (isPeerBackpressured(peer)) return; + flushDueChatEvents(peer); for (const sessionId of peer.subscribedChatSessionIds) { if (peer.hydratingChatSessionIds.has(sessionId)) continue; // A foreign quick-look session has no local row; tail its resolved @@ -6708,6 +6808,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { sessionFound: false, unavailable: true, } satisfies AgentChatEventHistoryPage, requestId); + return; + } + + if (type === "chat_tool_result") { + const toolPayload = (payload ?? {}) as { sessionId?: string; itemId?: string }; + sendRequired(peer, "chat_tool_result", { + sessionId: toOptionalString(toolPayload.sessionId) ?? "", + itemId: toOptionalString(toolPayload.itemId) ?? "", + found: false, + unavailable: true, + } satisfies SyncChatToolResultResponsePayload, requestId); } } @@ -7907,6 +8018,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { envelope.type === "chat_subscribe" || envelope.type === "chat_unsubscribe" || envelope.type === "chat_history" + || envelope.type === "chat_tool_result" ) && envelopePayload?.chatScope === "personal"; const projectScope: SyncHostProjectScopeResolution = personalChatEnvelope @@ -8417,6 +8529,97 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } break; } + case "chat_tool_result": { + // The other half of the slim mobile wire: the phone received a bounded + // head slice of this tool result and the user expanded the row. Scoped + // exactly like `chat_history` — the peer must already be subscribed to + // the session, and the requested scope must match the subscription, so + // a phone cannot read a personal or foreign-project transcript it is + // not attached to. + const payload = envelope.payload as SyncChatToolResultRequestPayload | null; + const sessionId = toOptionalString(payload?.sessionId); + const itemId = toOptionalString(payload?.itemId); + const unavailable = (): SyncChatToolResultResponsePayload => ({ + sessionId: sessionId ?? "", + itemId: itemId ?? "", + found: false, + unavailable: true, + }); + if (!sessionId || !itemId) { + sendRequired(peer, "chat_tool_result", unavailable(), envelope.requestId); + break; + } + const subscribedBinding = peer.chatSubscriptionBindings.get(sessionId); + if ( + !peer.subscribedChatSessionIds.has(sessionId) + || !chatSubscriptionMatchesRequest(subscribedBinding, payload, sessionId) + ) { + args.logger.warn("sync.chat_tool_result_unsubscribed_or_scope_mismatch", { + sessionId, + subscribedScope: subscribedBinding?.scope ?? null, + requestedScope: requestedChatSubscriptionScope(payload), + }); + sendRequired(peer, "chat_tool_result", unavailable(), envelope.requestId); + break; + } + try { + const configuredTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId) + ?? args.sessionService.get(sessionId)?.transcriptPath + ?? null; + const transcriptPath = configuredTranscriptPath + ? resolveReadableHistoryPath(configuredTranscriptPath) ?? configuredTranscriptPath + : null; + if (!transcriptPath) { + sendRequired(peer, "chat_tool_result", unavailable(), envelope.requestId); + break; + } + const hit = await runWithAbortSignal( + () => findStoredToolResult({ + transcriptPath, + sessionId, + itemId, + ...(signal ? { signal } : {}), + }), + signal, + "Sync operation aborted.", + ); + if (!hit) { + // Found nothing is not the same as could not look: the phone shows + // "no longer available" rather than an error it could retry. + sendRequired(peer, "chat_tool_result", { + sessionId, + itemId, + found: false, + } satisfies SyncChatToolResultResponsePayload, envelope.requestId); + break; + } + // The stored event, with the shared storage caps already applied to + // it — never the raw provider payload. The phone-only cap is the one + // thing this response deliberately does not apply. + const stored = compactChatEventForWire(hit.event); + sendRequired(peer, "chat_tool_result", { + sessionId, + itemId, + found: true, + result: stored.type === "tool_result" ? stored.result : undefined, + ...(stored.type === "tool_result" && typeof stored.resultOriginalBytes === "number" + ? { resultOriginalBytes: stored.resultOriginalBytes } + : {}), + ...(stored.type === "tool_result" && typeof stored.resultOmittedBytes === "number" + ? { resultOmittedBytes: stored.resultOmittedBytes } + : {}), + ...(stored.type === "tool_result" && stored.status ? { status: stored.status } : {}), + ...(stored.type === "tool_result" && stored.tool ? { tool: stored.tool } : {}), + } satisfies SyncChatToolResultResponsePayload, envelope.requestId); + } catch (error) { + args.logger.warn("sync.chat_tool_result_failed", { + sessionId, + error: error instanceof Error ? error.message : String(error), + }); + sendRequired(peer, "chat_tool_result", unavailable(), envelope.requestId); + } + break; + } case "chat_subscribe": { const payload = envelope.payload as SyncChatSubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); @@ -8543,6 +8746,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // guarantees the peer has (or will get) them. if (sendChatEvent(peer, entry.event, entry.seq) === "failed") break; } + // A resume is a catch-up burst, not a live stream: nothing the + // coalescer is holding should wait a further second behind it. + if (peerWantsSlimChat(peer)) { + for (const entry of chatEventCoalescerFor(peer, sessionId).flushAll(Date.now())) { + if (!deliverChatEvent(peer, entry.event, entry.seq)) break; + } + } args.logger.debug("sync_host.chat_subscribe_resumed", { sessionId, sinceSeq: payload?.sinceSeq, @@ -8591,7 +8801,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { hasOlderHistory = history?.hasOlderHistory ?? (history?.truncated === true && tailStartOffset > 0); } - events = events.map(compactChatEventEnvelopeForSync); + const slimChatPeer = peerWantsSlimChat(peer); + events = events.map( + slimChatPeer ? compactChatEventEnvelopeForMobileSync : compactChatEventEnvelopeForSync, + ); // Fold streaming deltas into the message they belong to. Snapshot-only // and capability-gated: the replay-buffer resume path below stays // unfolded because its per-event `seq` monotonicity is load-bearing for @@ -8599,6 +8812,25 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // recent gap. `sourceEvents` keeps the pre-fold envelopes so delivery // bookkeeping still marks every collapsed delta as sent. let sourceEvents: AgentChatEventEnvelope[] = events; + if (slimChatPeer) { + // A snapshot is a byte-capped tail; on a subagent-heavy thread most + // of it is superseded progress for agents whose card the phone will + // draw exactly once. Keep the latest per agent — started and result + // are untouched, so every card and every outcome still arrives. + const progressFolded = foldSubagentProgressForSnapshot(events); + if (progressFolded.foldedAwayCount > 0) { + args.logger.debug("sync_host.chat_replay_subagent_progress_folded", { + sessionId, + beforeCount: events.length, + afterCount: progressFolded.events.length, + foldedAwayCount: progressFolded.foldedAwayCount, + }); + } + events = progressFolded.events; + // The snapshot is the phone's new baseline: anything the live + // coalescer was holding for this session predates it. + discardChatEventCoalescer(peer, sessionId); + } if (peer.metadata?.capabilities?.includes(SYNC_FOLDED_REPLAY_CAPABILITY)) { const folded = foldChatEventEnvelopesForReplay(events); if (folded.foldedAwayCount > 0) { @@ -8609,7 +8841,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { foldedAwayCount: folded.foldedAwayCount, }); } - sourceEvents = folded.sources; + // `sourceEvents` deliberately keeps the FULL pre-fold list assigned + // above rather than `folded.sources`: on the slim wire the subagent + // progress fold has already removed envelopes from `events`, and + // every one of them still needs its delivery key marked or the + // transcript pump re-sends it as live traffic. For every other peer + // the two are the same array. events = folded.events; } peer.chatTranscriptOffsets.set(sessionId, hydrationStartOffset); @@ -8690,6 +8927,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.chatTranscriptScanOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); peer.resolvedChatTranscriptPaths.delete(sessionId); + discardChatEventCoalescer(peer, sessionId); } break; } diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts new file mode 100644 index 0000000000..b88a6609ff --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts @@ -0,0 +1,109 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; +import { chatToolResultRowId, findStoredToolResult } from "./chatToolResultLookup"; + +const SESSION_ID = "session-tool-result"; + +let tmpRoot = ""; +let transcriptPath = ""; + +beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-tool-result-lookup-")); + transcriptPath = path.join(tmpRoot, `${SESSION_ID}.chat.jsonl`); +}); + +afterEach(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +function write(events: AgentChatEvent[], sessionId = SESSION_ID): void { + const lines = events.map((event, index) => { + const envelope: AgentChatEventEnvelope = { + sessionId, + timestamp: new Date(1_700_000_000_000 + index).toISOString(), + sequence: index + 1, + event, + }; + return JSON.stringify(envelope); + }); + fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf8"); +} + +const toolResult = (itemId: string, result: unknown, extra: Partial = {}): AgentChatEvent => ({ + type: "tool_result", + tool: "Bash", + result, + itemId, + status: "completed", + ...extra, +} as AgentChatEvent); + +describe("chatToolResultRowId", () => { + it("prefers logicalItemId, the id tool_call/tool_result pairs are correlated on", () => { + expect(chatToolResultRowId(toolResult("item-1", "x", { logicalItemId: "logical-1" }))).toBe("logical-1"); + expect(chatToolResultRowId(toolResult("item-1", "x"))).toBe("item-1"); + expect(chatToolResultRowId({ type: "text", text: "hi" })).toBeNull(); + }); +}); + +describe("findStoredToolResult", () => { + it("returns the stored result for the requested row", async () => { + write([ + toolResult("item-1", "first"), + { type: "text", text: "between" }, + toolResult("item-2", { rows: [1, 2, 3] }), + ]); + const hit = await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-2" }); + expect(hit?.event.result).toEqual({ rows: [1, 2, 3] }); + }); + + it("matches on logicalItemId when the event carries one", async () => { + write([toolResult("item-1", "payload", { logicalItemId: "logical-9" })]); + expect((await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "logical-9" }))?.event.result) + .toBe("payload"); + // The raw itemId is not the row id once a logical one exists, so a client + // that sent the wrong one gets a miss rather than a stale payload. + expect(await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-1" })).toBeNull(); + }); + + it("returns the newest result when an id repeats after a retry", async () => { + write([toolResult("item-1", "old"), { type: "text", text: "retry" }, toolResult("item-1", "new")]); + expect((await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-1" }))?.event.result) + .toBe("new"); + }); + + it("finds a result that sits far behind the tail", async () => { + const filler: AgentChatEvent[] = Array.from({ length: 4_000 }, (_, index) => ({ + type: "text", + text: `${index}`.padEnd(200, "x"), + } as AgentChatEvent)); + write([toolResult("item-deep", "deep payload"), ...filler]); + expect((await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-deep" }))?.event.result) + .toBe("deep payload"); + }); + + it("misses cleanly for an unknown id and an empty id", async () => { + write([toolResult("item-1", "x")]); + expect(await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "nope" })).toBeNull(); + expect(await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: " " })).toBeNull(); + }); + + it("throws on a filesystem error rather than reporting the result as gone", async () => { + // "I could not read" and "it is not there" are different answers on the + // phone: the first is retryable, the second is final. The caller turns + // this into `unavailable`, not `found: false`. + await expect(findStoredToolResult({ + transcriptPath: path.join(tmpRoot, "missing.jsonl"), + sessionId: SESSION_ID, + itemId: "item-1", + })).rejects.toThrow(); + }); + + it("never reads another session's rows out of a shared transcript", async () => { + write([toolResult("item-1", "mine")], "other-session"); + expect(await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-1" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts new file mode 100644 index 0000000000..ce94d99df7 --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts @@ -0,0 +1,76 @@ +import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; +import { readTranscriptHistoryPage } from "./chatTranscriptHistoryPager"; +import { readHistoryFileSize } from "../storage/historyCompression"; + +/** + * Find one stored `tool_result` in a transcript by the id its row is keyed on. + * + * This backs the phone's "Show full result": the slim mobile wire sends a + * bounded head slice of every tool result (`shared/chatMobileSlim.ts`), and the + * full payload is fetched only for the row the user actually expanded. + * + * The scan runs BACKWARDS from the end of the file in the same byte windows the + * history pager already uses, because a result the user can see is a result + * near the tail — the snapshot that delivered the slice is itself a tail read. + * It is bounded twice over (a byte budget and a page count) so a request for an + * id that is not there costs a fixed amount of I/O instead of a full file read. + * + * Reading from the transcript rather than from a live in-memory index is + * deliberate: the transcript is what survives a restart, and a tool result the + * phone can see in its own history must still be fetchable hours later. + */ +export const CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES = 8 * 1024 * 1024; +export const CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES = 32; + +export type ChatToolResultLookupHit = { + event: Extract; + envelope: AgentChatEventEnvelope; +}; + +/** The id a tool_call/tool_result pair is correlated on, everywhere. */ +export function chatToolResultRowId(event: AgentChatEvent): string | null { + if (event.type !== "tool_result") return null; + const logical = typeof event.logicalItemId === "string" ? event.logicalItemId.trim() : ""; + if (logical) return logical; + const itemId = typeof event.itemId === "string" ? event.itemId.trim() : ""; + return itemId || null; +} + +export async function findStoredToolResult(options: { + transcriptPath: string; + sessionId: string; + itemId: string; + signal?: AbortSignal; +}): Promise { + const wantedId = options.itemId.trim(); + if (!wantedId) return null; + let beforeOffset = await readHistoryFileSize(options.transcriptPath); + let scannedBytes = 0; + for (let page = 0; page < CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES; page += 1) { + if (options.signal?.aborted) return null; + if (beforeOffset <= 0) return null; + const read = await readTranscriptHistoryPage({ + transcriptPath: options.transcriptPath, + sessionId: options.sessionId, + beforeOffset, + ...(options.signal ? { signal: options.signal } : {}), + }); + // The pager guarantees `startOffset < beforeOffset` whenever there is + // anything left to read, so this loop always terminates. + const windowBytes = Math.max(0, beforeOffset - read.startOffset); + // Newest first within the page: the match the user tapped is the newest + // one for that id, and an id can legitimately repeat after a retry. + for (let index = read.envelopes.length - 1; index >= 0; index -= 1) { + const envelope = read.envelopes[index]!; + const event = envelope.event; + if (!event || event.type !== "tool_result") continue; + if (chatToolResultRowId(event) !== wantedId) continue; + return { event, envelope }; + } + scannedBytes += windowBytes; + if (!read.hasMore) return null; + if (scannedBytes >= CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES) return null; + beforeOffset = read.startOffset; + } + return null; +} diff --git a/apps/desktop/src/shared/chatMobileSlim.test.ts b/apps/desktop/src/shared/chatMobileSlim.test.ts new file mode 100644 index 0000000000..34413a9f81 --- /dev/null +++ b/apps/desktop/src/shared/chatMobileSlim.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatEvent, AgentChatEventEnvelope } from "./types/chat"; +import { + compactChatEventForMobileWire, + compactToolResultForMobile, + createSubagentProgressCoalescer, + foldSubagentProgressForSnapshot, + isMirroredSubagentProgress, + MOBILE_TOOL_RESULT_MAX_BYTES, + subagentProgressIdentity, +} from "./chatMobileSlim"; + +const envelope = (event: AgentChatEvent, sequence: number): AgentChatEventEnvelope => ({ + sessionId: "s1", + timestamp: new Date(1_700_000_000_000 + sequence).toISOString(), + sequence, + event, +}); + +const underscoreProgress = (agentId: string, summary = "working"): AgentChatEvent => ({ + type: "subagent_progress", + taskId: agentId, + agentId, + summary, +}); + +const dotProgress = (agentId: string, text = "working"): AgentChatEvent => ({ + type: "subagent.progress", + agentId, + text, +}); + +describe("subagentProgressIdentity", () => { + it("identifies both families and never the lifecycle bookends", () => { + expect(subagentProgressIdentity(underscoreProgress("a"))).toEqual({ agentKey: "a", family: "underscore" }); + expect(subagentProgressIdentity(dotProgress("a"))).toEqual({ agentKey: "a", family: "dot" }); + expect(subagentProgressIdentity({ type: "subagent_started", taskId: "a", description: "d" })).toBeNull(); + expect(subagentProgressIdentity({ type: "subagent_result", taskId: "a", status: "completed", summary: "s" })).toBeNull(); + expect(subagentProgressIdentity({ type: "subagent.completed", agentId: "a", summary: "s" })).toBeNull(); + expect(subagentProgressIdentity({ type: "text", text: "hi" })).toBeNull(); + }); + + it("falls back to taskId when the underscore family omits agentId", () => { + expect(subagentProgressIdentity({ type: "subagent_progress", taskId: "t1", summary: "x" })) + .toEqual({ agentKey: "t1", family: "underscore" }); + }); +}); + +describe("isMirroredSubagentProgress", () => { + it("only drops a dot event that directly follows its underscore original", () => { + const underscore = { agentKey: "a", family: "underscore" } as const; + const dot = { agentKey: "a", family: "dot" } as const; + expect(isMirroredSubagentProgress(underscore, dot)).toBe(true); + // A different agent's underscore event is not this one's original. + expect(isMirroredSubagentProgress({ agentKey: "b", family: "underscore" }, dot)).toBe(false); + // A dot-only stream keeps every event: nothing minted an underscore twin. + expect(isMirroredSubagentProgress(dot, dot)).toBe(false); + expect(isMirroredSubagentProgress(null, dot)).toBe(false); + // The underscore original itself is never a mirror. + expect(isMirroredSubagentProgress(dot, underscore)).toBe(false); + }); +}); + +describe("foldSubagentProgressForSnapshot", () => { + it("keeps one progress per agent, at the position of the newest one", () => { + const events = [ + envelope(underscoreProgress("a", "1"), 1), + envelope(underscoreProgress("b", "1"), 2), + envelope({ type: "text", text: "hi" }, 3), + envelope(underscoreProgress("a", "2"), 4), + envelope(underscoreProgress("a", "3"), 5), + ]; + const folded = foldSubagentProgressForSnapshot(events); + expect(folded.foldedAwayCount).toBe(2); + expect(folded.events.map((entry) => entry.sequence)).toEqual([2, 3, 5]); + }); + + it("never lets a thinner dot event replace the underscore original", () => { + const events = [ + envelope(underscoreProgress("a", "rich"), 1), + envelope(dotProgress("a", "thin"), 2), + ]; + const folded = foldSubagentProgressForSnapshot(events); + expect(folded.events).toHaveLength(1); + expect((folded.events[0]!.event as { type: string }).type).toBe("subagent_progress"); + }); + + it("keeps a dot-only agent's progress rather than losing its card", () => { + const events = [envelope(dotProgress("a", "only"), 1), envelope(dotProgress("a", "newer"), 2)]; + const folded = foldSubagentProgressForSnapshot(events); + expect(folded.events).toHaveLength(1); + expect(folded.events[0]!.sequence).toBe(2); + }); + + it("never folds started or result", () => { + const events = [ + envelope({ type: "subagent_started", taskId: "a", description: "d" }, 1), + envelope(underscoreProgress("a"), 2), + envelope({ type: "subagent_result", taskId: "a", status: "completed", summary: "done" }, 3), + ]; + const folded = foldSubagentProgressForSnapshot(events); + expect(folded.foldedAwayCount).toBe(0); + expect(folded.events).toHaveLength(3); + }); + + it("returns a copy and leaves the input untouched when nothing folds", () => { + const events = [envelope({ type: "text", text: "hi" }, 1)]; + const folded = foldSubagentProgressForSnapshot(events); + expect(folded.events).toEqual(events); + expect(folded.events).not.toBe(events); + }); +}); + +describe("createSubagentProgressCoalescer", () => { + it("drops the dot mirror that follows its underscore original", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + const first = coalescer.admit(envelope(underscoreProgress("a"), 1), 1, 0); + expect(first).toHaveLength(1); + expect(coalescer.admit(envelope(dotProgress("a"), 2), 2, 1)).toEqual([]); + expect(coalescer.pendingCount).toBe(0); + }); + + it("sends the first progress immediately and coalesces the rest of the window", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + expect(coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0)).toHaveLength(1); + expect(coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100)).toEqual([]); + expect(coalescer.admit(envelope(underscoreProgress("a", "3"), 3), 3, 200)).toEqual([]); + expect(coalescer.flushDue(500)).toEqual([]); + const flushed = coalescer.flushDue(1_000); + expect(flushed).toHaveLength(1); + // Last write wins: the superseded middle event never goes out. + expect((flushed[0]!.event.event as { summary: string }).summary).toBe("3"); + expect(flushed[0]!.seq).toBe(3); + }); + + it("passes every non-progress event straight through, in order", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + const text = envelope({ type: "text", text: "hi" }, 1); + expect(coalescer.admit(text, 1, 0)).toEqual([{ event: text, seq: 1 }]); + }); + + it("drops pending progress when the agent's result supersedes it", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); + coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100); + expect(coalescer.pendingCount).toBe(1); + const result = envelope({ type: "subagent_result", taskId: "a", agentId: "a", status: "completed", summary: "done" }, 3); + expect(coalescer.admit(result, 3, 200)).toEqual([{ event: result, seq: 3 }]); + expect(coalescer.pendingCount).toBe(0); + expect(coalescer.flushDue(5_000)).toEqual([]); + }); + + it("delivers a progress stranded behind a newer event without a seq", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); + // Superseded within the window, so it waits. + coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100); + // A newer event for a different agent goes out first and advances the + // client's watermark past the pending event's seq. + coalescer.admit(envelope(underscoreProgress("b", "1"), 3), 3, 200); + const flushed = coalescer.flushDue(1_000); + const stranded = flushed.find((entry) => (entry.event.event as { taskId: string }).taskId === "a"); + expect(stranded).toBeDefined(); + // Sent without a seq: the client applies it (its drop rule is `if let + // seq`) and its resume watermark stays on the newer event. + expect(stranded!.seq).toBeNull(); + }); + + it("never downgrades a pending underscore event to its dot twin", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); + coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100); + // Not positionally a mirror (a dot event was admitted in between for b), + // but still the thinner twin of what is pending. + coalescer.admit(envelope(dotProgress("b"), 3), 3, 150); + coalescer.admit(envelope(dotProgress("a"), 4), 4, 160); + const flushed = coalescer.flushDue(2_000); + const forA = flushed.find((entry) => { + const event = entry.event.event as { taskId?: string; agentId?: string }; + return (event.taskId ?? event.agentId) === "a"; + }); + expect((forA!.event.event as { type: string }).type).toBe("subagent_progress"); + }); + + it("flushAll empties everything pending", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); + coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 10); + expect(coalescer.pendingCount).toBe(1); + expect(coalescer.flushAll(20)).toHaveLength(1); + expect(coalescer.pendingCount).toBe(0); + expect(coalescer.nextDueAtMs()).toBeNull(); + }); +}); + +describe("compactToolResultForMobile", () => { + const toolResult = (result: unknown, extra: Record = {}): Extract => ({ + type: "tool_result", + tool: "Bash", + result, + itemId: "item-1", + status: "completed", + ...extra, + } as Extract); + + it("leaves a result that already fits alone", () => { + const event = toolResult("short"); + expect(compactToolResultForMobile(event)).toBe(event); + }); + + it("slices a large result and flags it for on-demand fetch", () => { + const event = toolResult("x".repeat(50_000)); + const compacted = compactToolResultForMobile(event) as Extract; + expect(typeof compacted.result).toBe("string"); + expect(Buffer.byteLength(compacted.result as string, "utf8")).toBe(MOBILE_TOOL_RESULT_MAX_BYTES); + expect(compacted.resultTruncatedForMobile).toBe(true); + expect(compacted.resultOriginalBytes).toBe(50_000); + expect(compacted.resultOmittedBytes).toBe(50_000 - MOBILE_TOOL_RESULT_MAX_BYTES); + }); + + it("keeps the size the agent actually produced when the result was already capped", () => { + const compacted = compactToolResultForMobile( + toolResult("y".repeat(20_000), { resultOriginalBytes: 4_000_000 }), + ) as Extract; + expect(compacted.resultOriginalBytes).toBe(4_000_000); + }); + + it("replaces a structured result with text rather than half an object", () => { + const compacted = compactToolResultForMobile( + toolResult({ rows: Array.from({ length: 2_000 }, (_, index) => `row-${index}`) }), + ) as Extract; + expect(typeof compacted.result).toBe("string"); + expect(compacted.resultTruncatedForMobile).toBe(true); + }); + + it("never slices mid-codepoint", () => { + const compacted = compactToolResultForMobile(toolResult("é".repeat(4_000))) as Extract; + expect((compacted.result as string).includes("�")).toBe(false); + }); +}); + +describe("compactChatEventForMobileWire", () => { + it("applies the shared wire policy and then the phone cap", () => { + const wire = compactChatEventForMobileWire({ + type: "tool_result", + tool: "Bash", + result: "z".repeat(100_000), + structured: { big: "payload" }, + itemId: "item-1", + status: "completed", + } as AgentChatEvent) as Extract; + // `structured` is dropped by the shared wire policy for every client. + expect(wire.structured).toBeUndefined(); + expect(wire.resultTruncatedForMobile).toBe(true); + }); + + it("leaves every other event type to the shared policy alone", () => { + const event: AgentChatEvent = { type: "text", text: "hello" }; + expect(compactChatEventForMobileWire(event)).toBe(event); + }); +}); diff --git a/apps/desktop/src/shared/chatMobileSlim.ts b/apps/desktop/src/shared/chatMobileSlim.ts new file mode 100644 index 0000000000..41b44ac27e --- /dev/null +++ b/apps/desktop/src/shared/chatMobileSlim.ts @@ -0,0 +1,408 @@ +import type { AgentChatEvent } from "./types/chat"; +import type { AgentChatEventEnvelope } from "./types"; +import { compactChatEventForWire } from "./chatEventCompaction"; + +/** + * What a phone receives for a chat, and nothing more. + * + * `chatEventCompaction` bounds heavy fields for EVERY consumer — the stored + * transcript, desktop, hosted web, the TUI and the phone all share its caps, + * because a payload the desktop keeps and the phone drops would render + * differently on each. This module is the layer above that: the parts of a + * chat that a phone should not carry at all, gated on a capability the phone + * announces so an older build keeps today's wire byte for byte. + * + * Measured on a real 16.1 MB / 21,665-event thread from this machine + * (`.ade/transcripts/chat/67757bac-…jsonl`), three families own 58% of it: + * + * tool_result 23.1% 898 events + * subagent_progress 19.8% 7,775 events + * subagent.progress 15.2% 7,775 events (a mirror of the line above) + * subagent_result 15.3% 93 events + * + * Each rule below targets one of those and keeps the outcome the user can see. + * + * ## 1. The `subagent.progress` mirror + * + * `buildCanonicalAgentChatRuntimeEvent` (main/services/chat/runtimeEvents.ts) + * commits a dot-family twin next to every underscore subagent lifecycle event, + * so the transcript carries both and both cross the wire. They are not two + * facts: in the measured thread the pair is written 1 ms apart with identical + * ids, and every client folds them into ONE row — desktop and the TUI through + * `normalizeSubagentLifecycleEvent` (shared/chatSubagents.ts), iOS by decoding + * `subagent.progress` into the same `.subagentProgress` case as + * `subagent_progress` (RemoteModels.swift). + * + * So the mirror is dropped — but only when it IS a mirror. A transcript can + * legitimately contain a dot-family event with no underscore twin (an older + * build, or a runtime whose events arrive already canonical, where + * `buildCanonicalAgentChatRuntimeEvent` returns null and nothing mints the + * underscore form). Dropping the family outright would lose that subagent's + * card entirely, so the rule is positional: a dot event is dropped only when + * the underscore event it mirrors was itself just delivered for the same + * agent. That is exactly the shape the emitter produces and nothing else. + * + * ## 2. Progress is state, not history + * + * A `subagent_progress` event is a snapshot of one agent's current summary, + * token count and last tool. iOS says so explicitly — subagent progress + * "enriches the folded snapshot but never creates a timeline row" + * (WorkTimelineHelpers.swift). Nothing on the phone can show the thirty + * superseded copies between two renders, so only the latest per agent is sent: + * folded to one per agent in the subscribe snapshot, and coalesced to at most + * one per second per agent live. + * + * `started` and `result`/`completed` are history and are never folded — they + * are what the card's status and final summary are made of. + * + * ## 3. Tool results are fetched, not pushed + * + * The wire cap for every other client is 16 KB (`chatEventCompaction`), which + * is far more than a phone renders before the user taps to expand. Capable + * phones get `MOBILE_TOOL_RESULT_MAX_BYTES` — about one screen — plus the true + * size, and fetch the stored result on demand through the `chat_tool_result` + * request when the user expands the row. + * + * Nothing here changes what is persisted, what an agent does, or what any + * non-phone client receives. + */ + +/** + * Roughly one screen of monospaced text on a phone. Large enough that the + * common short result (an exit code, a path, a one-line answer) never needs a + * fetch, small enough that a 16 KB grep dump does. + */ +export const MOBILE_TOOL_RESULT_MAX_BYTES = 2_048; + +const utf8Bytes = (value: string): number => Buffer.byteLength(value, "utf8"); + +const sliceUtf8FromStart = (value: string, maxBytes: number): string => { + if (maxBytes <= 0) return ""; + if (utf8Bytes(value) <= maxBytes) return value; + let low = 0; + let high = value.length; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (utf8Bytes(value.slice(0, mid)) <= maxBytes) low = mid; + else high = mid - 1; + } + return value.slice(0, low); +}; + +const stringifyResult = (value: unknown): { text: string; measurable: boolean } => { + if (typeof value === "string") return { text: value, measurable: true }; + try { + const json = JSON.stringify(value, null, 2); + if (typeof json === "string") return { text: json, measurable: true }; + return { text: String(value), measurable: false }; + } catch { + // Same trap `chatEventCompaction` documents: `String(value)` on a circular + // payload is 15 bytes, which would report an unbounded object as small. + return { text: String(value), measurable: false }; + } +}; + +/** + * The tool result as a capable phone should receive it: a head slice, the real + * size, and a flag that tells the row it has more to fetch. + * + * The result is replaced by a plain string even when it was structured. Every + * surface that shows an object-shaped result renders it as a JSON dump anyway, + * and the phone must not mistake a truncated object for a complete one — a + * half-serialized JSON body would decode as text on iOS but read as a real + * value to anything that tried to parse it. + */ +export function compactToolResultForMobile( + event: Extract, +): AgentChatEvent { + const serialized = stringifyResult(event.result); + const totalBytes = serialized.measurable ? utf8Bytes(serialized.text) : 0; + // An already-capped result whose original size is known keeps that number: + // `resultOriginalBytes` is the size of what the agent produced, and the phone + // shows it to the user. Overwriting it with the post-compaction size would + // report a megabyte grep as 16 KB. + const originalBytes = typeof event.resultOriginalBytes === "number" && event.resultOriginalBytes > 0 + ? event.resultOriginalBytes + : totalBytes; + if (serialized.measurable && totalBytes <= MOBILE_TOOL_RESULT_MAX_BYTES) { + return event; + } + const preview = sliceUtf8FromStart(serialized.text, MOBILE_TOOL_RESULT_MAX_BYTES); + return { + ...event, + result: preview, + resultOriginalBytes: originalBytes, + resultOmittedBytes: Math.max(0, originalBytes - utf8Bytes(preview)), + // The row's "Show full result" affordance. Absent on every other wire, so + // an older phone sees exactly the event it sees today. + resultTruncatedForMobile: true, + }; +} + +/** Storage/wire compaction first, then the phone-only rules. */ +export function compactChatEventForMobileWire(event: AgentChatEvent): AgentChatEvent { + const wire = compactChatEventForWire(event); + if (wire.type !== "tool_result") return wire; + return compactToolResultForMobile(wire); +} + +// --------------------------------------------------------------------------- +// Subagent progress +// --------------------------------------------------------------------------- + +export type SubagentProgressFamily = "underscore" | "dot"; + +export type SubagentProgressIdentity = { + /** Which agent this progress is about. */ + agentKey: string; + family: SubagentProgressFamily; +}; + +const trimmed = (value: unknown): string | null => { + if (typeof value !== "string") return null; + const text = value.trim(); + return text.length > 0 ? text : null; +}; + +/** + * Identify a subagent PROGRESS event and the agent it belongs to. + * + * `started` and `result`/`completed` deliberately return null: they are the + * events a card's existence and outcome are made of, and folding them would + * lose a subagent, not a redraw. + */ +export function subagentProgressIdentity(event: unknown): SubagentProgressIdentity | null { + if (!event || typeof event !== "object") return null; + const record = event as Record; + const type = typeof record.type === "string" ? record.type : ""; + if (type !== "subagent_progress" && type !== "subagent.progress") return null; + // `agentId` is the stable identity on both families; the underscore family + // falls back to `taskId`, which is what it keys its own snapshot map on + // (`subagentAgentKey` in agentChatService). + const agentKey = trimmed(record.agentId) ?? trimmed(record.taskId); + if (!agentKey) return null; + return { agentKey, family: type === "subagent.progress" ? "dot" : "underscore" }; +} + +/** + * True when `candidate` is the dot-family twin that + * `commitChatEventWithCanonical` mints immediately after `previous`. + * + * Positional by design — see the module header. The pair is written back to + * back for the same agent, so "the last progress delivered for this session was + * the underscore original for this same agent" is the emitter's own shape. + */ +export function isMirroredSubagentProgress( + previous: SubagentProgressIdentity | null, + candidate: SubagentProgressIdentity, +): boolean { + if (candidate.family !== "dot") return false; + return previous?.family === "underscore" && previous.agentKey === candidate.agentKey; +} + +/** + * Collapse a subscribe snapshot's subagent progress to the latest event per + * agent, in place. + * + * Order is preserved by emitting each surviving event at the position of the + * LAST progress for that agent — the phone renders progress as card state, so + * the newest value belongs where the newest event was, next to whatever + * followed it. + * + * Snapshot-only, like `chatReplayFold`: the replay-buffer resume path keeps + * every event because its per-event `seq` monotonicity is what the client's + * `seq <= lastSeq` drop rule runs on, and it only covers a small recent gap. + */ +export function foldSubagentProgressForSnapshot( + envelopes: readonly AgentChatEventEnvelope[], +): { events: AgentChatEventEnvelope[]; foldedAwayCount: number } { + // Which index each agent's latest progress lives at, and whether an + // underscore event was seen for it. A dot event never replaces an underscore + // one: it carries strictly less (no description, no spawnDepth, no + // toolUses/durationMs in usage), and the two are the same fact. + const latestIndexByAgent = new Map(); + const drop = new Set(); + envelopes.forEach((envelope, index) => { + const identity = subagentProgressIdentity(envelope?.event); + if (!identity) return; + const previous = latestIndexByAgent.get(identity.agentKey); + if (!previous) { + latestIndexByAgent.set(identity.agentKey, { index, family: identity.family }); + return; + } + if (previous.family === "underscore" && identity.family === "dot") { + drop.add(index); + return; + } + drop.add(previous.index); + latestIndexByAgent.set(identity.agentKey, { index, family: identity.family }); + }); + if (drop.size === 0) return { events: [...envelopes], foldedAwayCount: 0 }; + return { + events: envelopes.filter((_, index) => !drop.has(index)), + foldedAwayCount: drop.size, + }; +} + +// --------------------------------------------------------------------------- +// Live coalescing +// --------------------------------------------------------------------------- + +export const MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS = 1_000; + +/** + * One outbound chat event, as the coalescer decides to send it. + * + * `seq` is omitted when the event is being delivered out of seq order — see + * `flushAgent` for why that is the correct thing to do rather than a + * workaround. + */ +export type CoalescedChatEvent = { + event: AgentChatEventEnvelope; + seq: number | null; +}; + +export type SubagentProgressCoalescer = { + /** + * Offer one outbound event. Returns what to actually send, in order. + * Non-progress events always pass through untouched. + */ + admit(event: AgentChatEventEnvelope, seq: number, nowMs: number): CoalescedChatEvent[]; + /** Emit any pending progress whose one-second window has closed. */ + flushDue(nowMs: number): CoalescedChatEvent[]; + /** Emit everything pending — unsubscribe, disconnect, end of turn. */ + flushAll(nowMs: number): CoalescedChatEvent[]; + /** Earliest ms at which `flushDue` could produce anything, or null. */ + nextDueAtMs(): number | null; + readonly pendingCount: number; +}; + +export function createSubagentProgressCoalescer(options: { + intervalMs?: number; +} = {}): SubagentProgressCoalescer { + const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS)); + const pending = new Map(); + const lastSentAtMs = new Map(); + const dueAtMs = new Map(); + let lastEmittedSeq = 0; + let lastProgressIdentity: SubagentProgressIdentity | null = null; + + const emit = (entry: { event: AgentChatEventEnvelope; seq: number }): CoalescedChatEvent => { + if (entry.seq > lastEmittedSeq) { + lastEmittedSeq = entry.seq; + return { event: entry.event, seq: entry.seq }; + } + // The window closed after a newer event for a DIFFERENT agent already went + // out, so this agent's latest state now carries a seq below the client's + // watermark — and the client drops `seq <= lastSeq` on sight. Dropping it + // here instead would leave that one card stale until the agent's next + // progress, which for a quiet agent is its result. + // + // Sending it without a seq is the honest encoding of what it is: card + // state, not a new point in the ordered stream. The client applies events + // with no seq (its drop rule is `if let seq`), its resume watermark stays + // on the newer event, and a reconnect that replays this one again is a + // no-op because progress never creates a row. + return { event: entry.event, seq: null }; + }; + + const flushAgent = (agentKey: string, nowMs: number): CoalescedChatEvent[] => { + const entry = pending.get(agentKey); + if (!entry) return []; + pending.delete(agentKey); + dueAtMs.delete(agentKey); + lastSentAtMs.set(agentKey, nowMs); + return [emit(entry)]; + }; + + return { + admit(event, seq, nowMs) { + const identity = subagentProgressIdentity(event?.event); + if (!identity) { + // A non-progress event does not flush pending progress. It cannot: + // flushing here would emit the pending event with a LOWER seq right + // before this one, and the ordered stream must not go backwards. The + // pending entry keeps its own one-second window and is delivered by + // `flushDue` (or seq-lessly, per `emit`) instead. + // + // The one case that does flush is a subagent ending: its result is the + // last word on that card, so the progress behind it has no reader. + const type = typeof (event?.event as { type?: unknown })?.type === "string" + ? (event.event as { type: string }).type + : ""; + const ending = type === "subagent_result" || type === "subagent.completed"; + const before: CoalescedChatEvent[] = []; + if (ending) { + const record = event.event as unknown as Record; + const agentKey = trimmed(record.agentId) ?? trimmed(record.taskId); + // Drop rather than emit: the result that follows in this same call + // supersedes it, and emitting first would put a lower seq ahead of + // a higher one for no visible gain. + if (agentKey && pending.delete(agentKey)) { + dueAtMs.delete(agentKey); + lastSentAtMs.delete(agentKey); + } + lastProgressIdentity = null; + } else { + lastProgressIdentity = null; + } + if (seq > lastEmittedSeq) lastEmittedSeq = seq; + return [...before, { event, seq }]; + } + + if (isMirroredSubagentProgress(lastProgressIdentity, identity)) { + // The underscore original was just delivered (or just coalesced) for + // this agent. The twin carries nothing new. + return []; + } + lastProgressIdentity = identity; + + const existing = pending.get(identity.agentKey); + if (existing && existing.family === "underscore" && identity.family === "dot") { + // Same reason as the snapshot fold: never downgrade a pending + // underscore event to its thinner twin. + return []; + } + pending.set(identity.agentKey, { event, seq, family: identity.family }); + + const lastSent = lastSentAtMs.get(identity.agentKey); + if (lastSent == null || nowMs - lastSent >= intervalMs) { + return flushAgent(identity.agentKey, nowMs); + } + if (!dueAtMs.has(identity.agentKey)) { + dueAtMs.set(identity.agentKey, lastSent + intervalMs); + } + return []; + }, + + flushDue(nowMs) { + const out: CoalescedChatEvent[] = []; + for (const [agentKey, due] of [...dueAtMs]) { + if (nowMs < due) continue; + out.push(...flushAgent(agentKey, nowMs)); + } + return out; + }, + + flushAll(nowMs) { + const out: CoalescedChatEvent[] = []; + for (const agentKey of [...pending.keys()]) { + out.push(...flushAgent(agentKey, nowMs)); + } + lastProgressIdentity = null; + return out; + }, + + nextDueAtMs() { + let earliest: number | null = null; + for (const due of dueAtMs.values()) { + if (earliest == null || due < earliest) earliest = due; + } + return earliest; + }, + + get pendingCount() { + return pending.size; + }, + }; +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 6fdc8b07c7..7d0c8c0fc7 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -978,6 +978,14 @@ export type AgentChatEvent = mcp?: AgentChatMcpToolSource; resultOriginalBytes?: number; resultOmittedBytes?: number; + /** + * Set only on the mobile wire, by `compactChatEventForMobileWire`, when + * `result` is a head slice rather than the whole thing. It is the row's + * cue to offer "Show full result" and fetch it through + * `chat_tool_result`. Never stored, and never sent to a client that did + * not announce `mobileChatSlimV1`. + */ + resultTruncatedForMobile?: boolean; itemId: string; logicalItemId?: string; parentItemId?: string; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 37fb694a27..e6a91677cd 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -140,6 +140,19 @@ export const SYNC_BINARY_ENVELOPES_CAPABILITY = "binaryEnvelopes"; * Clients that do not declare it receive every individual delta, unchanged. */ export const SYNC_FOLDED_REPLAY_CAPABILITY = "foldedReplay"; +/** + * Hello capability a phone declares when it accepts the slim mobile chat wire: + * subagent progress folded per agent (snapshot) and coalesced per agent per + * second (live), and tool results delivered as a bounded head slice that the + * client fetches in full through `chat_tool_result` when the user expands the + * row. See `shared/chatMobileSlim.ts` for each rule and why it keeps every + * user-visible outcome. + * + * Desktop, hosted web and the TUI never declare it: they render the full work + * timeline and keep every event. A client that does not declare it receives + * exactly the wire it receives today. + */ +export const SYNC_MOBILE_CHAT_SLIM_CAPABILITY = "mobileChatSlimV1"; export type SyncPayloadEncoding = "json" | "base64"; @@ -1656,6 +1669,35 @@ export type SyncChatHistoryRequestPayload = SyncChatUnsubscribePayload & { export type SyncChatHistoryResponsePayload = AgentChatEventHistoryPage; +/** + * On-demand fetch of one tool result the phone received as a bounded slice. + * + * Scoped exactly like `chat_history`: the peer must already be subscribed to + * the session, and the request carries the same chat-scope fields so a + * personal or cross-project quick-look reads from its own transcript and never + * from the active project's. + */ +export type SyncChatToolResultRequestPayload = SyncChatUnsubscribePayload & { + /** `logicalItemId ?? itemId` of the tool_result row the user expanded. */ + itemId: string; +}; + +export type SyncChatToolResultResponsePayload = { + sessionId: string; + itemId: string; + /** Absent when the event could not be found or read. */ + result?: unknown; + /** Size of the stored result, for the row's footer. */ + resultOriginalBytes?: number; + resultOmittedBytes?: number; + status?: "running" | "completed" | "failed" | "interrupted"; + tool?: string; + /** True when this host could not answer at all (no service, read failure). */ + unavailable?: boolean; + /** True when the session/item pair resolved to a stored tool result. */ + found: boolean; +}; + /** * Live chat event envelope. `seq` is a host-assigned, per-session, * monotonically increasing counter used for resumable streams: clients track @@ -2281,6 +2323,11 @@ export type SyncTerminalHistoryEnvelope = SyncEnvelopeWithPayload<"terminal_hist export type SyncChatSubscribeEnvelope = SyncEnvelopeWithPayload<"chat_subscribe", SyncChatSubscribePayload | SyncChatSubscribeSnapshotPayload>; export type SyncChatUnsubscribeEnvelope = SyncEnvelopeWithPayload<"chat_unsubscribe", SyncChatUnsubscribePayload>; export type SyncChatEventEnvelope = SyncEnvelopeWithPayload<"chat_event", SyncChatEventPayload>; +export type SyncChatToolResultEnvelope = SyncEnvelopeWithPayload< + "chat_tool_result", + SyncChatToolResultRequestPayload | SyncChatToolResultResponsePayload +>; + export type SyncChatHistoryEnvelope = SyncEnvelopeWithPayload< "chat_history", SyncChatHistoryRequestPayload | SyncChatHistoryResponsePayload @@ -2359,6 +2406,7 @@ export type SyncEnvelope = | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope | SyncChatHistoryEnvelope + | SyncChatToolResultEnvelope | SyncBrainStatusEnvelope | SyncPrsUpdatedEnvelope | SyncRosterSubscribeEnvelope From 32d53927e38caccb9bef7a0713293bd30a54b980 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:20:01 -0400 Subject: [PATCH 05/26] fix(ios): messages with an image send, and an unconfirmed send keeps its draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upload that starts when an attachment is staged ran through workChatSaveInputAttachments, which asks WorkComposerAttachmentUploads to resolve that same attachment — so the upload task awaited its own completion. For images (the only kind routed that way) it never finished, the send that awaited it never returned, and the message sat at "Sending" forever with the draft already cleared. The tracker's upload now calls the raw host stager, waiting for a staged upload is bounded by the chat-send budget and reports whether it gave up, and a send owns its stored draft — text and attachments — until the host confirms it, so leaving a chat mid-send restores the whole message. A failed send shows one retry row above the field. Co-Authored-By: Claude Opus 5 --- .../Views/Work/WorkChatAttachmentTray.swift | 110 ++++++--- .../ADE/Views/Work/WorkChatSessionView.swift | 207 ++++++++++++----- .../Work/WorkComposerAttachmentStaging.swift | 216 +++++++++++++----- .../WorkComposerDraftAttachmentTests.swift | 166 ++++++++++++++ .../WorkComposerTriggerDetectorTests.swift | 4 +- .../sync-and-multi-device/ios-companion.md | 47 +++- 6 files changed, 589 insertions(+), 161 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index 644f8dcc9c..016cb7bba1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -355,6 +355,69 @@ func workChatInputPasteImages(_ images: [UIImage], into attachments: Binding<[Wo attachments.wrappedValue = next } +/// Stages one attachment's bytes on the host and returns its ref. +/// +/// The raw host call, with no upload-tracker lookup in front of it. That +/// separation is load-bearing: `WorkComposerAttachmentUploads`'s own upload task +/// calls this, and when it instead went back through +/// `workChatSaveInputAttachments` the task awaited its own completion — a +/// permanent deadlock that left every message carrying an image stuck at +/// "Sending" with no failure and no retry. +/// +/// Images keep the historical base64 `chat.saveTempAttachment` route (the host +/// sniffs the bytes); everything else rides the chunked file route. +@MainActor +func workChatStageAttachmentOnHost( + _ attachment: WorkChatInputAttachment, + syncService: SyncService, + chatSessionId: String? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil +) async throws -> AgentChatFileRef { + guard let uploadData = attachment.uploadData, !uploadData.isEmpty else { + throw NSError( + domain: "ADE", + code: 30, + userInfo: [NSLocalizedDescriptionKey: "This attachment has no data to upload."] + ) + } + if attachment.kind != .image { + let saved = try await syncService.saveChatFileAttachment( + data: uploadData, + filename: attachment.filename, + chatSessionId: chatSessionId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + // Lowercase: the host ref parser drops any `type` that is not exactly + // `image` or `file`. + return AgentChatFileRef(path: saved.path, type: "file") + } + guard let dataUrl = workChatInputAttachmentDataURL(attachment) else { + throw NSError( + domain: "ADE", + code: 30, + userInfo: [NSLocalizedDescriptionKey: "This attachment has no data to upload."] + ) + } + let saved: SavedChatTempAttachment + if let chatSessionId, !chatSessionId.isEmpty { + saved = try await syncService.saveChatTempAttachmentForChat( + sessionId: chatSessionId, + dataUrl: dataUrl, + filename: attachment.filename + ) + } else { + saved = try await syncService.saveChatTempAttachment( + dataUrl: dataUrl, + filename: attachment.filename, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + } + return AgentChatFileRef(path: saved.path, type: "image") +} + @MainActor func workChatSaveInputAttachments( _ attachments: [WorkChatInputAttachment], @@ -374,40 +437,25 @@ func workChatSaveInputAttachments( } // The upload started when the attachment was staged; wait for it rather // than racing a second upload of the same bytes. - if let resolved = await WorkComposerAttachmentUploads.shared.resolve(attachment.id) { + switch await WorkComposerAttachmentUploads.shared.resolve(attachment.id) { + case .ref(let resolved): refs.append(workChatNormalizedOutboundRef(resolved)) continue + case .abandoned: + // The staged upload passed its deadline. A second inline upload would + // queue behind the same wedged leg, so fail now — the send path restores + // the draft and offers a retry. + throw workChatAttachmentUploadTimedOutError(attachment.filename) + case .stageInline: + break } - if attachment.kind != .image { - let saved = try await syncService.saveChatFileAttachment( - data: attachment.uploadData ?? Data(), - filename: attachment.filename, - chatSessionId: chatSessionId, - targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath - ) - // Lowercase: the host ref parser drops any `type` that is not exactly - // `image` or `file`. - refs.append(AgentChatFileRef(path: saved.path, type: "file")) - continue - } - guard let dataUrl = workChatInputAttachmentDataURL(attachment) else { continue } - let saved: SavedChatTempAttachment - if let chatSessionId, !chatSessionId.isEmpty { - saved = try await syncService.saveChatTempAttachmentForChat( - sessionId: chatSessionId, - dataUrl: dataUrl, - filename: attachment.filename - ) - } else { - saved = try await syncService.saveChatTempAttachment( - dataUrl: dataUrl, - filename: attachment.filename, - targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath - ) - } - refs.append(AgentChatFileRef(path: saved.path, type: "image")) + refs.append(try await workChatStageAttachmentOnHost( + attachment, + syncService: syncService, + chatSessionId: chatSessionId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + )) } return refs } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 4a5d498ed0..9475ce0315 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -3208,6 +3208,12 @@ private struct WorkChatComposerDraftInput: View { @State private var stopHapticToken = 0 @State private var activeSendMode: WorkActiveSendMode = .inline @State private var sendOptionsPresented = false + /// Set when a send came back unsent. Drives the one retry row above the + /// field; cleared by the next attempt and by a chat switch. + @State private var sendFailureNotice: String? + /// Mode the failed send used, so Retry repeats that send rather than + /// silently downgrading an interrupt to a staged message. + @State private var lastSendMode: WorkActiveSendMode = .queue private var hasSendableDraftOrAttachment: Bool { draftState.hasSendableText || !workChatInputReadyAttachments(inputAttachments).isEmpty @@ -3328,6 +3334,83 @@ private struct WorkChatComposerDraftInput: View { } } + private var sendEnabled: Bool { + workChatInputCanSend( + text: draftState.text, + attachments: inputAttachments, + baseEnabled: canSend, + canUploadAttachments: canUploadAttachments + ) + } + + /// The composer's one send path, shared by the send button and the retry row. + /// + /// Order matters: the outgoing text and attachments are written to this + /// chat's stored draft BEFORE the field and tray are emptied, and only + /// released once the host confirms. A send that hangs or fails therefore + /// leaves the message recoverable — leaving the chat mid-send used to discard + /// it entirely. + @MainActor + private func performSend(mode: WorkActiveSendMode) { + guard sendEnabled else { return } + let key = draftPersistenceKey + let originalText = draftState.beginPendingSend() + let outgoingAttachments = workChatInputReadyAttachments(inputAttachments) + let text = workChatOutgoingText(originalText, attachmentCount: outgoingAttachments.count) + let restoredAttachments = inputAttachments + workChatPersistComposerAttachments(outgoingAttachments, for: key) + inputAttachments.removeAll() + sendFailureNotice = nil + lastSendMode = mode + Task { @MainActor in + let sent = await onSend(text, outgoingAttachments, mode) + // Drops the stored draft only on a confirmed send; a failure keeps it, so + // the composer copy below and the stored copy stay the same message. + draftState.finishPendingSend(sent: sent) + if sent { + // The refs were consumed by the send; drop the upload tracking so a + // later attachment can never reuse a sent message's ref. + WorkComposerAttachmentUploads.shared.release(restoredAttachments.map(\.id)) + onSent() + } else { + inputAttachments = restoredAttachments + draftState.restoreUnsentText(originalText) + sendFailureNotice = workChatAttachmentUploadTimedOutMessage + } + } + } + + /// One plain sentence and a tap target, directly above the field the message + /// was just restored into. + @ViewBuilder + private var sendFailureRow: some View { + if let sendFailureNotice { + Button { + performSend(mode: lastSendMode) + } label: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption2) + Text(sendFailureNotice) + .font(.caption) + Spacer(minLength: 0) + } + .foregroundStyle(ADEColor.danger) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + ADEColor.danger.opacity(0.12), + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + } + .buttonStyle(.plain) + .accessibilityIdentifier("Work.Chat.Composer.SendFailure") + .accessibilityLabel(sendFailureNotice) + .accessibilityHint("Sends this message again") + } + } + private var attachmentTray: some View { WorkChatInputAttachmentTray( attachments: $inputAttachments, @@ -3350,6 +3433,8 @@ private struct WorkChatComposerDraftInput: View { VStack(alignment: .leading, spacing: 8) { composerHeaderRow + sendFailureRow + if compact { if !composerCollapsed { WorkComposerSuggestionStrip(controller: suggestionController) @@ -3509,6 +3594,8 @@ private struct WorkChatComposerDraftInput: View { persistDraftAttachments(for: draftPersistenceKey) } .task(id: draftPersistenceKey) { + // A failure notice belongs to the chat it happened in. + sendFailureNotice = nil await restoreDraftAttachments() } // A navigation pop beats the upload wait above; write what is known now so @@ -3540,21 +3627,15 @@ private struct WorkChatComposerDraftInput: View { /// Writes refs for everything the host already holds, and bytes (to the /// purgeable cache) only for what it does not — the offline leg. + /// + /// Skipped while a send for this key is still in flight: the send already + /// wrote the outgoing set and then emptied the tray, so running this again + /// would persist that emptiness over the very payload the user must get back + /// if the send never confirms. @MainActor private func persistDraftAttachments(for key: String) { - guard !key.isEmpty else { return } - let uploads = WorkComposerAttachmentUploads.shared - var refs: [AgentChatFileRef] = [] - var unsaved: [WorkChatInputAttachment] = [] - for attachment in inputAttachments where attachment.isReady { - if let ref = attachment.hostRef ?? uploads.ref(for: attachment.id) { - refs.append(ref) - } else { - unsaved.append(attachment) - } - } - let localFiles = WorkComposerDraftAttachmentCache.write(unsaved, for: key) - WorkComposerDraftStore.saveAttachments(refs, owner: nil, localFiles: localFiles, for: key) + guard !draftState.isSendInFlight(for: key) else { return } + workChatPersistComposerAttachments(inputAttachments, for: key) } /// Restores the staged attachments for this chat. Refs first (the host still @@ -3635,10 +3716,7 @@ private struct WorkChatComposerDraftInput: View { canUploadAttachments: canUploadAttachments, sending: sending, accessibilityLabelText: "Stage message", - onSend: { text, attachments in - await onSend(text, attachments, .queue) - }, - onSent: onSent + action: { performSend(mode: .queue) } ) } } else { @@ -3651,10 +3729,7 @@ private struct WorkChatComposerDraftInput: View { canSend: canSend, canUploadAttachments: canUploadAttachments, sending: sending, - onSend: { text, attachments in - await onSend(text, attachments, .queue) - }, - onSent: onSent + action: { performSend(mode: .queue) } ) } } @@ -3671,10 +3746,7 @@ private struct WorkChatComposerDraftInput: View { accessibilityLabelText: activeSendModeTitle(effectiveActiveSendMode), systemImageName: activeSendModeIcon(effectiveActiveSendMode), minimumTapTargetSize: 32, - onSend: { text, attachments in - await onSend(text, attachments, effectiveActiveSendMode) - }, - onSent: onSent + action: { performSend(mode: effectiveActiveSendMode) } ) Button { @@ -4105,6 +4177,10 @@ final class WorkChatComposerDraftState: ObservableObject { /// persist" (the key is unresolved), which is the safe default. private var persistenceKey = "" private var autosaveTask: Task? + /// Key of a send that has left the composer but has not been confirmed by the + /// host yet. While it is set, the stored draft belongs to that send — autosave + /// must not overwrite it with the emptied field. + private var pendingSendKey: String? var trimmedText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -4148,14 +4224,16 @@ final class WorkChatComposerDraftState: ObservableObject { func flushDraft() { autosaveTask?.cancel() autosaveTask = nil - guard !persistenceKey.isEmpty else { return } + guard !persistenceKey.isEmpty, pendingSendKey != persistenceKey else { return } WorkComposerDraftStore.save(text, for: persistenceKey) } /// Keystroke debounce: each edit restarts the timer, so a burst of typing /// costs one write instead of one per character. private func scheduleAutosave() { - guard !persistenceKey.isEmpty else { return } + // Only the key whose send is unconfirmed stands down; another chat's + // composer must keep autosaving normally. + guard !persistenceKey.isEmpty, pendingSendKey != persistenceKey else { return } autosaveTask?.cancel() let key = persistenceKey let value = text @@ -4170,27 +4248,48 @@ final class WorkChatComposerDraftState: ObservableObject { !trimmedText.isEmpty } - func consumeSendableText() -> String { + /// Takes the text out of the field and hands ownership of the stored draft to + /// the send. + /// + /// The stored copy is deliberately NOT dropped here. A send can hang (an + /// attachment upload that never lands, a host that never answers) or fail, + /// and clearing at tap time meant leaving the chat in that window destroyed + /// the message — text and attachments — with nothing on screen to show for + /// it. It is dropped in `finishPendingSend(sent:)` once the host has actually + /// taken the message. The cost is the reverse case: a force-quit between the + /// host accepting and this returning restores an already-sent message as a + /// draft, which is what `SyncRequestTimeout.chatSendMessage` already tells + /// the user to check the transcript for. + func beginPendingSend() -> String { let value = trimmedText isFocused = false + autosaveTask?.cancel() + autosaveTask = nil + if !persistenceKey.isEmpty { + pendingSendKey = persistenceKey + WorkComposerDraftStore.save(value, for: persistenceKey) + } text = "" - // Drop the stored copy synchronously rather than letting the 400ms debounce - // get to it. A jetsam or force-quit inside that window would otherwise - // restore an already-sent message into the composer, where it reads as - // unsent and invites sending it twice. The Hub and New Chat composers clear - // on send for the same reason. - clearStoredDraft() return value } - /// Cancels any pending autosave and removes the persisted draft. Not - /// actor-annotated so `consumeSendableText()` — which runs from the send - /// button's synchronous action — can call it directly. - func clearStoredDraft() { - autosaveTask?.cancel() - autosaveTask = nil - guard !persistenceKey.isEmpty else { return } - WorkComposerDraftStore.clear(persistenceKey) + /// Releases the stored draft back to the composer — dropping it when the host + /// took the message, keeping it when it did not. + func finishPendingSend(sent: Bool) { + guard let key = pendingSendKey else { return } + pendingSendKey = nil + guard sent else { return } + WorkComposerDraftStore.clear(key) + // Anything typed while the send was in flight was held out of the store by + // the guard above; write it now that the key is free again. + if key == persistenceKey, !trimmedText.isEmpty { + WorkComposerDraftStore.save(text, for: persistenceKey) + } + } + + /// Whether an unconfirmed send owns this key's stored draft. + func isSendInFlight(for key: String) -> Bool { + !key.isEmpty && pendingSendKey == key } func restoreUnsentText(_ value: String) { @@ -4252,8 +4351,10 @@ private struct WorkChatComposerSendButton: View { var accessibilityLabelText = "Send message" var systemImageName = "arrow.up" var minimumTapTargetSize: CGFloat = 28 - let onSend: @MainActor (String, [WorkChatInputAttachment]) async -> Bool - let onSent: () -> Void + /// The send itself lives on the composer, not here: the retry row runs the + /// exact same action, and two copies of "clear the field, stage, restore on + /// failure" is how the two drift apart. + let action: @MainActor () -> Void private var sendEnabled: Bool { workChatInputCanSend( @@ -4272,23 +4373,7 @@ private struct WorkChatComposerSendButton: View { systemImageName: systemImageName, minimumTapTargetSize: minimumTapTargetSize ) { - let originalText = draftState.consumeSendableText() - let outgoingAttachments = workChatInputReadyAttachments(attachments) - let text = workChatOutgoingText(originalText, attachmentCount: outgoingAttachments.count) - let restoredAttachments = attachments - attachments.removeAll() - Task { @MainActor in - let sent = await onSend(text, outgoingAttachments) - if sent { - // The refs were consumed by the send; drop the upload tracking so a - // later attachment can never reuse a sent message's ref. - WorkComposerAttachmentUploads.shared.release(restoredAttachments.map(\.id)) - onSent() - } else { - attachments = restoredAttachments - draftState.restoreUnsentText(originalText) - } - } + action() } .adeInspectable( "Work.Chat.Composer.SendButton", diff --git a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift index 1c1a047931..acdc402892 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift @@ -136,6 +136,113 @@ func workChatAttachmentSignature(_ attachments: [WorkChatInputAttachment]) -> St // MARK: - Upload on attach +/// How long a send waits for an upload that started when the attachment was +/// staged, before it gives up and fails visibly. +/// +/// Matches `SyncRequestTimeout.chatSendTimeoutNanoseconds`: the same budget the +/// message itself gets, which is long enough for a 50 MB video on a slow relay +/// leg and short enough that a wedged upload becomes an error the user can act +/// on instead of a permanent "Sending". +let workComposerAttachmentUploadTimeoutNanoseconds: UInt64 = SyncRequestTimeout.chatSendTimeoutNanoseconds + +/// Plain-language failure for an upload that never landed. Deliberately the +/// same sentence the composer's retry row shows, so the banner and the chip +/// cannot say two different things about one message. +let workChatAttachmentUploadTimedOutMessage = "Couldn't send. Tap to retry." + +func workChatAttachmentUploadTimedOutError(_ filename: String) -> NSError { + NSError( + domain: "ADE", + code: 32, + userInfo: [ + NSLocalizedDescriptionKey: workChatAttachmentUploadTimedOutMessage, + "attachmentFilename": filename, + ] + ) +} + +/// What the staged-upload tracker knows about one attachment at send time. +enum WorkComposerAttachmentUploadResolution: Equatable { + /// The host holds the bytes; send this ref. + case ref(AgentChatFileRef) + /// Nothing usable is tracked — no upload was started, or the one that ran + /// failed. The caller stages the bytes itself. + case stageInline + /// A tracked upload passed its deadline. Retrying inline would queue a second + /// copy behind the same wedged leg, so the send fails instead. + case abandoned +} + +/// Awaits `work`, giving up after `timeoutNanoseconds` and returning nil. +/// +/// Deliberately not a task group: a group only returns once *every* child has +/// finished, so a child parked in a non-cancellable continuation makes the +/// deadline a lie. This abandons the wait without touching the work, which is +/// the semantics a send needs — the upload may still land and be reused, but +/// the user is not held behind it. +@MainActor +func workAwaitWithDeadline( + timeoutNanoseconds: UInt64, + work: @escaping @MainActor () async -> T +) async -> T? { + let box = WorkDeadlineBox() + return await withCheckedContinuation { (continuation: CheckedContinuation) in + box.continuation = continuation + box.timer = Task { @MainActor in + try? await Task.sleep(nanoseconds: timeoutNanoseconds) + guard !Task.isCancelled else { return } + box.settle(nil) + } + Task { @MainActor in + let value = await work() + box.settle(value) + } + } +} + +/// One-shot resume guard for `workAwaitWithDeadline`. Both racers call +/// `settle`; the first one wins and the continuation is dropped, so the loser +/// cannot resume it twice. +@MainActor +private final class WorkDeadlineBox { + var continuation: CheckedContinuation? + var timer: Task? + + func settle(_ value: T?) { + guard let continuation else { return } + self.continuation = nil + timer?.cancel() + timer = nil + continuation.resume(returning: value) + } +} + +/// Writes one composer's staged attachments under a draft key: host refs for +/// everything already uploaded, purgeable cached bytes for everything else. +/// +/// Shared by the in-chat composer's autosave and by its send path, which has to +/// write the outgoing set BEFORE it clears the tray — a send that never +/// confirms must leave the user their attachments, not an empty composer. +@MainActor +func workChatPersistComposerAttachments( + _ attachments: [WorkChatInputAttachment], + for key: String +) { + guard !key.isEmpty else { return } + let uploads = WorkComposerAttachmentUploads.shared + var refs: [AgentChatFileRef] = [] + var unsaved: [WorkChatInputAttachment] = [] + for attachment in attachments where attachment.isReady { + if let ref = attachment.hostRef ?? uploads.ref(for: attachment.id) { + refs.append(ref) + } else { + unsaved.append(attachment) + } + } + let localFiles = WorkComposerDraftAttachmentCache.write(unsaved, for: key) + WorkComposerDraftStore.saveAttachments(refs, owner: nil, localFiles: localFiles, for: key) +} + /// Tracks the host upload that starts the moment an attachment becomes `.ready`, /// keyed by the attachment's id. /// @@ -203,10 +310,11 @@ final class WorkComposerAttachmentUploads { projectId: String?, projectRootPath: String? ) { - guard entries[attachment.id] == nil, attachment.isReady else { return } - let id = attachment.id - let task = Task { @MainActor () throws -> AgentChatFileRef in - try await workChatUploadSingleAttachment( + begin(id: attachment.id, isReady: attachment.isReady) { + // The RAW host call, never `workChatSaveInputAttachments`: that wrapper + // asks this tracker to resolve the same id, so routing the upload through + // it made this task await itself and hang forever. + try await workChatStageAttachmentOnHost( attachment, syncService: syncService, chatSessionId: chatSessionId, @@ -214,6 +322,20 @@ final class WorkComposerAttachmentUploads { targetProjectRootPath: projectRootPath ) } + } + + /// Tracking half of `begin`, with the upload itself injected. The only seam a + /// test can use to pin the "a stuck upload must not wedge a send" contract + /// without a live host. + func begin( + id: UUID, + isReady: Bool = true, + upload: @escaping @MainActor () async throws -> AgentChatFileRef + ) { + guard entries[id] == nil, isReady else { return } + let task = Task { @MainActor () throws -> AgentChatFileRef in + try await upload() + } insert(id, Entry(task: task, ref: nil, failure: nil, startedAt: Date())) Task { @MainActor in do { @@ -230,14 +352,36 @@ final class WorkComposerAttachmentUploads { } } - /// The ref for an attachment, waiting for an in-flight upload. Returns nil - /// when nothing is tracked (the caller then uploads inline) and rethrows a - /// tracked failure so the send path can retry. - func resolve(_ id: UUID) async -> AgentChatFileRef? { - guard let entry = entries[id] else { return nil } - if let ref = entry.ref { return ref } - if entry.failure != nil { return nil } - return try? await entry.task.value + /// The ref for an attachment, waiting for an in-flight upload — for at most + /// `timeoutNanoseconds`. + /// + /// The deadline is the reason this returns a case rather than an optional. A + /// send awaits this before it can put anything on the wire, so an upload that + /// never completes used to hold the message at "Sending" for the life of the + /// process: no error, no retry, and the draft already cleared. Waiting is now + /// always bounded, and "nothing is tracked" is told apart from "we gave up". + func resolve( + _ id: UUID, + timeoutNanoseconds: UInt64 = workComposerAttachmentUploadTimeoutNanoseconds + ) async -> WorkComposerAttachmentUploadResolution { + guard let entry = entries[id] else { return .stageInline } + if let ref = entry.ref { return .ref(ref) } + if entry.failure != nil { return .stageInline } + let task = entry.task + let settled = await workAwaitWithDeadline(timeoutNanoseconds: timeoutNanoseconds) { + try? await task.value + } + switch settled { + case .some(.some(let ref)): + return .ref(ref) + case .some(.none): + // The upload finished by failing. An inline retry is the cheap recovery + // for a transient network error. + return .stageInline + case .none: + entries[id]?.failure = workChatAttachmentUploadTimedOutMessage + return .abandoned + } } func release(_ ids: [UUID]) { @@ -259,54 +403,6 @@ final class WorkComposerAttachmentUploads { } } -/// Stages one attachment on the host and returns its ref. Images keep the -/// historical base64 image route; everything else rides the chunked file route. -@MainActor -func workChatUploadSingleAttachment( - _ attachment: WorkChatInputAttachment, - syncService: SyncService, - chatSessionId: String?, - targetProjectId: String?, - targetProjectRootPath: String? -) async throws -> AgentChatFileRef { - guard let data = attachment.uploadData else { - throw NSError( - domain: "ADE", - code: 30, - userInfo: [NSLocalizedDescriptionKey: "This attachment has no data to upload."] - ) - } - if attachment.kind == .image { - let refs = try await workChatSaveInputAttachments( - [attachment], - syncService: syncService, - chatSessionId: chatSessionId, - targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath - ) - guard let ref = refs.first else { - throw NSError( - domain: "ADE", - code: 31, - userInfo: [NSLocalizedDescriptionKey: "The host did not accept this image."] - ) - } - return ref - } - let saved = try await syncService.saveChatFileAttachment( - data: data, - filename: attachment.filename, - chatSessionId: chatSessionId, - targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath - ) - // `file` is the ref type desktop uses for non-image attachments, so the agent - // receives a path rather than an inlined image. The host parser accepts the - // exact literals `image` and `file` and silently DROPS anything else, so the - // capitalisation here is load-bearing. - return AgentChatFileRef(path: saved.path, type: "file") -} - // MARK: - Offline byte cache /// Purgeable on-disk home for staged bytes whose host upload has not landed — diff --git a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift index d572b2a454..0987d58810 100644 --- a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift +++ b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift @@ -233,6 +233,172 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { ) } + // MARK: - Staged uploads and unconfirmed sends + + /// The regression this file exists for as of 2026-09-19: a message with an + /// image sat at "Sending" forever. + /// + /// The send awaits the upload that started when the attachment was staged. As + /// long as that wait was unbounded, an upload that never completed — and the + /// image path deadlocked on itself, so it never did — held the message with + /// no error, no retry, and the draft already cleared. The wait is now bounded + /// and reports that it gave up. + @MainActor + func testAStuckStagedUploadIsAbandonedInsteadOfHangingTheSend() async { + let id = UUID() + let uploads = WorkComposerAttachmentUploads.shared + defer { uploads.release([id]) } + + uploads.begin(id: id) { + // Never lands: the wedged leg this bug was made of. + try await Task.sleep(nanoseconds: 60_000_000_000) + return AgentChatFileRef(path: "/never", type: "image") + } + + let resolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(resolution, .abandoned) + } + + /// The ordinary path: a send that arrives while the upload is still moving + /// waits for it and sends the host's ref rather than re-uploading the bytes. + @MainActor + func testResolveReturnsTheStagedRefWhenTheUploadLands() async { + let id = UUID() + let uploads = WorkComposerAttachmentUploads.shared + defer { uploads.release([id]) } + + uploads.begin(id: id) { + await Task.yield() + return AgentChatFileRef(path: "/tmp/.ade/attachments/staged.jpg", type: "image") + } + + let resolution = await uploads.resolve(id, timeoutNanoseconds: 5_000_000_000) + XCTAssertEqual(resolution, .ref(AgentChatFileRef(path: "/tmp/.ade/attachments/staged.jpg", type: "image"))) + } + + /// Two different "no ref" answers. An untracked attachment and one whose + /// upload failed both fall through to an inline upload — only a wait that ran + /// out of time fails the send, because a second copy would queue behind the + /// same stuck leg. + @MainActor + func testUntrackedAndFailedUploadsStageInline() async { + let uploads = WorkComposerAttachmentUploads.shared + let untracked = UUID() + let untrackedResolution = await uploads.resolve(untracked, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(untrackedResolution, .stageInline) + + let failing = UUID() + defer { uploads.release([failing]) } + uploads.begin(id: failing) { + throw NSError(domain: "ADE", code: 1, userInfo: [NSLocalizedDescriptionKey: "no route"]) + } + let failedResolution = await uploads.resolve(failing, timeoutNanoseconds: 5_000_000_000) + XCTAssertEqual(failedResolution, .stageInline) + } + + /// A send owns the stored draft until the host confirms it. Tapping send used + /// to clear the draft immediately, so leaving the chat while it said + /// "Sending" destroyed the message. + @MainActor + func testUnconfirmedSendKeepsTheDraftAndAConfirmedOneDropsIt() { + let draftKey = key() + defer { WorkComposerDraftStore.clear(draftKey) } + + let draft = WorkChatComposerDraftState() + draft.bind(persistenceKey: draftKey) + draft.text = "look at this screenshot" + + XCTAssertEqual(draft.beginPendingSend(), "look at this screenshot") + XCTAssertEqual(draft.text, "") + // Still in flight: the stored copy is the only surviving one, so leaving the + // chat now must find it. + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") + XCTAssertTrue(draft.isSendInFlight(for: draftKey)) + + // A teardown flush while the send is unconfirmed must not write the emptied + // field over it. + draft.flushDraft() + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") + + draft.finishPendingSend(sent: false) + XCTAssertFalse(draft.isSendInFlight(for: draftKey)) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") + + draft.text = "look at this screenshot" + _ = draft.beginPendingSend() + draft.finishPendingSend(sent: true) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "", "a confirmed send consumes the draft") + } + + /// The in-flight message owns the stored draft, but only its own key and + /// only until it settles — text typed behind it is written the moment the + /// send confirms, instead of being dropped with it. + @MainActor + func testTextTypedDuringAnUnconfirmedSendIsStoredOnceItConfirms() { + let draftKey = key() + defer { WorkComposerDraftStore.clear(draftKey) } + + let draft = WorkChatComposerDraftState() + draft.bind(persistenceKey: draftKey) + draft.text = "first message" + _ = draft.beginPendingSend() + + draft.text = "second message" + draft.flushDraft() + XCTAssertEqual( + WorkComposerDraftStore.load(draftKey), + "first message", + "the unconfirmed send owns the stored draft" + ) + + draft.finishPendingSend(sent: true) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "second message") + } + + /// The attachment half of the same rule: the outgoing files are written under + /// the chat's draft key before the tray is emptied, so an unconfirmed send + /// leaves both the text and the image recoverable. + @MainActor + func testOutgoingAttachmentsArePersistedForAnUnconfirmedSend() { + let draftKey = key() + defer { WorkComposerDraftStore.clear(draftKey) } + + let staged = WorkChatInputAttachment( + uploadData: Data("jpeg-bytes".utf8), + filename: "screenshot.jpg", + mimeType: "image/jpeg", + kind: .image, + state: .ready + ) + let alreadyUploaded = WorkChatInputAttachment( + filename: "clip.mov", + kind: .video, + hostRef: AgentChatFileRef(path: "/tmp/.ade/attachments/clip.mov", type: "file"), + state: .ready + ) + + workChatPersistComposerAttachments([staged, alreadyUploaded], for: draftKey) + + let entry = WorkComposerDraftStore.loadEntry(draftKey) + XCTAssertEqual(entry?.attachments.first?.path, "/tmp/.ade/attachments/clip.mov") + XCTAssertEqual(entry?.localFiles.first?.filename, "screenshot.jpg") + XCTAssertEqual( + WorkComposerDraftAttachmentCache.read(entry?.localFiles ?? [], for: draftKey).first?.uploadData, + Data("jpeg-bytes".utf8) + ) + } + + /// The copy the retry row shows. Short, plain, and one sentence — it is also + /// the message the abandoned-upload error carries, so the row and the error + /// cannot disagree. + func testSendFailureCopyIsPlain() { + XCTAssertEqual(workChatAttachmentUploadTimedOutMessage, "Couldn't send. Tap to retry.") + XCTAssertEqual( + workChatAttachmentUploadTimedOutError("screenshot.jpg").localizedDescription, + "Couldn't send. Tap to retry." + ) + } + // MARK: - Preview temp files /// The other end of the same staging story: a PDF or video preview is diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index 8395410ea3..79ed8ba380 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -875,7 +875,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { draft.text = " Ship this change " draft.isFocused = true - XCTAssertEqual(draft.consumeSendableText(), "Ship this change") + XCTAssertEqual(draft.beginPendingSend(), "Ship this change") XCTAssertEqual(draft.text, "") XCTAssertFalse(draft.isFocused) @@ -896,7 +896,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(draft.text, "Check whether this already started") XCTAssertTrue(draft.isFocused) - XCTAssertEqual(draft.consumeSendableText(), "Check whether this already started") + XCTAssertEqual(draft.beginPendingSend(), "Check whether this already started") draft.applyRestore(restore) XCTAssertEqual(draft.text, "", "The same restoration token must not refill a draft after the user sends it.") diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 4d98519880..a71e1631f1 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -776,8 +776,10 @@ without. Image sends echo *before* the upload: the composer's downscaled `UIImage` renders behind an uploading state under an `ade-pending-upload://` placeholder ref (`WorkPendingUploadPreviewStore`), swapped for the real host path before the message is sent so the echo's dedupe key still matches the -transcript row that returns. `sending` releases when the host accepts the -message; the transcript/artifact/summary/session refresh runs behind the +transcript row that returns. The wait for those bytes is bounded (see the +composer-draft rules below), so an upload that never lands fails the send and +offers a retry instead of holding the echo at "Sending". `sending` releases when +the host accepts the message; the transcript/artifact/summary/session refresh runs behind the composer, chained so two quick sends cannot interleave two transcript loads. Because that makes back-to-back identical sends easy, echo suppression counts @@ -4127,11 +4129,10 @@ the stats and shows update guidance. on a 400 ms debounce and flushed on disappear because a cancelled `.task` throws out of its sleep before the write. Three rules are load-bearing: restore only into an empty field (a failed send that put its text back, or - a card already mid-edit, is fresher than disk); clear synchronously on send - rather than letting the debounce get there, or a jetsam inside that window - resurrects an already-sent message and invites a duplicate; and never write - an `isSecret` answer, because that defaults suite is shared with the widget - extension and would hold a credential in plaintext. Clearing a host profile + a card already mid-edit, is fresher than disk); **a send owns the stored + draft until the host confirms it**; and never write an `isSecret` answer, + because that defaults suite is shared with the widget extension and would + hold a credential in plaintext. Clearing a host profile deliberately does **not** touch these stores — `forgetHost()` has no UI caller and fires automatically from `handleReconnectFailure` on an attributed auth failure, and the stores are keyed by session id rather than @@ -4144,6 +4145,38 @@ the stats and shows update guidance. in-flight task. When the host is unreachable there is no ref to persist, so the bytes go to a purgeable `Caches/ade-composer-drafts/` directory bounded at 5 files and 10 MB, purged on send, on clear, and on LRU eviction. +- **An unconfirmed send keeps its whole message, text and attachments.** + Tapping send writes the outgoing text (`WorkChatComposerDraftState + .beginPendingSend`) and the outgoing attachment refs or cached bytes + (`workChatPersistComposerAttachments`) under the chat's draft key *before* the + field and tray are emptied, and `finishPendingSend(sent:)` drops the stored + copy only once the host has taken the message. While that send is in flight + the composer's autosave and its attachment persist both stand down, so the + emptied field cannot be written over the payload they are holding. Leaving the + chat, switching chats, or relaunching therefore restores the message — the + case that used to lose it outright. The reverse cost is explicit: a kill + between the host accepting and the confirmation returning restores an + already-sent message as a draft, which is what the + `SyncRequestTimeout.chatSendMessage` copy already tells the user to check the + transcript for. +- **Waiting for a staged upload is bounded, and a send that cannot complete says + so.** `WorkComposerAttachmentUploads.resolve` waits at most + `workComposerAttachmentUploadTimeoutNanoseconds` (= + `SyncRequestTimeout.chatSendTimeoutNanoseconds`) for the upload that started + when the attachment was staged, and reports one of three answers: the host's + ref, "stage inline" (nothing tracked, or the upload failed — the send uploads + the bytes itself), or "abandoned" (the deadline passed; the send fails rather + than queueing a second copy behind a wedged leg). `workAwaitWithDeadline` + abandons the *wait*, not the upload, because a task group only returns once + every child has finished and the upload legs park in non-cancellable + continuations. A failed send restores the composer and shows one retry row + above the field — "Couldn't send. Tap to retry." — whose tap runs the same + `performSend` the button does, in the same send mode. The upload the tracker + runs calls `workChatStageAttachmentOnHost` (the raw host call) and never + `workChatSaveInputAttachments`: that wrapper resolves through this tracker, so + routing the upload through it made the task await its own completion and every + message carrying an image sat at "Sending" forever, with no error, no retry, + and the draft already cleared. - **The fallback transcript is built lazily, and the guard order that makes that work is load-bearing.** `WorkSessionDestinationView` keeps a cached-entry fallback alongside the live event transcript, but materializing From 022b926d2faccb45989e03b2e6c2bcb3613f96f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:23:49 -0400 Subject: [PATCH 06/26] feat(ios): announce mobileChatSlimV1, fetch big tool results on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the phone downloaded the same chat wire as desktop, and tapping an in-thread subagent replaced the thread the user was reading with a read-only copy of that agent's work, kept alive by a 1.5-second transcript poll. Change and boundary: iOS announces `mobileChatSlimV1`, so the host folds subagent progress per agent, drops the `subagent.progress` mirror, and sends tool results as a ~2 KB head slice. A truncated Result block now says how much more there is, fetches it through `chat_tool_result` on tap with a spinner, and caches it per item in SyncService so re-expanding it (or opening it from the turn-activity sheet) is free. Tapping an in-thread subagent expands its card and nothing else; `chat.getSubagentTranscript` and its poll are gone from the phone. Separate subagent chats and child lanes are untouched — they are full chats and open as they always have. Tool call + result already merged into one card and clustered into one group per turn (`collapseConsecutiveWorkToolEntries`, `workTurnToolActivityIndex`), so no row change was needed. Verification: xcodebuild build (iPhone 17e simulator, own derived data) BUILD SUCCEEDED; 14 focused ADETests timeline/subagent/tool-card tests passed, 0 failures. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Models/RemoteModels.swift | 43 +++- apps/ios/ADE/Services/SyncService.swift | 160 ++++++++---- .../Views/Work/WorkChatRichCardViews.swift | 104 +++++++- .../Work/WorkErrorAndMessageHelpers.swift | 3 +- apps/ios/ADE/Views/Work/WorkModels.swift | 22 +- .../WorkNavigationAndTranscriptHelpers.swift | 7 +- .../Work/WorkSessionDestinationView.swift | 241 ++---------------- .../Work/WorkStatusAndFormattingHelpers.swift | 17 +- apps/ios/ADETests/ADETests.swift | 50 +--- docs/features/sync-and-multi-device/README.md | 3 + .../sync-and-multi-device/ios-companion.md | 76 ++++++ 11 files changed, 386 insertions(+), 340 deletions(-) diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 67e491bd57..65b83fcd57 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2590,6 +2590,15 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { /// AgentChatEvent also exposes the two optional values for direct callers. var stopSource: String? var stopReason: String? + /// Size of the stored tool result when the host sent only a head slice of + /// it (`resultTruncatedForMobile` on the wire), nil when the row carries the + /// whole thing. + /// + /// Kept beside the event for the same reason as the fields above: the Result + /// block needs "there is more, and it is this big" to label its fetch, and + /// `AgentChatEvent.toolResult` already carries seven associated values that + /// every construction site in the app and the tests would have to restate. + var toolResultFullBytes: Int? init( sessionId: String, @@ -2606,7 +2615,8 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { apiErrorStatus: Int? = nil, isLegacySubagentCompletedFrame: Bool = false, stopSource: String? = nil, - stopReason: String? = nil + stopReason: String? = nil, + toolResultFullBytes: Int? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -2623,6 +2633,7 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { self.isLegacySubagentCompletedFrame = isLegacySubagentCompletedFrame self.stopSource = stopSource self.stopReason = stopReason + self.toolResultFullBytes = toolResultFullBytes } private enum CodingKeys: String, CodingKey { @@ -2642,6 +2653,8 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { var apiErrorStatus: Int? var stopSource: String? var stopReason: String? + var resultTruncatedForMobile: Bool? + var resultOriginalBytes: Int? private enum CodingKeys: String, CodingKey { case type @@ -2649,6 +2662,8 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { case apiErrorStatusSnake = "api_error_status" case stopSource case stopReason + case resultTruncatedForMobile + case resultOriginalBytes } /// Each field is decoded on its own tolerant path, never a shared throwing @@ -2665,6 +2680,8 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { ?? nil stopSource = (try? container.decodeIfPresent(String.self, forKey: .stopSource)) ?? nil stopReason = (try? container.decodeIfPresent(String.self, forKey: .stopReason)) ?? nil + resultTruncatedForMobile = (try? container.decodeIfPresent(Bool.self, forKey: .resultTruncatedForMobile)) ?? nil + resultOriginalBytes = (try? container.decodeIfPresent(Int.self, forKey: .resultOriginalBytes)) ?? nil } } @@ -2721,6 +2738,12 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { isLegacySubagentCompletedFrame = rawEvent?.type == "subagent.completed" stopSource = rawEvent?.stopSource stopReason = rawEvent?.stopReason + // Only a host that honoured `mobileChatSlimV1` sets this. A host that did + // not sends the whole result and no flag, and the row behaves exactly as + // it does today. + toolResultFullBytes = rawEvent?.resultTruncatedForMobile == true + ? (rawEvent?.resultOriginalBytes).map { max(0, $0) } + : nil } } @@ -2782,6 +2805,24 @@ struct AgentChatEventHistoryPage: Decodable, Equatable { var unavailable: Bool? } +/// One tool result fetched on demand, after the slim mobile wire delivered +/// only its head slice. +/// +/// `found == false` is final ("that result is no longer in the transcript"); +/// `unavailable == true` is retryable ("this host could not read it"). The row +/// says different things for the two, so they stay different fields. +struct AgentChatToolResultResponse: Decodable, Equatable { + var sessionId: String + var itemId: String + var result: RemoteJSONValue? + var resultOriginalBytes: Int? + var resultOmittedBytes: Int? + var status: String? + var tool: String? + var unavailable: Bool? + var found: Bool +} + struct AgentChatFileRef: Codable, Equatable, Hashable { var path: String var type: String diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4aa25ad51e..4aca63f543 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -3581,6 +3581,7 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St "chat_subscribe", "chat_unsubscribe", "chat_history", + "chat_tool_result", ] guard projectScopedTypes.contains(type) else { return nil } return syncNormalizedCommandScopeValue(activeProjectId) @@ -13802,6 +13803,98 @@ final class SyncService: ObservableObject { return page } + /// Full tool results already fetched on this device, keyed + /// `sessionId|itemId`. + /// + /// A tool result is immutable once written, so a hit is always correct and + /// re-expanding a row never pays for the round trip twice. Lives here rather + /// than in the row so it survives the LazyVStack recycling the row, and so + /// the inline card and the turn-activity sheet share one copy. Bounded: a + /// long thread must not accumulate every result the user ever opened. + private var chatToolResultCache: [String: String] = [:] + private var chatToolResultCacheOrder: [String] = [] + private var chatToolResultInFlight: [String: Task] = [:] + private static let chatToolResultCacheLimit = 32 + + private func chatToolResultCacheKey(sessionId: String, itemId: String) -> String { + "\(sessionId)|\(itemId)" + } + + /// The cached full result, if this row has already been expanded once. + func cachedFullToolResult(sessionId: String, itemId: String) -> String? { + chatToolResultCache[chatToolResultCacheKey(sessionId: sessionId, itemId: itemId)] + } + + /// Fetch (or return the cached) full text of one truncated tool result. + /// + /// Concurrent expands of the same row share one request: a double tap, or an + /// inline card and the turn-activity sheet showing the same result, must not + /// become two reads of the same transcript. + func fullToolResult(sessionId: String, itemId: String) async throws -> String { + let key = chatToolResultCacheKey(sessionId: sessionId, itemId: itemId) + if let cached = chatToolResultCache[key] { return cached } + if let inFlight = chatToolResultInFlight[key] { return try await inFlight.value } + let task = Task { [weak self] in + guard let self else { throw CancellationError() } + let response = try await self.fetchChatToolResult(sessionId: sessionId, itemId: itemId) + if response.unavailable == true { + throw NSError( + domain: "ADE", + code: 22, + userInfo: [NSLocalizedDescriptionKey: "Could not load the full result from this machine."] + ) + } + guard response.found, let result = response.result else { + throw NSError( + domain: "ADE", + code: 23, + userInfo: [NSLocalizedDescriptionKey: "This result is no longer in the transcript."] + ) + } + return prettyPrintedRemoteJSONValue(result) + } + chatToolResultInFlight[key] = task + defer { chatToolResultInFlight[key] = nil } + let text = try await task.value + chatToolResultCache[key] = text + chatToolResultCacheOrder.append(key) + while chatToolResultCacheOrder.count > Self.chatToolResultCacheLimit { + let evicted = chatToolResultCacheOrder.removeFirst() + chatToolResultCache[evicted] = nil + } + return text + } + + /// Fetch one tool result in full, for a row the slim mobile wire delivered + /// as a head slice. + /// + /// Requires an open subscription, exactly like `chat_history`: the host + /// scopes the read to the transcript this device is attached to. There is no + /// ADE-RPC fallback on purpose — only a host that honoured + /// `mobileChatSlimV1` truncates a result in the first place, and that host + /// serves `chat_tool_result` by construction. + func fetchChatToolResult( + sessionId: String, + itemId: String + ) async throws -> AgentChatToolResultResponse { + let requestId = makeRequestId() + var payload = chatSubscriptionPayload( + sessionId: sessionId, + maxBytes: nil, + includeSinceSeq: false + ) + payload["itemId"] = itemId + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: "Timed out loading the full tool result.", + timeoutNanoseconds: 8_000_000_000 + ) { + self.sendEnvelope(type: "chat_tool_result", requestId: requestId, payload: payload) + } + return try decode(raw, as: AgentChatToolResultResponse.self) + } + func fetchChatEventHistoryPage( sessionId: String, beforeOffset: Int, @@ -13899,16 +13992,6 @@ final class SyncService: ObservableObject { var cursorKind: String? } - struct AgentChatSubagentTranscriptMessage: Codable, Equatable { - var type: String - var uuid: String? - var sessionId: String - var parentToolUseId: String? - var message: RemoteJSONValue? - var text: String? - var subagentMetadata: RemoteJSONValue? - } - struct AgentChatSubagentSnapshot: Codable, Equatable { var taskId: String var agentId: String? @@ -14013,46 +14096,6 @@ final class SyncService: ObservableObject { ) } - func fetchSubagentTranscript( - sessionId: String, - agentId: String, - taskId: String? = nil, - laneId: String? = nil, - limit: Int? = nil, - offset: Int? = nil - ) async throws -> [AgentChatSubagentTranscriptMessage]? { - var args: [String: Any] = [ - "sessionId": sessionId, - "agentId": agentId, - ] - if let taskId, !taskId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args["taskId"] = taskId - } - if let laneId, !laneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args["laneId"] = laneId - } - if let limit { - args["limit"] = limit - } - if let offset { - args["offset"] = offset - } - let scope = chatCommandScope(for: sessionId) - let response = try await sendCommand( - action: chatActionName("chat.getSubagentTranscript", sessionId: sessionId), - args: args, - targetProjectId: scope.projectId, - targetProjectRootPath: scope.rootPath - ) - if response is NSNull { - return nil - } - if let payload = response as? [String: Any], payload["queued"] as? Bool == true { - throw QueuedRemoteCommandError(action: "chat.getSubagentTranscript") - } - return try decode(response, as: [AgentChatSubagentTranscriptMessage].self) - } - func fetchSubagents(sessionId: String) async throws -> [AgentChatSubagentSnapshot] { let scope = chatCommandScope(for: sessionId) let response = try await sendCommand( @@ -17569,7 +17612,19 @@ final class SyncService: ObservableObject { "deviceType": "phone", "siteId": database.localSiteId(), "dbVersion": latestRemoteDbVersion, - "capabilities": ["changesetAck", "chunkedEnvelopes", "relayReauthorizeV1", "binaryEnvelopes", "foldedReplay"], + // `mobileChatSlimV1`: the host may fold subagent progress to the latest + // per agent, drop the mirrored `subagent.progress` twin, and send tool + // results as a bounded slice this app fetches in full on expand (see + // `fetchChatToolResult`). Older hosts ignore the capability and keep + // sending the full wire, which this app still renders unchanged. + "capabilities": [ + "changesetAck", + "chunkedEnvelopes", + "relayReauthorizeV1", + "binaryEnvelopes", + "foldedReplay", + "mobileChatSlimV1", + ], ] if let appVersion = (info["CFBundleShortVersionString"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines), @@ -19420,7 +19475,8 @@ final class SyncService: ObservableObject { let message = dict["message"] as? String ?? "Remote command rejected." resolve(requestId: requestId, result: .failure(NSError(domain: "ADE", code: 6, userInfo: [NSLocalizedDescriptionKey: message]))) } - case "command_result", "file_response", "terminal_snapshot", "terminal_history", "chat_history": + case "command_result", "file_response", "terminal_snapshot", "terminal_history", "chat_history", + "chat_tool_result": resolve(requestId: requestId, result: .success(payload)) case "chat_subscribe": if supportsChatStreaming, diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index c2dfe0640f..191734eb62 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -89,7 +89,45 @@ struct WorkToolCardView: View, Equatable { let onOpenPr: (Int) -> Void @Environment(\.openURL) private var openURL + @EnvironmentObject private var syncService: SyncService @State private var resultExpanded = false + /// The full result, once fetched for a row the slim mobile wire truncated. + /// Seeded from the service cache on appear so a recycled row does not lose + /// what the user already opened. + @State private var fetchedFullResult: String? + @State private var fetchingFullResult = false + @State private var fullResultError: String? + + /// The result this card should render: the fetched full text when there is + /// one, otherwise whatever arrived on the wire. + private var effectiveResultText: String? { + fetchedFullResult ?? toolCard.resultText + } + + /// True while the card is showing a head slice and the rest is one tap away. + private var hasUnfetchedRemoteResult: Bool { + fetchedFullResult == nil && toolCard.remoteResultBytes != nil && toolCard.sessionId != nil + } + + private func loadFullResult() { + guard let sessionId = toolCard.sessionId, !fetchingFullResult else { return } + fetchingFullResult = true + fullResultError = nil + Task { @MainActor in + defer { fetchingFullResult = false } + do { + fetchedFullResult = try await syncService.fullToolResult( + sessionId: sessionId, + itemId: toolCard.id + ) + resultExpanded = true + } catch { + // The slice stays on screen; only the affordance changes. Losing the + // preview to an error message would hide what the user already had. + fullResultError = (error as NSError).localizedDescription + } + } + } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -170,7 +208,7 @@ struct WorkToolCardView: View, Equatable { if let argsText = toolCard.argsText, !argsText.isEmpty { WorkStructuredOutputBlock(title: "Arguments", text: argsText) } - if let resultText = toolCard.resultText, !resultText.isEmpty { + if let resultText = effectiveResultText, !resultText.isEmpty { let result = workToolResultBlockText(resultText, expanded: resultExpanded) // The block displays a slice; Copy and the viewer get the whole // result. @@ -223,6 +261,12 @@ struct WorkToolCardView: View, Equatable { displayedText: String, didTruncate: Bool ) -> some View { + if hasUnfetchedRemoteResult { + // This machine sent one screen of the result and kept the rest. The row + // says how much more there is, fetches it on tap, and shows the fetch + // running rather than freezing on the slice. + remoteResultAffordance + } else { let affordance = workTruncatedOutputAffordance( isTruncated: didTruncate, hasExpandedInPlace: resultExpanded, @@ -274,6 +318,48 @@ struct WorkToolCardView: View, Equatable { Spacer(minLength: 0) } + } + } + + /// "Show all (N)" for a result whose bytes live on the machine, not here. + @ViewBuilder + private var remoteResultAffordance: some View { + HStack(spacing: 12) { + if fetchingFullResult { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading full result…") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + .frame(minHeight: 44) + .accessibilityLabel("Loading full tool result") + } else { + Button(action: loadFullResult) { + Text("Show all (\(workToolResultRemoteByteLabel(toolCard.remoteResultBytes ?? 0)))") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Show full tool result") + } + + if let fullResultError { + Text(fullResultError) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + + Spacer(minLength: 0) + } + .onAppear { + guard fetchedFullResult == nil, let sessionId = toolCard.sessionId else { return } + fetchedFullResult = syncService.cachedFullToolResult(sessionId: sessionId, itemId: toolCard.id) + } } /// Single-line compact row used once the tool completes — matches the @@ -877,6 +963,14 @@ func workToolResultBlockText( /// Short "N chars" label used in the "Show all" affordance. Uses the raw /// character count — this is display copy, not a byte-precise measurement. +/// Size label for a result this device has not downloaded, so it is measured +/// in the host's bytes rather than in characters this device does not have. +func workToolResultRemoteByteLabel(_ bytes: Int) -> String { + if bytes < 1_024 { return "\(bytes) bytes" } + if bytes < 1_024 * 1_024 { return String(format: "%.1f KB", Double(bytes) / 1_024.0) } + return String(format: "%.1f MB", Double(bytes) / (1_024.0 * 1_024.0)) +} + func workToolResultByteLabel(_ text: String) -> String { let count = text.count if count < 1000 { return "\(count) chars" } @@ -2825,7 +2919,6 @@ struct WorkChatInfoDetailsSheet: View { let nextWakeAt: String? let provider: String? let selectedTaskId: String? - let probingTaskId: String? let sessionModel: String? @Binding var expandedTaskIds: Set let onSelect: @MainActor (WorkSubagentSnapshot) async -> Void @@ -2854,7 +2947,6 @@ struct WorkChatInfoDetailsSheet: View { nextWakeAt: String?, provider: String?, selectedTaskId: String?, - probingTaskId: String?, expandedTaskIds: Binding>, sessionModel: String? = nil, onSelect: @escaping @MainActor (WorkSubagentSnapshot) async -> Void, @@ -2869,7 +2961,6 @@ struct WorkChatInfoDetailsSheet: View { self.nextWakeAt = nextWakeAt self.provider = provider self.selectedTaskId = selectedTaskId - self.probingTaskId = probingTaskId self.sessionModel = sessionModel self._expandedTaskIds = expandedTaskIds self.onSelect = onSelect @@ -3190,7 +3281,6 @@ struct WorkChatInfoDetailsSheet: View { WorkChatInfoSubagentRow( snapshot: snapshot, selected: selectedTaskId == snapshot.taskId, - probing: probingTaskId == snapshot.taskId, expanded: expandedTaskIds.contains(snapshot.taskId), sessionModel: sessionModel, treePrefix: workSubagentTreePrefix(snapshot, in: subagents), @@ -3365,7 +3455,6 @@ private struct WorkSquareStopButton: View { private struct WorkChatInfoSubagentRow: View { let snapshot: WorkSubagentSnapshot let selected: Bool - let probing: Bool let expanded: Bool let sessionModel: String? var treePrefix: String = "" @@ -3417,9 +3506,6 @@ private struct WorkChatInfoSubagentRow: View { } Spacer(minLength: 0) HStack(spacing: 8) { - if probing { - ProgressView().controlSize(.small) - } WorkSubagentStatusChip(status: snapshot.status) if showsDisclosure { Image(systemName: selected ? "arrow.uturn.left" : "chevron.right") diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index c04c3eeac8..b7bfbbd9d3 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -875,7 +875,8 @@ func makeWorkChatTranscript(from entries: [AgentChatEventEnvelope]) -> [WorkChat apiErrorStatus: entry.apiErrorStatus, isLegacySubagentCompletedFrame: entry.isLegacySubagentCompletedFrame, stopSource: entry.stopSource, - stopReason: entry.stopReason + stopReason: entry.stopReason, + toolResultFullBytes: entry.toolResultFullBytes ) } .sorted(by: workChatEnvelopeOrderedBefore) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index e27d752971..74fb0a7466 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -80,6 +80,14 @@ struct WorkToolCardModel: Identifiable, Equatable { let resultText: String? let webSearchActions: [CodexWebSearchAction]? let webSearchResults: [CodexWebSearchResult]? + /// Size of the stored result when `resultText` is only the head slice the + /// slim mobile wire delivered. nil means the card already has everything, + /// which is the case for every host and every client that does not use that + /// wire. + let remoteResultBytes: Int? + /// Session this card came from. Needed only to fetch a truncated result, so + /// it is optional: a card built without one simply cannot offer the fetch. + let sessionId: String? init( id: String, @@ -90,7 +98,9 @@ struct WorkToolCardModel: Identifiable, Equatable { argsText: String?, resultText: String?, webSearchActions: [CodexWebSearchAction]? = nil, - webSearchResults: [CodexWebSearchResult]? = nil + webSearchResults: [CodexWebSearchResult]? = nil, + remoteResultBytes: Int? = nil, + sessionId: String? = nil ) { self.id = id self.toolName = toolName @@ -101,6 +111,8 @@ struct WorkToolCardModel: Identifiable, Equatable { self.resultText = resultText self.webSearchActions = webSearchActions self.webSearchResults = webSearchResults + self.remoteResultBytes = remoteResultBytes + self.sessionId = sessionId } } @@ -1677,6 +1689,10 @@ struct WorkChatEnvelope: Identifiable, Equatable { /// without changing the broad WorkChatEvent associated-value surface. let stopSource: String? let stopReason: String? + /// Size of the stored tool result when this row carries only the head slice + /// the slim mobile wire sent, nil when the result is complete. Drives the + /// Result block's on-demand fetch. + let toolResultFullBytes: Int? init( sessionId: String, @@ -1692,7 +1708,8 @@ struct WorkChatEnvelope: Identifiable, Equatable { apiErrorStatus: Int? = nil, isLegacySubagentCompletedFrame: Bool = false, stopSource: String? = nil, - stopReason: String? = nil + stopReason: String? = nil, + toolResultFullBytes: Int? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -1708,6 +1725,7 @@ struct WorkChatEnvelope: Identifiable, Equatable { self.isLegacySubagentCompletedFrame = isLegacySubagentCompletedFrame self.stopSource = stopSource self.stopReason = stopReason + self.toolResultFullBytes = toolResultFullBytes } } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 9b157575e1..0ca5840ebd 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -892,7 +892,12 @@ func buildWorkToolCards( startedAt: existing?.startedAt ?? envelope.timestamp, completedAt: envelope.timestamp, argsText: existing?.argsText, - resultText: nonEmpty(resultText) + resultText: nonEmpty(resultText), + // Only set when the host sent a slice. The card keeps it so the Result + // block can offer the fetch instead of pretending the slice is all + // there was. + remoteResultBytes: envelope.toolResultFullBytes, + sessionId: envelope.sessionId ) case .webSearch(let query, let action, let actions, let results, let status, let itemId, _): // Web searches are tool calls — render them as tool cards so they're diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 145e7eb22d..9157d4ec41 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -895,7 +895,6 @@ struct WorkSessionDestinationView: View { @State var parentFallbackEntriesBeforeSubagent: [AgentChatTranscriptEntry] = [] @State var chatInfoPresented = false @State var expandedSubagentDetailIds: Set = [] - @State var probingSubagentTaskId: String? @State var remoteSubagentRefreshInFlight = false /// Central expansion state for every collapsible transcript card. Lives /// here, above the list, so it survives `LazyVStack` recycling and can be @@ -1462,7 +1461,6 @@ struct WorkSessionDestinationView: View { nextWakeAt: composerChatSummary?.nextWakeAt, provider: subagentProvider, selectedTaskId: subagentView?.taskId, - probingTaskId: probingSubagentTaskId, expandedTaskIds: $expandedSubagentDetailIds, sessionModel: composerChatSummary?.model, onSelect: handleSubagentSelection, @@ -1681,9 +1679,6 @@ struct WorkSessionDestinationView: View { .task(id: pollingKey) { await pollIfNeeded() } - .task(id: selectedSubagentPollingKey) { - await pollSelectedSubagentTranscriptIfNeeded() - } .task(id: cursorCloudMirrorWatchKey) { let watchId = sessionId let agentId = composerChatSummary?.cursorCloudAgentId? @@ -2195,13 +2190,6 @@ struct WorkSessionDestinationView: View { return .idle } - var selectedSubagentPollingKey: String { - guard let selectedSubagentSnapshot, - selectedSubagentSnapshot.status == .running - else { return "paused" } - return "\(sessionId)-\(selectedSubagentSnapshot.taskId)-running-\(isLiveAndReachable)" - } - var artifactObservationKey: String { "\(sessionId)-proof:\(syncService.proofArtifactsProjectionRevision)" } @@ -3390,7 +3378,6 @@ struct WorkSessionDestinationView: View { func clearSubagentView() { subagentView = nil setSubagentTranscript([]) - probingSubagentTaskId = nil } @MainActor @@ -3461,57 +3448,23 @@ struct WorkSessionDestinationView: View { } } + /// Tapping an in-thread subagent expands its card, and nothing more. + /// + /// A subagent that runs INSIDE a main thread (a Claude/Codex native Task) is + /// not a chat: it has no composer, no lane and no life of its own, and its + /// transcript is the parent's work seen from one level down. Opening it on a + /// phone replaced the thread the user was reading with a read-only copy they + /// had to back out of, while the card beside it already carried the label, + /// model, status, latest summary and final result — everything a phone can + /// act on. So the card is all there is now, and the drill-in and its + /// per-second transcript polling are gone. + /// + /// This is only about in-thread subagents. A `--type subagent` chat and a + /// child lane are full chats with their own rows, and open exactly as they + /// always have. @MainActor func handleSubagentSelection(_ snapshot: WorkSubagentSnapshot) async { - if let subagentView, - snapshot.taskId == subagentView.taskId - || snapshot.agentId == subagentView.taskId - || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) { - await dismissSubagentView() - chatInfoPresented = false - return - } - - guard subagentCapability.canViewFullTranscript else { - toggleExpandedSubagentDetail(snapshot.taskId) - return - } - - guard snapshot.status == .running else { - toggleExpandedSubagentDetail(snapshot.taskId) - return - } - - rememberParentTranscriptBeforeSubagent() - - probingSubagentTaskId = snapshot.taskId - defer { probingSubagentTaskId = nil } - - do { - let messages = try await syncService.fetchSubagentTranscript( - sessionId: sessionId, - agentId: snapshot.agentId ?? snapshot.taskId, - taskId: snapshot.taskId, - laneId: (session ?? initialSession)?.laneId, - limit: 200 - ) - guard let messages, !messages.isEmpty else { - toggleExpandedSubagentDetail(snapshot.taskId) - return - } - let subagentEnvelopes = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: sessionId) - guard workSubagentTranscriptHasVisibleTimeline(subagentEnvelopes) else { - toggleExpandedSubagentDetail(snapshot.taskId) - return - } - setSubagentTranscript(subagentEnvelopes) - await Task.yield() - subagentView = workSubagentSelection(from: snapshot) - expandedSubagentDetailIds.remove(snapshot.taskId) - chatInfoPresented = false - } catch { - toggleExpandedSubagentDetail(snapshot.taskId) - } + toggleExpandedSubagentDetail(snapshot.taskId) } @MainActor @@ -3523,38 +3476,6 @@ struct WorkSessionDestinationView: View { } } - @MainActor - func refreshSelectedSubagentTranscript() async { - guard let selectedSubagentSnapshot, - subagentCapability.canViewFullTranscript - else { return } - guard let messages = try? await syncService.fetchSubagentTranscript( - sessionId: sessionId, - agentId: selectedSubagentSnapshot.agentId ?? selectedSubagentSnapshot.taskId, - taskId: selectedSubagentSnapshot.taskId, - laneId: (session ?? initialSession)?.laneId, - limit: 200 - ), !messages.isEmpty else { return } - let next = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: sessionId) - guard workSubagentTranscriptHasVisibleTimeline(next) else { return } - if next != subagentTranscript { - setSubagentTranscript(next) - } - } - - @MainActor - func pollSelectedSubagentTranscriptIfNeeded() async { - guard isLiveAndReachable, - selectedSubagentSnapshot?.status == .running - else { return } - while !Task.isCancelled, - isLiveAndReachable, - selectedSubagentSnapshot?.status == .running { - await refreshSelectedSubagentTranscript() - try? await Task.sleep(nanoseconds: 1_500_000_000) - } - } - @MainActor func updateLocalEchoDeliveryState(echoId: String, deliveryState: String?) { guard let index = localEchoMessages.firstIndex(where: { $0.id == echoId }) else { return } @@ -3657,138 +3578,6 @@ extension WorkSessionDestinationView: Equatable { } } -func workSubagentTranscriptToEnvelopes( - messages: [SyncService.AgentChatSubagentTranscriptMessage], - sessionId parentSessionId: String -) -> [WorkChatEnvelope] { - let baseDate = Date(timeIntervalSince1970: 0) - let coalesced = workCoalescedSubagentTranscriptMessages(messages) - return coalesced.enumerated().compactMap { index, message in - let text = workSubagentTranscriptText(message) - let timestamp = workSubagentTranscriptIsoFormatter.string(from: baseDate.addingTimeInterval(Double(index))) - let itemId = workSubagentTranscriptItemId(message) - let event: WorkChatEvent - switch message.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "user": - event = .userMessage( - text: text, - attachments: nil, - turnId: nil, - steerId: nil, - deliveryState: nil, - processed: true - ) - case "assistant": - event = .assistantText( - text: text, - turnId: nil, - itemId: itemId?.isEmpty == false ? itemId : nil - ) - default: - event = .systemNotice( - kind: "subagent", - message: text.isEmpty ? "Subagent event" : text, - detail: nil, - turnId: nil, - steerId: nil - ) - } - return WorkChatEnvelope( - sessionId: "\(parentSessionId):subagent:\(message.sessionId)", - timestamp: timestamp, - sequence: index, - event: event - ) - } -} - -func workSubagentTranscriptHasVisibleTimeline(_ envelopes: [WorkChatEnvelope]) -> Bool { - guard !envelopes.isEmpty else { return false } - return !buildWorkChatTimelineSnapshot( - transcript: envelopes, - fallbackEntries: [], - artifacts: [], - localEchoMessages: [] - ).timeline.isEmpty -} - -private func workCoalescedSubagentTranscriptMessages( - _ messages: [SyncService.AgentChatSubagentTranscriptMessage] -) -> [SyncService.AgentChatSubagentTranscriptMessage] { - var result: [SyncService.AgentChatSubagentTranscriptMessage] = [] - result.reserveCapacity(messages.count) - - for message in messages { - let key = workSubagentTranscriptMergeKey(message) - if key != nil, - let last = result.last, - workSubagentTranscriptMergeKey(last) == key { - var merged = last - merged.text = workSubagentTranscriptRawText(last) + workSubagentTranscriptRawText(message) - result[result.count - 1] = merged - continue - } - result.append(message) - } - - return result -} - -private func workSubagentTranscriptMergeKey(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String? { - let type = message.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard type == "assistant" || type == "user" else { return nil } - guard let itemId = workSubagentTranscriptItemId(message), !itemId.isEmpty else { return nil } - return "\(type)|\(message.sessionId)|\(itemId)" -} - -private func workSubagentTranscriptItemId(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String? { - let candidates = [ - workRemoteJSONString(message.message, key: "messageId"), - workRemoteJSONString(message.message, key: "itemId"), - message.uuid, - ] - return candidates - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } -} - -private func workRemoteJSONString(_ value: RemoteJSONValue?, key: String) -> String? { - guard case .object(let object)? = value, - case .string(let string)? = object[key] - else { - return nil - } - return string -} - -private func workSubagentTranscriptRawText(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String { - if let text = message.text { - return text - } - if let payload = message.message { - return prettyPrintedRemoteJSONValue(payload) - } - return "" -} - -private func workSubagentTranscriptText(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String { - if let text = message.text?.trimmingCharacters(in: .whitespacesAndNewlines), - !text.isEmpty { - return text - } - if let payload = message.message { - let text = prettyPrintedRemoteJSONValue(payload).trimmingCharacters(in: .whitespacesAndNewlines) - if !text.isEmpty { return text } - } - return "" -} - -private let workSubagentTranscriptIsoFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter -}() - private struct WorkSessionNavigationChromeModifier: ViewModifier { @Environment(\.dismiss) private var dismiss @Environment(\.layoutDirection) private var layoutDirection diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 75d9475f7f..f65745c55b 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -165,19 +165,24 @@ struct WorkToolActivityPresentation: Equatable { let detail: String? } +/// What this provider's in-thread subagents support on a phone. +/// +/// There is only one capability left. Opening an in-thread subagent's +/// transcript was removed (see `handleSubagentSelection`): the phone shows the +/// subagent card — label, model, status, latest summary, result — and never +/// replaces the thread with a read-only copy of the agent's own work. Separate +/// subagent chats (`--type subagent`) and child lanes are unaffected; they are +/// full chats with their own rows. struct WorkSubagentCapability: Equatable { let canList: Bool - let canViewFullTranscript: Bool - static let none = WorkSubagentCapability(canList: false, canViewFullTranscript: false) + static let none = WorkSubagentCapability(canList: false) } func workResolveSubagentCapability(provider: String?) -> WorkSubagentCapability { switch providerFamilyKey(provider ?? "") { - case "codex", "claude", "opencode": - return WorkSubagentCapability(canList: true, canViewFullTranscript: true) - case "cursor", "droid", "factory": - return WorkSubagentCapability(canList: true, canViewFullTranscript: false) + case "codex", "claude", "opencode", "cursor", "droid", "factory": + return WorkSubagentCapability(canList: true) default: return .none } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index c12f34db4d..856676c2b4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -17476,48 +17476,14 @@ final class ADETests: XCTestCase { XCTAssertEqual(workSubagentRunningCount(snapshots), 0) } - func testWorkSubagentCapabilityMatchesDesktopTakeoverRules() { - XCTAssertTrue(workResolveSubagentCapability(provider: "codex").canViewFullTranscript) - XCTAssertTrue(workResolveSubagentCapability(provider: "claude").canViewFullTranscript) - XCTAssertTrue(workResolveSubagentCapability(provider: "opencode").canViewFullTranscript) - XCTAssertFalse(workResolveSubagentCapability(provider: "cursor").canViewFullTranscript) - XCTAssertFalse(workResolveSubagentCapability(provider: "droid").canViewFullTranscript) - } - - func testWorkSubagentTranscriptMessagesConvertToChatEnvelopes() { - let messages = [ - SyncService.AgentChatSubagentTranscriptMessage( - type: "user", - uuid: "u-1", - sessionId: "child-1", - parentToolUseId: nil, - message: nil, - text: "Inspect this", - subagentMetadata: nil - ), - SyncService.AgentChatSubagentTranscriptMessage( - type: "assistant", - uuid: "a-1", - sessionId: "child-1", - parentToolUseId: nil, - message: nil, - text: "Done", - subagentMetadata: nil - ), - ] - - let envelopes = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: "parent-1") - - XCTAssertEqual(envelopes.count, 2) - guard case .userMessage(let userText, _, _, _, _, _) = envelopes[0].event else { - return XCTFail("Expected first subagent transcript row to be a user message.") - } - XCTAssertEqual(userText, "Inspect this") - guard case .assistantText(let assistantText, _, let itemId) = envelopes[1].event else { - return XCTFail("Expected second subagent transcript row to be assistant text.") - } - XCTAssertEqual(assistantText, "Done") - XCTAssertEqual(itemId, "a-1") + /// Listing is the whole capability now: the in-thread subagent transcript + /// drill-in was removed, so no provider opens one on a phone. + func testWorkSubagentCapabilityListsForEverySupportedProvider() { + for provider in ["codex", "claude", "opencode", "cursor", "droid", "factory"] { + XCTAssertTrue(workResolveSubagentCapability(provider: provider).canList, provider) + } + XCTAssertFalse(workResolveSubagentCapability(provider: "unknown-provider").canList) + XCTAssertFalse(workResolveSubagentCapability(provider: nil).canList) } func testWorkChatTranscriptUsesMessageIdToSplitAssistantMessages() { diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 1aa27e4493..39e782d35f 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -1714,6 +1714,8 @@ Canonical files (`apps/ade-cli/src/services/sync/`): transcript pump cannot re-send a collapsed delta. The replay-buffer resume path is deliberately not folded: its per-event `seq` monotonicity drives the client's `seq <= lastSeq` drop rule), + the slim mobile chat wire (`mobileChatSlimV1`, see + [iOS Companion → Slim mobile chat wire](ios-companion.md#slim-mobile-chat-wire)), the mobile changeset diet (`MOBILE_CHANGESET_EXCLUDED_TABLES`: tables the phone never reads from a changeset — `attempt_transcripts`, `operations`, @@ -3307,6 +3309,7 @@ Envelopes are JSON with fields: "terminal_snapshot" | "terminal_data" | "terminal_exit" | "terminal_input" | "terminal_resize" | "terminal_history" | "chat_subscribe" | "chat_unsubscribe" | "chat_event" | + "chat_history" | "chat_tool_result" | "roster_subscribe" | "roster_unsubscribe" | "roster_snapshot" | "roster_delta" | "brain_status" | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index a71e1631f1..f467c3e516 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -176,6 +176,82 @@ listings and network failures stay silent. Settings diagnostics shows the current version, the latest available version when known, and a manual check action. +## Slim mobile chat wire + +A phone used to download the same chat bytes a desktop does. On a real 16.1 MB +/ 21,665-event thread from a four-lane program, three families own most of it: +`tool_result` 23.1%, `subagent_progress` 19.8% (7,775 events), and +`subagent.progress` a further 15.2% — a second copy of the same 7,775 facts. + +The phone announces `mobileChatSlimV1` in its hello capabilities. A host that +sees it applies three rules, in `apps/desktop/src/shared/chatMobileSlim.ts`. +Every other client — desktop, hosted web, the TUI, the CLI — receives exactly +the wire it received before, and nothing here changes what is written to the +transcript or what an agent does. + +**The `subagent.progress` mirror is dropped.** The host commits a dot-family +twin beside every underscore subagent lifecycle event +(`buildCanonicalAgentChatRuntimeEvent`), so both cross the wire; in the +measured thread the pair is written 1 ms apart with identical ids, and iOS +decodes both into the same `.subagentProgress` case. The twin is dropped only +where it *is* a twin — the underscore original for that agent was the previous +progress event delivered. A transcript that carries dot-family progress with no +underscore original (an older build) keeps every event, so no subagent loses +its card. + +**Progress is state, not history.** A `subagent_progress` event is a snapshot +of one agent's summary, tokens and last tool, and iOS never makes a timeline row +from one — it "enriches the folded snapshot" and nothing else. So the +`chat_subscribe` snapshot keeps only the latest progress per agent, and the +live stream sends at most one per agent per second (last write wins, dropped +when a result supersedes it). `subagent_started` and `subagent_result` are +never folded: they are what the card's existence and outcome are made of. A +progress event whose window closes after a newer event already went out is sent +without a `seq`, because the client drops `seq <= lastSeq` — the event is card +state, not a new point in the ordered stream, and the resume watermark stays on +the newer event. + +**Tool results are fetched, not pushed.** The shared wire cap is 16 KB; a +capable phone gets 2 KB — about one screen — plus the true size and +`resultTruncatedForMobile`. Expanding the row sends `chat_tool_result` +(`sessionId` + `itemId`, scoped exactly like `chat_history`: the peer must +already be subscribed and the chat scope must match), which reads the stored +result back out of the transcript. The row shows a spinner while it loads and +`SyncService` caches the result per item, so re-expanding it — or opening it +again from the turn-activity sheet — costs nothing. "Could not read it" and +"it is no longer in the transcript" are different answers and the row says +which. + +### Before and after, on the measured thread + +| | legacy phone | `mobileChatSlimV1` | +|---|---|---| +| `chat_subscribe` snapshot, events | 498 | 91 (81.7% fewer) | +| `chat_subscribe` snapshot, bytes | 237.4 KiB | 86.2 KiB (63.7% smaller) | +| whole live stream, events | 21,937 | 14,046 (36.0% fewer) | +| whole live stream, bytes | 14,367.5 KiB | 10,859.0 KiB (24.4% smaller) | + +The snapshot is what the phone pays every time it opens a thread, which is why +it is the number that matters most. In the live stream the mirror is the +biggest single saving (2,489.8 KiB → 0) and tool results the next +(2,036.7 → 1,070.8 KiB); the one-per-second coalescing removes only 116 events +on this thread, because a single subagent already reports more slowly than once +a second — it bounds a burst rather than thinning a steady stream. + +## In-thread subagents have a card, not a transcript + +Tapping a subagent that runs INSIDE a main thread — a Claude or Codex native +Task — expands its card and nothing more. The card carries the label, model, +status, latest summary and final result, which is everything a phone can act +on. Opening the transcript replaced the thread the user was reading with a +read-only copy they had to back out of, and kept a one-and-a-half-second +transcript poll running for as long as it was open, for content that is the +parent's own work seen one level down. + +This is only about in-thread subagents. A `--type subagent` chat and a child +lane are full chats with their own rows, their own composer and their own lane, +and they open exactly as they always have. + ## Project layout > The same Xcode project also ships `apps/ios/ADE/Debug/ADEInspectorKit/`, From 88c87089d2a222b9029d5c8b5fd82006b9cac8f6 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:26:52 -0400 Subject: [PATCH 07/26] test(ios): scroll-bench harness and DEBUG scroll tracing for the Work chat Problem: "mobile chats snap around and jump" had no recording and no measurement, so every candidate cause was a guess. Change and boundary: adds a DEBUG-only fixture screen (`-adePreviewScreen chat-scroll`) that renders the real `WorkChatSessionView` over a transcript JSONL file, with an optional `--stream` mode that appends the newest N events on a timer to imitate a live turn; a `WorkChatScrollTrace` logger (subsystem `com.ade.ios.scrollbench`) that records every programmatic scroll write, every suppressed write, every scroll frame, every content-size change and every follow-latch transition, all with `#if DEBUG` bodies; and an `ADEUITests` target whose only job is to drive the eight recorded cases with real touches (idb's HID path needs a SimulatorKit the installed Xcode does not ship). No product behavior changes: the trace calls are no-ops in release and nothing in the scroll machinery was altered. Verification: built for the simulator and ran all eight cases (`xcodebuild build-for-testing` + `test-without-building`, iPhone 17 Pro, iOS 26); harness commands are documented at the top of WorkChatScrollBench.swift. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 102 ++++ .../ADE/Views/Work/WorkChatScrollBench.swift | 508 ++++++++++++++++++ .../Work/WorkChatSessionView+Actions.swift | 57 +- .../Work/WorkChatSessionView+Timeline.swift | 11 + .../ADE/Views/Work/WorkChatSessionView.swift | 65 ++- apps/ios/ADE/Views/Work/WorkPreviews.swift | 4 + .../WorkChatScrollBenchUITests.swift | 168 ++++++ 7 files changed, 905 insertions(+), 10 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkChatScrollBench.swift create mode 100644 apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 2457e85fee..b27fb4efe8 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + EB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */; }; E20000000000000000000091 /* LinearBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000091 /* LinearBrand.swift */; }; E20000000000000000000092 /* LinearSVGPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000092 /* LinearSVGPath.swift */; }; E20000000000000000000093 /* LinearLaunchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000093 /* LinearLaunchModel.swift */; }; @@ -299,6 +300,7 @@ E10000000000000000000057 /* WorkExternalSessionAffordances.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000057 /* WorkExternalSessionAffordances.swift */; }; E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000051 /* WorkLanePickerDropdown.swift */; }; E10000000000000000000042 /* WorkPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000042 /* WorkPreviews.swift */; }; + E1000000000000000000BB01 /* WorkChatScrollBench.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000BB01 /* WorkChatScrollBench.swift */; }; E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000090 /* WorkComposerTypedTriggers.swift */; }; E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000045 /* WorkSelectionActionBar.swift */; }; E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000046 /* WorkRootScreen+Selection.swift */; }; @@ -334,6 +336,13 @@ /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + EB000000000000000000UI21 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 9CBC925352322D208431EFAA /* Project object */; + proxyType = 1; + remoteGlobalIDString = 928432ECB10B6E8870725B02; + remoteInfo = ADE; + }; A17C7B6209E54B4FAE0D11A1 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 9CBC925352322D208431EFAA /* Project object */; @@ -482,6 +491,7 @@ D10000000000000000000057 /* WorkExternalSessionAffordances.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkExternalSessionAffordances.swift; path = ADE/Views/Work/WorkExternalSessionAffordances.swift; sourceTree = ""; }; D10000000000000000000051 /* WorkLanePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLanePickerDropdown.swift; path = ADE/Views/Work/WorkLanePickerDropdown.swift; sourceTree = ""; }; D10000000000000000000042 /* WorkPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPreviews.swift; path = ADE/Views/Work/WorkPreviews.swift; sourceTree = ""; }; + D1000000000000000000BB01 /* WorkChatScrollBench.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatScrollBench.swift; path = ADE/Views/Work/WorkChatScrollBench.swift; sourceTree = ""; }; D20000000000000000000090 /* WorkComposerTypedTriggers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTypedTriggers.swift; path = ADE/Views/Work/WorkComposerTypedTriggers.swift; sourceTree = ""; }; D10000000000000000000045 /* WorkSelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSelectionActionBar.swift; path = ADE/Views/Work/WorkSelectionActionBar.swift; sourceTree = ""; }; D10000000000000000000046 /* WorkRootScreen+Selection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkRootScreen+Selection.swift"; path = "ADE/Views/Work/WorkRootScreen+Selection.swift"; sourceTree = ""; }; @@ -658,6 +668,8 @@ CCAB2414C359E971B780BF99 /* PreviewHost.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PreviewHost.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6F9B21C0E4A6D8F1B3C5A77 /* FilesCodeSupport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesCodeSupport.swift; path = ADE/Views/Components/FilesCodeSupport.swift; sourceTree = ""; }; E3A5721EB84321D201716BC3 /* ADETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ADETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DB000000000000000000UI02 /* ADEUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ADEUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatScrollBenchUITests.swift; path = ADEUITests/WorkChatScrollBenchUITests.swift; sourceTree = ""; }; C5A000000000000000000001 /* MobileTrustResetPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileTrustResetPolicy.swift; path = ADE/Services/MobileTrustResetPolicy.swift; sourceTree = ""; }; C5A00000000000000000001F /* PairedHostCredentialState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairedHostCredentialState.swift; path = ADE/Services/PairedHostCredentialState.swift; sourceTree = ""; }; C5A000000000000000000002 /* SSHBootstrapModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SSHBootstrapModels.swift; path = ADE/Services/SSHBootstrapModels.swift; sourceTree = ""; }; @@ -680,6 +692,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + EB000000000000000000UI11 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DE00988229EE6003DBF8D8FA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -961,6 +980,7 @@ D10000000000000000000057 /* WorkExternalSessionAffordances.swift */, D10000000000000000000051 /* WorkLanePickerDropdown.swift */, D10000000000000000000042 /* WorkPreviews.swift */, + D1000000000000000000BB01 /* WorkChatScrollBench.swift */, D20000000000000000000090 /* WorkComposerTypedTriggers.swift */, D10000000000000000000045 /* WorkSelectionActionBar.swift */, D10000000000000000000046 /* WorkRootScreen+Selection.swift */, @@ -1187,6 +1207,7 @@ children = ( 27A125DE2C17BA32F9291513 /* ADE.app */, E3A5721EB84321D201716BC3 /* ADETests.xctest */, + DB000000000000000000UI02 /* ADEUITests.xctest */, CCAB2414C359E971B780BF99 /* PreviewHost.app */, AA0000000000000000000002 /* ADEWidgets.appex */, AC0000000000000000000002 /* ADEClip.app */, @@ -1217,6 +1238,7 @@ isa = PBXGroup; children = ( 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, + DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */, AC8000000000000000000002 /* ProjectHostRecoveryTests.swift */, AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */, B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */, @@ -1266,6 +1288,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + EB000000000000000000UI40 /* ADEUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = EB000000000000000000UI32 /* Build configuration list for PBXNativeTarget "ADEUITests" */; + buildPhases = ( + EB000000000000000000UI10 /* Sources */, + EB000000000000000000UI11 /* Frameworks */, + EB000000000000000000UI12 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + EB000000000000000000UI20 /* PBXTargetDependency */, + ); + name = ADEUITests; + productName = ADEUITests; + productReference = DB000000000000000000UI02 /* ADEUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; 62C217CE2C1C31B3127D1ACF /* ADETests */ = { isa = PBXNativeTarget; buildConfigurationList = 7F1C55DA37975982AB8F1BB9 /* Build configuration list for PBXNativeTarget "ADETests" */; @@ -1447,11 +1487,19 @@ AA0000000000000000000001 /* ADEWidgets */, AC0000000000000000000001 /* ADEClip */, 62C217CE2C1C31B3127D1ACF /* ADETests */, + EB000000000000000000UI40 /* ADEUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + EB000000000000000000UI12 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 0C78157439BD019931D9CBAD /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -1515,6 +1563,14 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + EB000000000000000000UI10 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ED0BCB9618AA29B41D84DA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1715,6 +1771,7 @@ E10000000000000000000057 /* WorkExternalSessionAffordances.swift in Sources */, E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */, E10000000000000000000042 /* WorkPreviews.swift in Sources */, + E1000000000000000000BB01 /* WorkChatScrollBench.swift in Sources */, E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */, E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, @@ -1855,6 +1912,12 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + EB000000000000000000UI20 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + name = ADE; + target = 928432ECB10B6E8870725B02 /* ADE */; + targetProxy = EB000000000000000000UI21 /* PBXContainerItemProxy */; + }; A17C7B6209E54B4FAE0D11A2 /* PBXTargetDependency */ = { isa = PBXTargetDependency; name = ADE; @@ -1876,6 +1939,36 @@ /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ + EB000000000000000000UI30 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + DEVELOPMENT_TEAM = VQ372F39G6; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.uitests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ADE; + }; + name = Debug; + }; + EB000000000000000000UI31 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGNING_ALLOWED = NO; + DEVELOPMENT_TEAM = VQ372F39G6; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ade.ios.uitests; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = ADE; + }; + name = Release; + }; 46C95E3D8691C9E33C8FBF15 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -2213,6 +2306,15 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + EB000000000000000000UI32 /* Build configuration list for PBXNativeTarget "ADEUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EB000000000000000000UI31 /* Release */, + EB000000000000000000UI30 /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 7F1C55DA37975982AB8F1BB9 /* Build configuration list for PBXNativeTarget "ADETests" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift new file mode 100644 index 0000000000..056d602e96 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift @@ -0,0 +1,508 @@ +import Foundation +import OSLog +import SwiftUI + +// MARK: - Harness +// +// Scroll benchmark harness for the Work chat transcript. DEBUG-only on both +// sides: the fixture screen is compiled out of release, and every trace call +// below has an empty release body. +// +// Build (one xcodebuild at a time — take the shared lock in the same command): +// +// until mkdir "$TMPDIR/ade-xcodebuild.lock" 2>/dev/null; do sleep 15; done; \ +// xcodebuild -project apps/ios/ADE.xcodeproj -scheme ADE \ +// -destination 'platform=iOS Simulator,id=' \ +// -derivedDataPath "$TMPDIR/dd-scroll-bench" build; \ +// rc=$?; rmdir "$TMPDIR/ade-xcodebuild.lock"; exit $rc +// +// Install + seed the transcript into the app container: +// +// xcrun simctl install "$TMPDIR/dd-scroll-bench/Build/Products/Debug-iphonesimulator/ADE.app" +// DATA=$(xcrun simctl get_app_container com.ade.ios data) +// cp thread.jsonl "$DATA/Documents/bench.jsonl" +// +// Run (static tail, 3000 newest events, tracing on): +// +// xcrun simctl launch com.ade.ios \ +// -adePreviewScreen chat-scroll \ +// -adeBenchTranscript "$DATA/Documents/bench.jsonl" \ +// -adeBenchLimit 3000 -adeScrollTrace 1 +// +// Run (live turn: hold back the newest 120 events and append one every 120ms): +// +// ... -adeBenchStream 120 -adeBenchStreamIntervalMs 120 +// +// Logs (subsystem is `com.ade.ios.scrollbench`): +// +// xcrun simctl spawn log stream --style compact \ +// --predicate 'subsystem == "com.ade.ios.scrollbench"' > "$TMPDIR/scroll-.log" +// +// Video: +// +// xcrun simctl io recordVideo --codec h264 "$TMPDIR/scroll-.mp4" + +/// Records every programmatic scroll write and every scroll/content-size frame +/// so a recorded case can be reduced to "who moved the reader, how far". +/// +/// All bodies are `#if DEBUG`; in release each entry point is a no-op that the +/// optimizer removes, so the product call sites stay unconditional. +enum WorkChatScrollTrace { + #if DEBUG + static let logger = Logger(subsystem: "com.ade.ios.scrollbench", category: "scroll") + static let signposter = OSSignposter( + subsystem: "com.ade.ios.scrollbench", + category: "scroll" + ) + + /// Opt-in so a normal DEBUG build of the app does not log per scroll frame. + nonisolated(unsafe) static let enabled = ProcessInfo.processInfo.arguments + .contains("-adeScrollTrace") + + @MainActor private static var lastSampleOffset: CGFloat = .nan + @MainActor private static var writeSerial: Int = 0 + #endif + + /// A programmatic scroll write is about to be issued. + /// + /// `target` is the write's own description (`y=1234`, `edge=bottom`, + /// `id=chat-end`), `offsetBefore` the live `contentOffset.y` at the moment of + /// the call. The settled offset is recovered from the `sample` lines that + /// follow, because SwiftUI applies the write on a later layout pass. + @MainActor + static func write( + reason: String, + target: String, + site: String, + offsetBefore: CGFloat, + contentHeight: CGFloat, + containerHeight: CGFloat, + scrollableHeight: CGFloat, + following: Bool, + userDrivenPhase: Bool + ) { + #if DEBUG + guard enabled else { return } + writeSerial += 1 + let serial = writeSerial + signposter.emitEvent("scrollWrite", "\(reason, privacy: .public)") + logger.log( + """ + write#\(serial, privacy: .public) reason=\(reason, privacy: .public) \ + target=\(target, privacy: .public) site=\(site, privacy: .public) \ + offsetBefore=\(offsetBefore, privacy: .public) \ + content=\(contentHeight, privacy: .public) \ + container=\(containerHeight, privacy: .public) \ + scrollable=\(scrollableHeight, privacy: .public) \ + following=\(following, privacy: .public) \ + userPhase=\(userDrivenPhase, privacy: .public) + """ + ) + #endif + } + + /// A write site that decided not to write. Counting the refusals is how a + /// "nothing happened when I asked" complaint gets separated from a jump. + @MainActor + static func writeSuppressed(reason: String, site: String, cause: String) { + #if DEBUG + guard enabled else { return } + logger.log( + "suppressed reason=\(reason, privacy: .public) site=\(site, privacy: .public) cause=\(cause, privacy: .public)" + ) + #endif + } + + /// One per scroll geometry frame. The offset delta between consecutive + /// samples is the ground truth for "the reader moved"; a large delta with no + /// `write` line in front of it is SwiftUI's own re-measurement. + @MainActor + static func sample( + offsetY: CGFloat, + contentHeight: CGFloat, + containerHeight: CGFloat, + scrollableHeight: CGFloat, + distanceFromBottom: CGFloat, + userDrivenPhase: Bool, + following: Bool + ) { + #if DEBUG + guard enabled else { return } + let delta = lastSampleOffset.isNaN ? 0 : offsetY - lastSampleOffset + lastSampleOffset = offsetY + logger.log( + """ + sample off=\(offsetY, privacy: .public) d=\(delta, privacy: .public) \ + content=\(contentHeight, privacy: .public) \ + container=\(containerHeight, privacy: .public) \ + scrollable=\(scrollableHeight, privacy: .public) \ + fromBottom=\(distanceFromBottom, privacy: .public) \ + userPhase=\(userDrivenPhase, privacy: .public) \ + following=\(following, privacy: .public) + """ + ) + #endif + } + + /// Content size / window change, which is the other thing that can move what + /// the reader sees without anybody issuing a scroll write. + @MainActor + static func contentSize( + previousContent: CGFloat, + nextContent: CGFloat, + previousContainer: CGFloat, + nextContainer: CGFloat, + offsetY: CGFloat, + following: Bool + ) { + #if DEBUG + guard enabled else { return } + logger.log( + """ + content prevContent=\(previousContent, privacy: .public) \ + nextContent=\(nextContent, privacy: .public) \ + dContent=\(nextContent - previousContent, privacy: .public) \ + prevContainer=\(previousContainer, privacy: .public) \ + nextContainer=\(nextContainer, privacy: .public) \ + dContainer=\(nextContainer - previousContainer, privacy: .public) \ + off=\(offsetY, privacy: .public) following=\(following, privacy: .public) + """ + ) + #endif + } + + /// Follow-latch transitions, so a jump can be attributed to the state machine + /// flipping rather than to the write that the flip authorized. + @MainActor + static func follow(_ following: Bool, reason: String) { + #if DEBUG + guard enabled else { return } + logger.log("follow=\(following, privacy: .public) reason=\(reason, privacy: .public)") + #endif + } + + @MainActor + static func phase(userDriven: Bool, raw: String) { + #if DEBUG + guard enabled else { return } + logger.log("phase user=\(userDriven, privacy: .public) raw=\(raw, privacy: .public)") + #endif + } + + @MainActor + static func note(_ message: String) { + #if DEBUG + guard enabled else { return } + logger.log("note \(message, privacy: .public)") + #endif + } +} + +#if DEBUG + +/// Launch-argument configuration for the scroll bench. +struct WorkChatScrollBenchOptions { + var transcriptPath: String? + /// Keep only the newest N envelopes. 0 = the whole file. + var limit: Int = 0 + /// Hold back the newest N envelopes and append them on a timer, imitating a + /// live turn arriving while the reader is in the transcript. + var streamCount: Int = 0 + var streamIntervalMs: Int = 120 + + static func fromLaunchArguments(_ arguments: [String] = ProcessInfo.processInfo.arguments) + -> WorkChatScrollBenchOptions + { + func value(_ flag: String) -> String? { + guard let index = arguments.firstIndex(of: flag), + arguments.index(after: index) < arguments.endIndex else { return nil } + return arguments[arguments.index(after: index)] + } + var options = WorkChatScrollBenchOptions() + options.transcriptPath = value("-adeBenchTranscript") + options.limit = value("-adeBenchLimit").flatMap(Int.init) ?? 0 + options.streamCount = value("-adeBenchStream").flatMap(Int.init) ?? 0 + options.streamIntervalMs = value("-adeBenchStreamIntervalMs").flatMap(Int.init) ?? 120 + return options + } +} + +/// Reads an ADE chat transcript JSONL off disk into the same envelope type the +/// sync path produces, so the bench renders the real view over real data with +/// no brain, no pairing, and no network. +enum WorkChatScrollBenchLoader { + static func load(path: String, limit: Int) -> [AgentChatEventEnvelope] { + guard let data = FileManager.default.contents(atPath: path), + let text = String(data: data, encoding: .utf8) else { + return [] + } + let decoder = JSONDecoder() + var envelopes: [AgentChatEventEnvelope] = [] + envelopes.reserveCapacity(4096) + var sequence = 0 + text.enumerateLines { line, _ in + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, let lineData = trimmed.data(using: .utf8) else { return } + // The first line is a `session_init` header with no `event` object; every + // other line is an envelope. A row this decoder cannot read is skipped + // rather than failing the load — a bench over 20k rows should not die on + // one legacy frame. + guard var envelope = try? decoder.decode(AgentChatEventEnvelope.self, from: lineData) else { + return + } + sequence += 1 + if envelope.sequence == nil { + envelope.sequence = sequence + } + envelopes.append(envelope) + } + guard limit > 0, envelopes.count > limit else { return envelopes } + return Array(envelopes.suffix(limit)) + } +} + +/// Renders the real `WorkChatSessionView` over a transcript file. +struct WorkChatScrollBenchScreen: View { + let options: WorkChatScrollBenchOptions + + @State private var loaded: [AgentChatEventEnvelope] = [] + @State private var held: [AgentChatEventEnvelope] = [] + @State private var transcript: [WorkChatEnvelope] = [] + @State private var incrementalDelta: [WorkChatEnvelope] = [] + @State private var cardExpansion = WorkCardExpansionState(expandedIds: []) + @State private var artifactContent: [String: WorkLoadedArtifactContent] = [:] + @State private var fullscreenImage: WorkFullscreenImage? + @State private var artifactDrawerPresented = false + @State private var sending = false + @State private var errorMessage: String? + @State private var didLoad = false + + private var sessionId: String { + loaded.first?.sessionId ?? "scroll-bench-session" + } + + var body: some View { + NavigationStack { + content + } + .task { + guard !didLoad else { return } + didLoad = true + loadTranscript() + } + } + + @ViewBuilder + private var content: some View { + if transcript.isEmpty { + VStack(spacing: 12) { + Text("Scroll bench") + .font(.headline) + Text(options.transcriptPath ?? "no -adeBenchTranscript argument") + .font(.footnote) + .multilineTextAlignment(.center) + Text("Loaded 0 envelopes") + .font(.footnote) + } + .padding() + } else { + chatView + } + } + + private var chatView: some View { + WorkChatSessionView( + session: WorkChatSessionRenderContext(benchTerminalSession(sessionId: sessionId)), + chatSummaryContext: WorkChatSummaryRenderContext(benchChatSummary(sessionId: sessionId)), + transcript: transcript, + transcriptRenderSignature: workChatEnvelopeListRenderSignature(transcript), + allowsIncrementalTranscriptUpdate: false, + transcriptIncrementalDelta: $incrementalDelta, + fallbackEntries: [], + fallbackEntriesRenderSignature: workFallbackEntriesRenderSignature([]), + artifacts: [], + artifactsRenderSignature: workArtifactSummariesRenderSignature([]), + optimisticPendingSteers: [], + optimisticPendingSteersRenderSignature: workPendingSteersRenderSignature([]), + localEchoMessages: [], + localEchoMessagesRenderSignature: workLocalEchoMessagesRenderSignature([]), + cardExpansionSnapshot: cardExpansion, + cardExpansionRenderSignature: workCardExpansionRenderSignature(cardExpansion), + artifactContentRenderSignature: workLoadedArtifactContentRenderSignature([:]), + artifactDrawerPresentedSnapshot: artifactDrawerPresented, + sendingSnapshot: sending, + errorMessageSnapshot: errorMessage, + cardExpansion: $cardExpansion, + artifactContent: $artifactContent, + fullscreenImage: $fullscreenImage, + artifactDrawerPresented: $artifactDrawerPresented, + artifactRefreshInFlight: false, + artifactRefreshError: nil, + sending: $sending, + errorMessage: $errorMessage, + isLive: true, + hostUnreachable: false, + canComposeMessages: true, + canSendMessages: true, + sendWillQueue: false, + sendWillQueueIsReconnect: false, + activeSendModesAvailable: true, + queueAwareStopAvailable: true, + transportHealth: .connected, + composerDraftRestore: nil, + transitionNamespace: nil, + onOpenLane: {}, + onSend: { _, _, _ in true }, + onInterrupt: { _ in }, + onRestoreCancelledQueue: nil, + onApproveRequest: { _, _, _ in }, + onRespondToQuestion: { _, _, _, _ in }, + onSubmitQuestionAnswers: { _, _, _ in }, + onDeclineQuestion: { _ in }, + onRespondToPermission: { _, _ in }, + onRetryLoad: {}, + onOpenFile: { _ in }, + onOpenPr: { _ in }, + onLoadArtifact: { _ in }, + onRefreshArtifacts: {}, + onCancelSteer: { _ in }, + onEditSteer: { _, _ in }, + onDispatchSteerInline: nil, + onDispatchSteerInterrupt: nil, + onSelectModel: { _ in }, + onSelectRuntimeMode: { _ in true }, + onSelectEffort: { _ in }, + onSelectCodexFastMode: { _ in true }, + lanesRenderSignature: workLaneListRenderSignature([]), + subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature([]), + scheduledWorkSnapshotsRenderSignature: workScheduledWorkSnapshotsRenderSignature([]) + ) + } + + @MainActor + private func loadTranscript() { + guard let path = options.transcriptPath else { + WorkChatScrollTrace.note("bench no transcript path") + return + } + let started = ProcessInfo.processInfo.systemUptime + var envelopes = WorkChatScrollBenchLoader.load(path: path, limit: options.limit) + guard !envelopes.isEmpty else { + WorkChatScrollTrace.note("bench loaded 0 envelopes from \(path)") + return + } + if options.streamCount > 0, envelopes.count > options.streamCount { + held = Array(envelopes.suffix(options.streamCount)) + envelopes = Array(envelopes.dropLast(options.streamCount)) + } + loaded = envelopes + transcript = makeWorkChatTranscript(from: envelopes) + let elapsed = ProcessInfo.processInfo.systemUptime - started + WorkChatScrollTrace.note( + "bench loaded envelopes=\(envelopes.count) rows=\(transcript.count) held=\(held.count) loadMs=\(Int(elapsed * 1000))" + ) + guard !held.isEmpty else { return } + startStreaming() + } + + @MainActor + private func startStreaming() { + let interval = max(16, options.streamIntervalMs) + Task { @MainActor in + // A short head start so the opening pin and hydration have settled before + // the first appended event lands. + try? await Task.sleep(for: .milliseconds(1500)) + WorkChatScrollTrace.note("bench stream start pending=\(held.count) intervalMs=\(interval)") + while !held.isEmpty { + let next = held.removeFirst() + loaded.append(next) + transcript = makeWorkChatTranscript(from: loaded) + WorkChatScrollTrace.note("bench stream append rows=\(transcript.count)") + try? await Task.sleep(for: .milliseconds(interval)) + } + WorkChatScrollTrace.note("bench stream done") + } + } +} + +/// Session-row fixture. Duplicated rather than shared with `WorkPreviewData` +/// (which is `private`) so the bench owns its own file and does not collide +/// with edits to the preview fixtures. +@MainActor +func benchChatSummary(sessionId: String) -> AgentChatSessionSummary { + let now = ISO8601DateFormatter().string(from: Date()) + return AgentChatSessionSummary( + sessionId: sessionId, + laneId: "scroll-bench-lane", + provider: "claude", + model: "claude-opus-5", + modelId: "anthropic/claude-opus-5", + sessionProfile: nil, + title: "Scroll bench", + goal: "Reproduce transcript scroll jumps", + reasoningEffort: nil, + codexFastMode: nil, + fastMode: nil, + executionMode: nil, + permissionMode: "edit", + interactionMode: "default", + claudePermissionMode: "default", + codexApprovalPolicy: nil, + codexSandbox: nil, + codexConfigSource: nil, + opencodePermissionMode: nil, + droidPermissionMode: nil, + cursorModeSnapshot: nil, + cursorModeId: nil, + cursorConfigValues: nil, + identityKey: nil, + surface: "mobile", + automationId: nil, + automationRunId: nil, + capabilityMode: nil, + computerUse: nil, + completion: nil, + status: "active", + idleSinceAt: nil, + startedAt: now, + endedAt: nil, + lastActivityAt: now, + lastOutputPreview: nil, + summary: "Scroll bench transcript", + awaitingInput: false, + threadId: nil, + requestedCwd: "/tmp" + ) +} + +@MainActor +func benchTerminalSession(sessionId: String) -> TerminalSessionSummary { + let now = ISO8601DateFormatter().string(from: Date()) + return TerminalSessionSummary( + id: sessionId, + laneId: "scroll-bench-lane", + laneName: "scroll-bench", + ptyId: nil, + tracked: true, + pinned: false, + manuallyNamed: true, + goal: "Reproduce transcript scroll jumps", + toolType: "claude-chat", + title: "Scroll bench", + status: "running", + startedAt: now, + endedAt: nil, + exitCode: nil, + transcriptPath: ".ade/transcripts/chat/\(sessionId).jsonl", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: nil, + summary: "Scroll bench transcript", + runtimeState: "active", + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil + ) +} + +#endif diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index b5b18c482e..f54b43e7e1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -1515,6 +1515,7 @@ extension WorkChatSessionView { offsetRetreat: scrollMetrics.stableOffsetY - scrollMetrics.offsetY ) { cancelPendingInitialBottomPinForUserScroll() + WorkChatScrollTrace.follow(false, reason: "user-scroll release") releaseBottomStickinessForUserScroll(reason: "user-scroll") return } @@ -1532,6 +1533,7 @@ extension WorkChatSessionView { bottomStickinessReleasedByUser = false if !isNearBottom { isNearBottom = true + WorkChatScrollTrace.follow(true, reason: "resume-threshold") } if unreadBelowCount > 0 { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { @@ -1549,6 +1551,7 @@ extension WorkChatSessionView { if nextIsNearBottom != isNearBottom { isNearBottom = nextIsNearBottom + WorkChatScrollTrace.follow(nextIsNearBottom, reason: "stickiness-threshold") } guard nextIsNearBottom else { return } @@ -1635,6 +1638,7 @@ extension WorkChatSessionView { ) { bottomStickinessReleasedByUser = false isNearBottom = true + WorkChatScrollTrace.follow(true, reason: "reclaim-after-window-change") } } @@ -1654,6 +1658,17 @@ extension WorkChatSessionView { pinToLatestAfterLayout(proxy, reason: "layout-geometry") case .restoreOffset(let y): guard abs(y - scrollMetrics.offsetY) > workChatLayoutGeometrySlop else { return } + WorkChatScrollTrace.write( + reason: "layout-restore-offset", + target: "y=\(y)", + site: "WorkChatSessionView+Actions.swift:applyLayoutGeometryScrollAdjustment", + offsetBefore: scrollMetrics.offsetY, + contentHeight: next.contentHeight, + containerHeight: next.containerHeight, + scrollableHeight: next.scrollableHeight, + following: isNearBottom, + userDrivenPhase: timelineScrollPhaseUserDriven + ) var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { @@ -1663,8 +1678,19 @@ extension WorkChatSessionView { } @MainActor - func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool) { + func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool, traceReason: String = "scroll-to-latest") { bottomStickinessReleasedByUser = false + WorkChatScrollTrace.write( + reason: traceReason, + target: animated ? "edge=bottom(animated)" : "edge=bottom", + site: "WorkChatSessionView+Actions.swift:scrollToLatest", + offsetBefore: scrollMetrics.offsetY, + contentHeight: scrollMetrics.contentHeight, + containerHeight: scrollMetrics.containerHeight, + scrollableHeight: scrollMetrics.scrollableHeight, + following: isNearBottom, + userDrivenPhase: timelineScrollPhaseUserDriven + ) if animated { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { // Keep the ScrollPosition channel and the id-based reader in agreement. @@ -1699,19 +1725,26 @@ extension WorkChatSessionView { @MainActor func pinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard isNearBottom, canWriteAutomaticScrollOffset else { return } + guard isNearBottom, canWriteAutomaticScrollOffset else { + WorkChatScrollTrace.writeSuppressed( + reason: "pin:\(reason)", + site: "WorkChatSessionView+Actions.swift:pinToLatestAfterLayout", + cause: isNearBottom ? "reader-owns-scroll" : "not-following" + ) + return + } latestPinGeneration &+= 1 let generation = latestPinGeneration latestPinTask?.cancel() latestPinTask = Task { @MainActor in guard generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) + scrollToLatest(proxy, animated: false, traceReason: "pin:\(reason)") try? await Task.sleep(for: .milliseconds(16)) guard !Task.isCancelled, generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) + scrollToLatest(proxy, animated: false, traceReason: "pin-retry:\(reason)") if generation == latestPinGeneration { latestPinTask = nil } @@ -1720,7 +1753,17 @@ extension WorkChatSessionView { @MainActor func forcePinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard canWriteAutomaticScrollOffset else { return } + guard canWriteAutomaticScrollOffset else { + WorkChatScrollTrace.writeSuppressed( + reason: "force-pin:\(reason)", + site: "WorkChatSessionView+Actions.swift:forcePinToLatestAfterLayout", + cause: "reader-owns-scroll" + ) + return + } + if !isNearBottom { + WorkChatScrollTrace.follow(true, reason: "force-pin:\(reason)") + } isNearBottom = true if unreadBelowCount > 0 { unreadBelowCount = 0 @@ -1730,14 +1773,14 @@ extension WorkChatSessionView { latestPinTask?.cancel() latestPinTask = Task { @MainActor in guard generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) + scrollToLatest(proxy, animated: false, traceReason: "force-pin:\(reason)") for delay in [16, 80, 180, 320] { try? await Task.sleep(for: .milliseconds(delay)) guard !Task.isCancelled, generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) + scrollToLatest(proxy, animated: false, traceReason: "force-pin-retry\(delay):\(reason)") } if generation == latestPinGeneration { latestPinTask = nil diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 56d9dbc158..66831462fa 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -244,6 +244,17 @@ extension WorkChatSessionView { // card above the keyboard. Task { @MainActor in try? await Task.sleep(nanoseconds: 300_000_000) + WorkChatScrollTrace.write( + reason: "question-freeform-focus", + target: "id=pending-question-\(question.id)", + site: "WorkChatSessionView+Timeline.swift:onFreeformFocusChange", + offsetBefore: scrollMetrics.offsetY, + contentHeight: scrollMetrics.contentHeight, + containerHeight: scrollMetrics.containerHeight, + scrollableHeight: scrollMetrics.scrollableHeight, + following: isNearBottom, + userDrivenPhase: timelineScrollPhaseUserDriven + ) withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo("pending-question-\(question.id)", anchor: .bottom) } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 9475ce0315..08cab259fa 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1332,12 +1332,24 @@ struct WorkChatSessionView: View { probeRowY: scrollMetrics.probeRowY ) { case .ignore: + WorkChatScrollTrace.note( + "prepend-arm IGNORE prevFirst=\(previousFirstId ?? "nil") " + + "nextFirst=\(nextPresentation.visibleEntries.first?.id ?? "nil") " + + "probeRow=\(scrollMetrics.probeRowId ?? "nil") " + + "probeY=\(scrollMetrics.probeRowY.map { "\($0)" } ?? "nil") " + + "prevCount=\(timelinePresentation.visibleEntries.count) " + + "nextCount=\(nextPresentation.visibleEntries.count) " + + "distanceFromTop=\(scrollMetrics.distanceFromTop)" + ) return case .extendExistingAnchorWindow: + WorkChatScrollTrace.note("prepend-arm EXTEND") scrollMetrics.prependAnchor?.remainingAttempts = workChatPrependAnchorAttempts case .retireAnchor: + WorkChatScrollTrace.note("prepend-arm RETIRE") scrollMetrics.prependAnchor = nil case .arm(let rowId, let rowY): + WorkChatScrollTrace.note("prepend-arm ARM row=\(rowId) y=\(rowY)") scrollMetrics.prependAnchor = WorkChatPrependAnchor( rowId: rowId, rowY: rowY, @@ -1367,10 +1379,20 @@ struct WorkChatSessionView: View { ) ) { case .wait: + WorkChatScrollTrace.writeSuppressed( + reason: "prepend-restore", + site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", + cause: "wait-for-probe" + ) return case .retry: anchor.remainingAttempts -= 1 scrollMetrics.prependAnchor = anchor.remainingAttempts > 0 ? anchor : nil + WorkChatScrollTrace.writeSuppressed( + reason: "prepend-restore", + site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", + cause: "retry attempts=\(anchor.remainingAttempts)" + ) return case .apply(let height): insertedHeight = height @@ -1379,8 +1401,26 @@ struct WorkChatSessionView: View { scrollMetrics.prependAnchor = nil // Bottom-follow owns the scroll position when the reader is parked at the // tail; a prepend there is invisible anyway. - guard !isNearBottom else { return } + guard !isNearBottom else { + WorkChatScrollTrace.writeSuppressed( + reason: "prepend-restore", + site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", + cause: "following-tail" + ) + return + } + WorkChatScrollTrace.write( + reason: "prepend-restore", + target: "y=\(min(max(0, scrollMetrics.offsetY + insertedHeight), scrollMetrics.scrollableHeight)) inserted=\(insertedHeight)", + site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", + offsetBefore: scrollMetrics.offsetY, + contentHeight: scrollMetrics.contentHeight, + containerHeight: scrollMetrics.containerHeight, + scrollableHeight: scrollMetrics.scrollableHeight, + following: isNearBottom, + userDrivenPhase: timelineScrollPhaseUserDriven + ) var transaction = Transaction() transaction.disablesAnimations = true withTransaction(transaction) { @@ -1883,7 +1923,7 @@ struct WorkChatSessionView: View { }, onSend: onSend, onSent: { - scrollToLatest(proxy, animated: true) + scrollToLatest(proxy, animated: true, traceReason: "composer-sent") } ) } @@ -1950,6 +1990,7 @@ struct WorkChatSessionView: View { .scrollPosition($scrollPosition) .onScrollPhaseChange { _, phase in let userDriven = workChatScrollPhaseIsUserDriven(phase) + WorkChatScrollTrace.phase(userDriven: userDriven, raw: "\(phase)") scrollMetrics.phaseIsUserDriven = userDriven let layoutRecent = workChatLayoutAdjustedRecently( lastAdjustmentUptime: scrollMetrics.lastLayoutAdjustmentUptime, @@ -2012,6 +2053,15 @@ struct WorkChatSessionView: View { scrollMetrics.stableOffsetY = sample.offsetY } guard sample.containerHeight > 1 else { return } + WorkChatScrollTrace.sample( + offsetY: sample.offsetY, + contentHeight: sample.contentHeight, + containerHeight: sample.containerHeight, + scrollableHeight: sample.scrollableHeight, + distanceFromBottom: sample.distanceFromBottom, + userDrivenPhase: timelineScrollPhaseUserDriven, + following: isNearBottom + ) updateBottomStickiness(distanceFromBottom: sample.distanceFromBottom, proxy: proxy) continueAutomaticOlderHistoryIfNeeded() requestOlderHistoryIfScrolledNearTop(distanceFromTop: sample.distanceFromTop) @@ -2024,6 +2074,14 @@ struct WorkChatSessionView: View { .onScrollGeometryChange(for: WorkChatContentSizeSample.self) { geometry in WorkChatContentSizeSample(geometry) } action: { previous, sample in + WorkChatScrollTrace.contentSize( + previousContent: previous.contentHeight, + nextContent: sample.contentHeight, + previousContainer: previous.containerHeight, + nextContainer: sample.containerHeight, + offsetY: scrollMetrics.offsetY, + following: isNearBottom + ) if transcriptContentFitsViewport != sample.contentFitsViewport { transcriptContentFitsViewport = sample.contentFitsViewport } @@ -2047,11 +2105,12 @@ struct WorkChatSessionView: View { .overlay(alignment: .bottomTrailing) { if unreadBelowCount > 0 || !isNearBottom { WorkJumpToLatestPill(count: unreadBelowCount) { + WorkChatScrollTrace.follow(true, reason: "jump-to-latest-pill") isNearBottom = true if timelineDragActive { timelineDragActive = false } - scrollToLatest(proxy, animated: true) + scrollToLatest(proxy, animated: true, traceReason: "jump-to-latest-pill") unreadBelowCount = 0 } .padding(.trailing, 16) diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 5f77584fff..604b04611e 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -1172,6 +1172,8 @@ enum ADEPreviewScreen: String, CaseIterable { case proofEmpty = "proof-empty" case proofViewer = "proof-viewer" case tools = "tools" + /// Scroll benchmark over a real transcript file. See `WorkChatScrollBench.swift`. + case chatScroll = "chat-scroll" case queuedSteerDetail = "queued-steer" /// `-adePreviewScreen `. Matches the shape `simctl launch` and the @@ -1221,6 +1223,8 @@ struct ADEPreviewScreenHost: View { initialArtifactId: "proof-1", onLoadArtifact: { _ in } ) + case .chatScroll: + WorkChatScrollBenchScreen(options: .fromLaunchArguments()) case .tools: WorkToolsSheet( laneId: WorkProofPreviewData.laneId, diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift new file mode 100644 index 0000000000..57e07a0b64 --- /dev/null +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -0,0 +1,168 @@ +import XCTest + +/// Gesture driver for the Work chat scroll bench. +/// +/// This target exists because `idb`'s HID path needs +/// `Developer/Library/PrivateFrameworks/SimulatorKit.framework`, which the +/// installed Xcode does not ship — XCUITest is the only real-touch path on this +/// machine. Each test launches the DEBUG fixture screen +/// (`-adePreviewScreen chat-scroll`, see `WorkChatScrollBench.swift`) over a +/// transcript JSONL seeded into the app container, drives one case, and leaves +/// the analysis to the `com.ade.ios.scrollbench` log stream. +/// +/// DATA=$(xcrun simctl get_app_container com.ade.ios data) +/// cp thread.jsonl "$DATA/Documents/bench.jsonl" +/// xcrun simctl spawn log stream --style compact \ +/// --predicate 'subsystem == "com.ade.ios.scrollbench"' > case.log & +/// xcrun simctl io recordVideo --codec h264 case.mp4 & +/// until mkdir "$TMPDIR/ade-xcodebuild.lock" 2>/dev/null; do sleep 15; done; \ +/// xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADEScrollBench \ +/// -destination 'platform=iOS Simulator,id=' \ +/// -derivedDataPath "$TMPDIR/dd-scroll-bench" \ +/// -only-testing:ADEUITests/WorkChatScrollBenchUITests/testCaseA_slowScrollUp; \ +/// rc=$?; rmdir "$TMPDIR/ade-xcodebuild.lock"; exit $rc +/// +/// `ADE_BENCH_TRANSCRIPT` overrides the seeded transcript path. +final class WorkChatScrollBenchUITests: XCTestCase { + private var transcriptPath: String { + ProcessInfo.processInfo.environment["ADE_BENCH_TRANSCRIPT"] + ?? "/tmp/bench.jsonl" + } + + private func launch(extra: [String] = []) -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-adePreviewScreen", "chat-scroll", + "-adeBenchTranscript", transcriptPath, + "-adeScrollTrace", + "-adeBenchLimit", ProcessInfo.processInfo.environment["ADE_BENCH_LIMIT"] ?? "3000", + ] + extra + app.launch() + return app + } + + /// Transcript area of the window: below the header, above the composer. + private func transcript(_ app: XCUIApplication) -> XCUIElement { + app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.windows.firstMatch + } + + private func drag( + _ app: XCUIApplication, + fromY: CGFloat, + toY: CGFloat, + duration: TimeInterval + ) { + let window = app.windows.firstMatch + let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: fromY)) + let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: toY)) + start.press(forDuration: duration, thenDragTo: end) + } + + // (a) Open at the tail, then scroll up slowly through three screens. + func testCaseA_slowScrollUp() { + let app = launch() + sleep(6) + for _ in 0..<3 { + drag(app, fromY: 0.25, toY: 0.72, duration: 0.9) + sleep(2) + } + sleep(3) + } + + // (b) Fling up, then let momentum stop. + func testCaseB_fling() { + let app = launch() + sleep(6) + for _ in 0..<2 { + drag(app, fromY: 0.2, toY: 0.8, duration: 0.02) + sleep(4) + } + sleep(3) + } + + // (c) Scroll up, then tap jump-to-latest. + func testCaseC_jumpToLatest() { + let app = launch() + sleep(6) + drag(app, fromY: 0.2, toY: 0.8, duration: 0.05) + sleep(3) + let pill = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'latest'")).firstMatch + if pill.waitForExistence(timeout: 3) { + pill.tap() + } else { + // The pill carries a count label rather than text on some builds; fall + // back to its screen position (bottom trailing of the transcript). + app.windows.firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.88, dy: 0.76)) + .tap() + } + sleep(4) + } + + // (d) Reach the top, trigger load-older (prepend), keep scrolling. + func testCaseD_prepend() { + let app = launch() + sleep(6) + for _ in 0..<26 { + drag(app, fromY: 0.15, toY: 0.85, duration: 0.02) + usleep(400_000) + } + sleep(4) + for _ in 0..<4 { + drag(app, fromY: 0.25, toY: 0.7, duration: 0.8) + sleep(1) + } + sleep(4) + } + + // (e) Sit at the tail while events append. + func testCaseE_streamAtTail() { + _ = launch(extra: ["-adeBenchStream", "160", "-adeBenchStreamIntervalMs", "140"]) + sleep(35) + } + + // (f) Scrolled up two screens while events append. + func testCaseF_streamScrolledUp() { + let app = launch(extra: ["-adeBenchStream", "160", "-adeBenchStreamIntervalMs", "140"]) + sleep(5) + for _ in 0..<2 { + drag(app, fromY: 0.25, toY: 0.75, duration: 0.5) + usleep(500_000) + } + sleep(32) + } + + // (g) Open the keyboard at the tail, then again while scrolled up. + func testCaseG_keyboard() { + let app = launch() + sleep(6) + let composer = app.textViews.firstMatch.exists + ? app.textViews.firstMatch + : app.textFields.firstMatch + if composer.waitForExistence(timeout: 4) { + composer.tap() + } else { + app.windows.firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.4, dy: 0.88)) + .tap() + } + sleep(4) + app.windows.firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + .tap() + sleep(3) + // Now scrolled up, reopen the keyboard. + drag(app, fromY: 0.25, toY: 0.75, duration: 0.5) + sleep(2) + if composer.exists { + composer.tap() + } + sleep(5) + } + + // (h) Cold open: where does the transcript land. + func testCaseH_initialLanding() { + _ = launch() + sleep(12) + } +} From dfe7c34cec8d81da80da13e126d9cbdabca22d27 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:28:37 -0400 Subject: [PATCH 08/26] refactor(ios): drop the subagent-transcript presentation plumbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: removing the in-thread subagent drill-in left `subagentView` with no setter, so every `viewingSubagent` branch in the session destination — a second transcript, a parent-transcript save/restore pair, a locked composer, zeroed render signatures, a back button, a selected-row highlight — was unreachable code that still had to be read and maintained. Change and boundary: deletes that state and collapses each branch to the main chat path. `WorkChatSessionView` keeps its `viewingSubagent` parameter and is passed `false`; separate subagent chats and child lanes are untouched. `handleChatInfoPresentationChange` is extracted because the destination's modifier chain is long enough that an inline closure there pushes the type checker past its budget. Verification: xcodebuild build BUILD SUCCEEDED; 15 focused ADETests timeline/subagent/tool-card tests, 0 failures. Co-Authored-By: Claude Opus 5 --- .../Views/Work/WorkChatRichCardViews.swift | 15 +- .../Work/WorkSessionDestinationView.swift | 201 +++++------------- 2 files changed, 56 insertions(+), 160 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 191734eb62..16d8a960cc 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2918,7 +2918,6 @@ struct WorkChatInfoDetailsSheet: View { let scheduledWorkPaused: Bool let nextWakeAt: String? let provider: String? - let selectedTaskId: String? let sessionModel: String? @Binding var expandedTaskIds: Set let onSelect: @MainActor (WorkSubagentSnapshot) async -> Void @@ -2946,7 +2945,6 @@ struct WorkChatInfoDetailsSheet: View { scheduledWorkPaused: Bool, nextWakeAt: String?, provider: String?, - selectedTaskId: String?, expandedTaskIds: Binding>, sessionModel: String? = nil, onSelect: @escaping @MainActor (WorkSubagentSnapshot) async -> Void, @@ -2960,7 +2958,6 @@ struct WorkChatInfoDetailsSheet: View { self.scheduledWorkPaused = scheduledWorkPaused self.nextWakeAt = nextWakeAt self.provider = provider - self.selectedTaskId = selectedTaskId self.sessionModel = sessionModel self._expandedTaskIds = expandedTaskIds self.onSelect = onSelect @@ -3105,7 +3102,7 @@ struct WorkChatInfoDetailsSheet: View { private func partitionSubagents(_ items: [WorkSubagentSnapshot]) -> (active: [WorkSubagentSnapshot], earlier: [WorkSubagentSnapshot], clearedCount: Int) { let cleared = clearedIds("subagents") - let pinned = Set([selectedTaskId].compactMap { $0 }).union(expandedTaskIds) + let pinned = expandedTaskIds var active: [WorkSubagentSnapshot] = [] var earlier: [WorkSubagentSnapshot] = [] var clearedCount = 0 @@ -3161,7 +3158,7 @@ struct WorkChatInfoDetailsSheet: View { let backgroundPartition = partitionScheduled(backgroundItems, section: "background", isEarlier: workBackgroundItemIsEarlier) let schedulePartition = partitionScheduled(scheduleItems, section: "schedule", isEarlier: workScheduleItemIsEarlier) let visibleSubagents = capped(subagentPartition.active, cap: subagentsCap, showAll: showAllSections.contains("subagents")) { - $0.status == .failed || selectedTaskId == $0.taskId || expandedTaskIds.contains($0.taskId) + $0.status == .failed || expandedTaskIds.contains($0.taskId) } let visibleBackground = capped(backgroundPartition.active, cap: backgroundCap, showAll: showAllSections.contains("background")) { $0.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "failed" @@ -3280,7 +3277,6 @@ struct WorkChatInfoDetailsSheet: View { private func subagentRow(_ snapshot: WorkSubagentSnapshot) -> some View { WorkChatInfoSubagentRow( snapshot: snapshot, - selected: selectedTaskId == snapshot.taskId, expanded: expandedTaskIds.contains(snapshot.taskId), sessionModel: sessionModel, treePrefix: workSubagentTreePrefix(snapshot, in: subagents), @@ -3454,7 +3450,6 @@ private struct WorkSquareStopButton: View { private struct WorkChatInfoSubagentRow: View { let snapshot: WorkSubagentSnapshot - let selected: Bool let expanded: Bool let sessionModel: String? var treePrefix: String = "" @@ -3508,7 +3503,7 @@ private struct WorkChatInfoSubagentRow: View { HStack(spacing: 8) { WorkSubagentStatusChip(status: snapshot.status) if showsDisclosure { - Image(systemName: selected ? "arrow.uturn.left" : "chevron.right") + Image(systemName: "chevron.right") .font(.system(size: 12, weight: .bold)) .foregroundStyle(ADEColor.textMuted) } @@ -3536,11 +3531,11 @@ private struct WorkChatInfoSubagentRow: View { .padding(.vertical, 9) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(selected ? ADEColor.accent.opacity(0.12) : ADEColor.cardBackground.opacity(0.52)) + .fill(ADEColor.cardBackground.opacity(0.52)) ) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(selected ? ADEColor.accent.opacity(0.45) : ADEColor.glassBorder, lineWidth: 1) + .stroke(ADEColor.glassBorder, lineWidth: 1) ) } .buttonStyle(.plain) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 9157d4ec41..ec2b957b5b 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -888,11 +888,6 @@ struct WorkSessionDestinationView: View { @State var remoteSubagentSnapshots: [WorkSubagentSnapshot] = [] @State var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] @State var scheduledWorkSnapshotsRenderSignature = 0 - @State var subagentView: WorkSubagentSelection? - @State var subagentTranscript: [WorkChatEnvelope] = [] - @State var subagentTranscriptRenderSignature = 0 - @State var parentTranscriptBeforeSubagent: [WorkChatEnvelope] = [] - @State var parentFallbackEntriesBeforeSubagent: [AgentChatTranscriptEntry] = [] @State var chatInfoPresented = false @State var expandedSubagentDetailIds: Set = [] @State var remoteSubagentRefreshInFlight = false @@ -1072,12 +1067,6 @@ struct WorkSessionDestinationView: View { artifactsRenderSignature = workArtifactSummariesRenderSignature(next) } - @MainActor - func setSubagentTranscript(_ next: [WorkChatEnvelope]) { - subagentTranscript = next - subagentTranscriptRenderSignature &+= 1 - } - @MainActor func refreshArtifactContentRenderSignature() { artifactContentRenderSignature = workLoadedArtifactContentRenderSignature(artifactContent) @@ -1096,9 +1085,6 @@ struct WorkSessionDestinationView: View { } var sessionDestinationNavigationTitle: String { - if let subagentView { - return subagentView.name - } if let navigationTitleOverride { return navigationTitleOverride } @@ -1135,14 +1121,7 @@ struct WorkSessionDestinationView: View { workResolveSubagentCapability(provider: subagentProvider) } - var selectedSubagentSnapshot: WorkSubagentSnapshot? { - guard let subagentView else { return nil } - return subagentSnapshots.first { snapshot in - snapshot.taskId == subagentView.taskId - || snapshot.agentId == subagentView.taskId - || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) - } - } + var hostReachable: Bool { syncService.connectionState == .connected @@ -1233,24 +1212,6 @@ struct WorkSessionDestinationView: View { var sessionHeaderTrailingControls: some View { if let session, isChatSession(session) { HStack(spacing: 8) { - if subagentView != nil { - Button { - Task { await dismissSubagentView() } - } label: { - Image(systemName: "chevron.left") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - .frame(width: 30, height: 30) - .background(ADEColor.surfaceBackground.opacity(0.9), in: Circle()) - .overlay( - Circle() - .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.5) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Back to main chat") - } - WorkChatHeaderMenu( model: headerMenuModel(session), onShowChatInfo: { Task { await prepareChatInfoPresentation() } }, @@ -1460,7 +1421,6 @@ struct WorkSessionDestinationView: View { scheduledWorkPaused: composerChatSummary?.scheduledWorkPaused == true, nextWakeAt: composerChatSummary?.nextWakeAt, provider: subagentProvider, - selectedTaskId: subagentView?.taskId, expandedTaskIds: $expandedSubagentDetailIds, sessionModel: composerChatSummary?.model, onSelect: handleSubagentSelection, @@ -1711,8 +1671,7 @@ struct WorkSessionDestinationView: View { refreshChatInfoSnapshots() } .onChange(of: chatInfoPresented) { _, presented in - guard presented else { return } - Task { await refreshRemoteSubagentSnapshots() } + handleChatInfoPresentationChange(presented) } .onDisappear { chatDestinationVisible = false @@ -1875,35 +1834,23 @@ struct WorkSessionDestinationView: View { @ViewBuilder private func chatSessionDestinationRoot(for session: TerminalSessionSummary) -> some View { - let viewingSubagent = subagentView != nil - makeWorkChatSessionView(for: session, viewingSubagent: viewingSubagent) - .id(chatSessionDestinationRootId(for: session, viewingSubagent: viewingSubagent)) - } - - private func chatSessionDestinationRootId( - for session: TerminalSessionSummary, - viewingSubagent: Bool - ) -> String { - if viewingSubagent { - return "subagent-\(subagentView?.taskId ?? "unknown")-\(subagentTranscriptRenderSignature)" - } - return "main-\(session.id)-\(mainChatRenderEpoch)" + makeWorkChatSessionView(for: session) + .id("main-\(session.id)-\(mainChatRenderEpoch)") } private func makeWorkChatSessionView( - for session: TerminalSessionSummary, - viewingSubagent: Bool + for session: TerminalSessionSummary ) -> WorkChatSessionView { - let transcriptForView = viewingSubagent ? subagentTranscript : transcript - let fallbackEntriesForView: [AgentChatTranscriptEntry] = viewingSubagent ? [] : fallbackEntries - let artifactsForView: [ComputerUseArtifactSummary] = viewingSubagent ? [] : artifacts - let optimisticPendingSteersForView: [WorkPendingSteerModel] = viewingSubagent ? [] : optimisticPendingSteers - let localEchoMessagesForView: [WorkLocalEchoMessage] = viewingSubagent ? [] : localEchoMessages + let transcriptForView = transcript + let fallbackEntriesForView: [AgentChatTranscriptEntry] = fallbackEntries + let artifactsForView: [ComputerUseArtifactSummary] = artifacts + let optimisticPendingSteersForView: [WorkPendingSteerModel] = optimisticPendingSteers + let localEchoMessagesForView: [WorkLocalEchoMessage] = localEchoMessages let sessionStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) let shouldSteer = hostReachable && sessionStatus == "active" let prPolicy = WorkChatLanePrPolicy( showsLaneActions: showsLaneActions, - viewingSubagent: viewingSubagent + viewingSubagent: false ) let chatPrBadge: WorkChatPrBadgeModel? = prPolicy.rendersPrBadge ? workChatPrBadgeModel( @@ -1914,9 +1861,7 @@ struct WorkSessionDestinationView: View { ) : nil let openPrDetails: (() -> Void)? = prPolicy.rendersPrBadge ? { presentChatPrDetails() } : nil - let inputLockMessage: String? = viewingSubagent - ? "Viewing subagent transcript. Return to main chat to send." - : nil + let inputLockMessage: String? = nil let openLaneAction: (() -> Void)? = showsLaneActions ? { openSessionLane() } : nil // Wired per mode, not per provider, so a provider that gains or loses a // mode needs no change here. Matches the desktop pane, which gates each @@ -1941,11 +1886,9 @@ struct WorkSessionDestinationView: View { } else { dispatchSteerInterruptAction = nil } - let resolvedSessionStatus: String? = viewingSubagent ? "ended" : sessionStatus + let resolvedSessionStatus: String? = sessionStatus let loadOlderTranscriptAction: (@MainActor () async -> WorkChatOlderHistoryLoadResult)? - if viewingSubagent { - loadOlderTranscriptAction = nil - } else if isCrossProject && !syncService.supportsSubscribedChatHistory(sessionId: sessionId) { + if isCrossProject && !syncService.supportsSubscribedChatHistory(sessionId: sessionId) { // Older hosts have no scoped transcript-page envelope. Do not fall back // to a foreign project command, which would activate its runtime just to // read history; the next host upgrade makes the same cached view pageable. @@ -1983,7 +1926,7 @@ struct WorkSessionDestinationView: View { } else { resumeUsageLimitNowAction = nil } - let canWriteSpawnKind = !viewingSubagent && syncService.supportsSpawnKindUpdate + let canWriteSpawnKind = syncService.supportsSpawnKindUpdate let restoreCancelledQueueAction: (@MainActor (String) async -> Void)? if syncService.supportsChatRemoteAction( "chat.restoreCancelledQueue", @@ -2004,17 +1947,17 @@ struct WorkSessionDestinationView: View { } ), transcript: transcriptForView, - transcriptRenderSignature: viewingSubagent ? subagentTranscriptRenderSignature : transcriptRenderSignature, - allowsIncrementalTranscriptUpdate: viewingSubagent ? false : transcriptAllowsIncrementalSnapshot, - transcriptIncrementalDelta: viewingSubagent ? .constant([]) : $transcriptIncrementalDelta, + transcriptRenderSignature: transcriptRenderSignature, + allowsIncrementalTranscriptUpdate: transcriptAllowsIncrementalSnapshot, + transcriptIncrementalDelta: $transcriptIncrementalDelta, fallbackEntries: fallbackEntriesForView, - fallbackEntriesRenderSignature: viewingSubagent ? 0 : fallbackEntriesRenderSignature, + fallbackEntriesRenderSignature: fallbackEntriesRenderSignature, artifacts: artifactsForView, - artifactsRenderSignature: viewingSubagent ? 0 : artifactsRenderSignature, + artifactsRenderSignature: artifactsRenderSignature, optimisticPendingSteers: optimisticPendingSteersForView, - optimisticPendingSteersRenderSignature: viewingSubagent ? 0 : workPendingSteersRenderSignature(optimisticPendingSteers), + optimisticPendingSteersRenderSignature: workPendingSteersRenderSignature(optimisticPendingSteers), localEchoMessages: localEchoMessagesForView, - localEchoMessagesRenderSignature: viewingSubagent ? 0 : workLocalEchoMessagesRenderSignature(localEchoMessages), + localEchoMessagesRenderSignature: workLocalEchoMessagesRenderSignature(localEchoMessages), cardExpansionSnapshot: cardExpansion, cardExpansionRenderSignature: workCardExpansionRenderSignature(cardExpansion), artifactContentRenderSignature: artifactContentRenderSignature, @@ -2031,8 +1974,8 @@ struct WorkSessionDestinationView: View { errorMessage: $errorMessage, isLive: isLiveAndReachable, hostUnreachable: syncService.connectionState.isHostUnreachable, - canComposeMessages: canComposeChatMessages && !viewingSubagent, - canSendMessages: canSendChatMessages && !viewingSubagent, + canComposeMessages: canComposeChatMessages, + canSendMessages: canSendChatMessages, sendWillQueue: sendWillQueueChatMessage || shouldSteer, sendWillQueueIsReconnect: sendWillQueueChatMessage, activeSendModesAvailable: activeSendModesAvailable, @@ -2074,23 +2017,23 @@ struct WorkSessionDestinationView: View { // Memberwise-init argument order follows property declaration order in // WorkChatSessionView, where onOpenParentSession sits after the model // controls. - onOpenParentSession: viewingSubagent ? nil : { openParentSession() }, + onOpenParentSession: { openParentSession() }, resolvedSessionStatus: resolvedSessionStatus, lanes: lanes, lanesRenderSignature: lanesRenderSignature, - hasOlderTranscriptHistory: viewingSubagent ? false : hasOlderTranscriptHistory, + hasOlderTranscriptHistory: hasOlderTranscriptHistory, onLoadOlderTranscript: loadOlderTranscriptAction, subagentSnapshots: subagentSnapshots, subagentSnapshotsRenderSignature: subagentSnapshotsRenderSignature, - scheduledWorkSnapshots: viewingSubagent ? [] : scheduledWorkSnapshots, - scheduledWorkSnapshotsRenderSignature: viewingSubagent ? 0 : scheduledWorkSnapshotsRenderSignature, - selectedSubagentTaskId: subagentView?.taskId, + scheduledWorkSnapshots: scheduledWorkSnapshots, + scheduledWorkSnapshotsRenderSignature: scheduledWorkSnapshotsRenderSignature, + selectedSubagentTaskId: nil, // One chip, one destination: subagents, background work and schedules // all live in the Chat Info sheet. Timeline-row selection stays scoped // to the parent chat, so nested transcript state cannot accidentally // fetch from itself. - onOpenChatInfo: viewingSubagent ? nil : { Task { await prepareChatInfoPresentation() } }, - onSelectSubagentRow: subagentRowSelectionHandler(viewingSubagent: viewingSubagent), + onOpenChatInfo: { Task { await prepareChatInfoPresentation() } }, + onSelectSubagentRow: subagentRowSelectionHandler(), onForkChatInLane: { let modelId = (composerChatSummary?.modelId ?? composerChatSummary?.model ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2124,13 +2067,13 @@ struct WorkSessionDestinationView: View { || syncService.canInvokeRemoteAction("personalChats.updateSession"), onRecoverCodexTurn: workChatCodexRecoveryAvailable( hostSupportsRecovery: supportsRecovery, - viewingSubagent: viewingSubagent + viewingSubagent: false ) ? recoverCodexTurn : nil, - onRunUnprocessedMessage: !viewingSubagent && supportsUnprocessedResolution + onRunUnprocessedMessage: supportsUnprocessedResolution ? runUnprocessedMessage : nil, - onEditUnprocessedMessage: !viewingSubagent ? editUnprocessedMessage : nil, - onDismissUnprocessedMessage: !viewingSubagent && supportsUnprocessedResolution + onEditUnprocessedMessage: editUnprocessedMessage, + onDismissUnprocessedMessage: supportsUnprocessedResolution ? dismissUnprocessedMessage : nil, transcriptLoadState: transcriptLoadState, @@ -2147,11 +2090,22 @@ struct WorkSessionDestinationView: View { /// Explicitly-typed handler so the optional async closure doesn't make the /// large `WorkChatSessionView(...)` initializer ambiguous at the call site. - private func subagentRowSelectionHandler( - viewingSubagent: Bool - ) -> (@MainActor (WorkSubagentSnapshot) async -> Void)? { - guard !viewingSubagent else { return nil } - return { snapshot in await handleSubagentSelection(snapshot) } + /// Explicitly-typed handler so the optional async closure doesn't make the + /// large `WorkChatSessionView(...)` initializer ambiguous at the call site. + private func subagentRowSelectionHandler() -> (@MainActor (WorkSubagentSnapshot) async -> Void)? { + let handler: @MainActor (WorkSubagentSnapshot) async -> Void = { snapshot in + await handleSubagentSelection(snapshot) + } + return handler + } + + /// Extracted from the `.onChange` modifier: the destination's modifier chain + /// is long enough that an inline closure body pushes the type checker past + /// its budget for the whole expression. + @MainActor + private func handleChatInfoPresentationChange(_ presented: Bool) { + guard presented else { return } + Task { await refreshRemoteSubagentSnapshots() } } var pollingKey: String { @@ -3311,17 +3265,6 @@ struct WorkSessionDestinationView: View { subagentSnapshots = next subagentSnapshotsRenderSignature = workSubagentSnapshotsRenderSignature(next) } - if let subagentView, - let updated = next.first(where: { snapshot in - snapshot.taskId == subagentView.taskId - || snapshot.agentId == subagentView.taskId - || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) - }) { - let selection = workSubagentSelection(from: updated) - if selection != subagentView { - self.subagentView = selection - } - } } @MainActor @@ -3374,31 +3317,6 @@ struct WorkSessionDestinationView: View { } } - @MainActor - func clearSubagentView() { - subagentView = nil - setSubagentTranscript([]) - } - - @MainActor - func rememberParentTranscriptBeforeSubagent() { - guard !transcript.isEmpty || !fallbackEntries.isEmpty else { return } - parentTranscriptBeforeSubagent = transcript - parentFallbackEntriesBeforeSubagent = fallbackEntries - } - - @MainActor - func restoreParentTranscriptAfterSubagentIfNeeded() { - if transcript.isEmpty, !parentTranscriptBeforeSubagent.isEmpty { - setTranscript(parentTranscriptBeforeSubagent) - } - if fallbackEntries.isEmpty, !parentFallbackEntriesBeforeSubagent.isEmpty { - setFallbackEntries(parentFallbackEntriesBeforeSubagent) - } - parentTranscriptBeforeSubagent = [] - parentFallbackEntriesBeforeSubagent = [] - } - @MainActor func materializeParentTranscriptFromLiveEventsIfNeeded() { guard transcript.isEmpty, fallbackEntries.isEmpty else { return } @@ -3425,28 +3343,11 @@ struct WorkSessionDestinationView: View { await loadTranscript(forceRemote: true, preferLightweight: false) } materializeParentTranscriptFromLiveEventsIfNeeded() - rememberParentTranscriptBeforeSubagent() chatInfoPresented = true await refreshRemoteSubagentSnapshots() } - @MainActor - func dismissSubagentView() async { - guard subagentView != nil else { return } - if transcript.isEmpty && fallbackEntries.isEmpty && shouldHydrateTranscriptFromHost { - await refreshChatSummaryFromHost() - await loadTranscript(forceRemote: true, preferLightweight: false) - } - - clearSubagentView() - restoreParentTranscriptAfterSubagentIfNeeded() - materializeParentTranscriptFromLiveEventsIfNeeded() - - if transcript.isEmpty && fallbackEntries.isEmpty && shouldHydrateTranscriptFromHost { - await hydrateEmptyTranscriptFromHostIfNeeded(force: true) - } - } /// Tapping an in-thread subagent expands its card, and nothing more. /// From cbacdd3103248664435bd3f5d1cb6cc7322b7729 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 15:38:56 -0400 Subject: [PATCH 09/26] feat(ios): the chat composer folds with a swipe instead of a row The collapse control had its own row above the prompt field, and folding a long draft stepped down instead of animating: the fold changed the field's line clamp, which re-measured the UITextView on a background hop that landed outside the animation. The row is gone. A downward swipe anywhere on the composer card folds the field to one line and lowers the keyboard on one spring; an upward swipe, a tap into the field, or a tap on the compact tray brings both back. The swipe rides alongside the field's own recognizers, so text selection and the tray's horizontal scroll still work, and only a mostly-vertical drag past 40pt is claimed. The folded card clips the same measured field instead of re-laying it out, and no fold path scrolls the transcript. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 8 + .../Work/WorkChatComposerAndInputViews.swift | 77 +++++++++ .../ADE/Views/Work/WorkChatSessionView.swift | 151 ++++++++++-------- .../Work/WorkComposerTypedTriggers.swift | 3 + .../ADETests/WorkComposerFoldStateTests.swift | 117 ++++++++++++++ .../ADEUITests/WorkComposerFoldUITests.swift | 115 +++++++++++++ .../sync-and-multi-device/ios-companion.md | 32 +++- 7 files changed, 435 insertions(+), 68 deletions(-) create mode 100644 apps/ios/ADETests/WorkComposerFoldStateTests.swift create mode 100644 apps/ios/ADEUITests/WorkComposerFoldUITests.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index b27fb4efe8..dd7a1aa47c 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ EB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */; }; + DCF01D0000000000000004 /* WorkComposerFoldUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF01D0000000000000003 /* WorkComposerFoldUITests.swift */; }; E20000000000000000000091 /* LinearBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000091 /* LinearBrand.swift */; }; E20000000000000000000092 /* LinearSVGPath.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000092 /* LinearSVGPath.swift */; }; E20000000000000000000093 /* LinearLaunchModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000093 /* LinearLaunchModel.swift */; }; @@ -205,6 +206,7 @@ D30000000000000000000019 /* WorkComposerTriggerDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */; }; D30000000000000000000F32 /* WorkChipDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000F31 /* WorkChipDetectorTests.swift */; }; D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000000A /* WorkPromptStashTests.swift */; }; + DCF01D0000000000000002 /* WorkComposerFoldStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */; }; D31000000000000000000F0D /* WorkToolsContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000F0D /* WorkToolsContractTests.swift */; }; D31000000000000000000F20 /* WorkProofRowModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000F20 /* WorkProofRowModelTests.swift */; }; FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */; }; @@ -545,6 +547,7 @@ D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTriggerDetectorTests.swift; path = ADETests/WorkComposerTriggerDetectorTests.swift; sourceTree = ""; }; D30000000000000000000F31 /* WorkChipDetectorTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChipDetectorTests.swift; path = ADETests/WorkChipDetectorTests.swift; sourceTree = ""; }; D3000000000000000000000A /* WorkPromptStashTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStashTests.swift; path = ADETests/WorkPromptStashTests.swift; sourceTree = ""; }; + DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerFoldStateTests.swift; path = ADETests/WorkComposerFoldStateTests.swift; sourceTree = ""; }; D30000000000000000000F0D /* WorkToolsContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkToolsContractTests.swift; path = ADETests/WorkToolsContractTests.swift; sourceTree = ""; }; D30000000000000000000F20 /* WorkProofRowModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkProofRowModelTests.swift; path = ADETests/WorkProofRowModelTests.swift; sourceTree = ""; }; FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionCanonicalStateTests.swift; path = ADETests/WorkSessionCanonicalStateTests.swift; sourceTree = ""; }; @@ -670,6 +673,7 @@ E3A5721EB84321D201716BC3 /* ADETests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ADETests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB000000000000000000UI02 /* ADEUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ADEUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatScrollBenchUITests.swift; path = ADEUITests/WorkChatScrollBenchUITests.swift; sourceTree = ""; }; + DCF01D0000000000000003 /* WorkComposerFoldUITests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerFoldUITests.swift; path = ADEUITests/WorkComposerFoldUITests.swift; sourceTree = ""; }; C5A000000000000000000001 /* MobileTrustResetPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MobileTrustResetPolicy.swift; path = ADE/Services/MobileTrustResetPolicy.swift; sourceTree = ""; }; C5A00000000000000000001F /* PairedHostCredentialState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairedHostCredentialState.swift; path = ADE/Services/PairedHostCredentialState.swift; sourceTree = ""; }; C5A000000000000000000002 /* SSHBootstrapModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SSHBootstrapModels.swift; path = ADE/Services/SSHBootstrapModels.swift; sourceTree = ""; }; @@ -1239,6 +1243,7 @@ children = ( 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */, + DCF01D0000000000000003 /* WorkComposerFoldUITests.swift */, AC8000000000000000000002 /* ProjectHostRecoveryTests.swift */, AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */, B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */, @@ -1269,6 +1274,7 @@ D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */, D30000000000000000000F31 /* WorkChipDetectorTests.swift */, D3000000000000000000000A /* WorkPromptStashTests.swift */, + DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */, D30000000000000000000F0D /* WorkToolsContractTests.swift */, D30000000000000000000F20 /* WorkProofRowModelTests.swift */, FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */, @@ -1568,6 +1574,7 @@ buildActionMask = 2147483647; files = ( EB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift in Sources */, + DCF01D0000000000000004 /* WorkComposerFoldUITests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1863,6 +1870,7 @@ D30000000000000000000019 /* WorkComposerTriggerDetectorTests.swift in Sources */, D30000000000000000000F32 /* WorkChipDetectorTests.swift in Sources */, D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */, + DCF01D0000000000000002 /* WorkComposerFoldStateTests.swift in Sources */, D31000000000000000000F0D /* WorkToolsContractTests.swift in Sources */, D31000000000000000000F20 /* WorkProofRowModelTests.swift in Sources */, FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */, diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 2102437bbe..8255c070a4 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -2,6 +2,83 @@ import SwiftUI import UIKit import AVKit +// MARK: - Composer fold + +/// The one animation the composer fold rides. A single spring on the collapsed +/// state, applied where the state changes rather than as an `.animation(_:value:)` +/// modifier, so the card's height is the only thing interpolated and nothing +/// animates the change twice. +let workComposerFoldAnimation: Animation = .snappy(duration: 0.26, extraBounce: 0.02) + +/// Minimum downward travel that counts as a fold swipe. Short enough to feel +/// like a flick, long enough that it is never a mis-read tap or a text +/// selection drag. +let workComposerFoldSwipeThreshold: CGFloat = 40 + +/// What a finished drag on the composer card means. +enum WorkComposerFoldGesture: Equatable { + case collapse + case expand + case ignore +} + +/// Classifies a drag on the composer card. +/// +/// A swipe only counts when it is mostly vertical — a mostly horizontal drag is +/// the attachment tray scrolling or a text selection, and stealing it would +/// make both unusable. Direction is read against the current state so the same +/// gesture never toggles back and forth mid-drag. +func workComposerFoldGesture( + translation: CGSize, + collapsed: Bool +) -> WorkComposerFoldGesture { + let dy = translation.height + let dx = abs(translation.width) + guard abs(dy) >= workComposerFoldSwipeThreshold, abs(dy) > dx * 1.5 else { return .ignore } + if dy > 0 { return collapsed ? .ignore : .collapse } + return collapsed ? .expand : .ignore +} + +/// The composer's fold state: what the card shows and whether the keyboard is +/// up. Collapsed is a view mode only — nothing is unstaged, and the draft is +/// untouched. +struct WorkComposerFoldState: Equatable { + var collapsed: Bool + var focused: Bool + + static let expanded = WorkComposerFoldState(collapsed: false, focused: false) +} + +/// Every way the fold can change. +enum WorkComposerFoldIntent: Equatable { + /// Swipe down on the card: fold to one line and put the keyboard away. + case collapse + /// Swipe up on the folded card, or a tap on the compact tray. + case expand + /// The field reported focus (the user tapped into it, or a restore pushed + /// focus there). Typing is always an escape from the folded state. + case focusChanged(Bool) +} + +/// Pure transition for the composer fold. +/// +/// `canCompose` is false while a question gates the field; expanding then must +/// not raise a keyboard over a field that rejects every keystroke. +func workComposerFoldTransition( + _ state: WorkComposerFoldState, + intent: WorkComposerFoldIntent, + canCompose: Bool +) -> WorkComposerFoldState { + switch intent { + case .collapse: + return WorkComposerFoldState(collapsed: true, focused: false) + case .expand: + return WorkComposerFoldState(collapsed: false, focused: canCompose) + case .focusChanged(let focused): + return WorkComposerFoldState(collapsed: focused ? false : state.collapsed, focused: focused) + } +} + struct WorkTurnUsageSummaryBanner: View { let summary: WorkUsageSummary /// Retained for call-site compatibility. Model is shown in usage and composer, not the turn line. diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 08cab259fa..915a584db1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -3196,18 +3196,6 @@ private struct WorkChatComposerCard: View { onSend: onSend, onSent: onSent ) - .padding(.horizontal, 12) - .padding(.vertical, compact ? 8 : 10) - .background(composerSurface) - } - - private var composerSurface: some View { - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill(ADEColor.composerBackground) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 1) - ) } } @@ -3355,42 +3343,53 @@ private struct WorkChatComposerDraftInput: View { !isPersonalChat && syncService.canInvokeRemoteAction("chat.listPromptStashes") } - /// The collapse affordance lives in the composer card, not in a - /// `ToolbarItemGroup(placement: .keyboard)`. Keyboard toolbars are scoped to - /// the view that mounts them, so one mounted here could not dismiss the main - /// composer's `UITextView` — and a toolbar button also disappears with the - /// keyboard, which is exactly when the user needs the control to get back. - private var collapseControlVisible: Bool { - draftState.isFocused || !inputAttachments.isEmpty + /// The fold is a gesture, not a control: a downward swipe anywhere on the + /// card puts the keyboard away and folds the field to one line, and an upward + /// swipe (or a tap into the field, or on the compact tray) brings both back. + /// The old dedicated button cost the composer a whole row above the field, + /// and a keyboard toolbar item cannot work here at all — a toolbar is scoped + /// to the view that mounts it, and it disappears with the keyboard, which is + /// exactly when the user needs the way back. + private var foldState: WorkComposerFoldState { + WorkComposerFoldState(collapsed: composerCollapsed, focused: draftState.isFocused) + } + + /// Applies a fold intent. The height change rides one spring; the focus + /// change is deliberately outside it, because the keyboard runs its own + /// animation and driving it from here animates the fold twice. + private func applyFold(_ intent: WorkComposerFoldIntent) { + let next = workComposerFoldTransition(foldState, intent: intent, canCompose: canCompose) + if next.collapsed != composerCollapsed { + withAnimation(workComposerFoldAnimation) { + composerCollapsed = next.collapsed + } + } + if next.focused != draftState.isFocused { + draftState.isFocused = next.focused + } } - @ViewBuilder - private var composerHeaderRow: some View { - if collapseControlVisible { - HStack(spacing: 0) { - Spacer(minLength: 0) - Button { - draftState.isFocused = false - composerCollapsed = true - } label: { - Image(systemName: "keyboard.chevron.compact.down") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 28, height: 28) - .background(ADEColor.surfaceBackground.opacity(0.38), in: Circle()) - .overlay(Circle().stroke(ADEColor.border.opacity(0.28), lineWidth: 0.6)) - // 28pt reads right next to a one-line field but is under the 44pt - // minimum; the hit area grows, the drawn circle does not — the same - // split the composer's overflow control uses. - .frame(width: 44, height: 44) - .contentShape(Rectangle()) + /// Rides alongside the field's own recognizers rather than replacing them, so + /// text selection and the tray's horizontal scroll keep working; only a + /// mostly-vertical swipe past the threshold is claimed. + private var foldGesture: some Gesture { + DragGesture(minimumDistance: 12, coordinateSpace: .local) + .onEnded { value in + switch workComposerFoldGesture(translation: value.translation, collapsed: composerCollapsed) { + case .collapse: applyFold(.collapse) + case .expand: applyFold(.expand) + case .ignore: break } - .buttonStyle(.plain) - .accessibilityLabel("Collapse composer") - .accessibilityHint("Lowers the keyboard and shrinks the composer") - .accessibilityIdentifier("Work.Chat.Composer.Collapse") } - } + } + + private var composerSurface: some View { + RoundedRectangle(cornerRadius: 24, style: .continuous) + .fill(ADEColor.composerBackground) + .overlay( + RoundedRectangle(cornerRadius: 24, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 1) + ) } private var sendEnabled: Bool { @@ -3474,24 +3473,21 @@ private struct WorkChatComposerDraftInput: View { WorkChatInputAttachmentTray( attachments: $inputAttachments, compact: composerCollapsed, - onExpand: { - composerCollapsed = false - if canCompose { draftState.isFocused = true } - }, + onExpand: { applyFold(.expand) }, chatSessionId: sessionId.isEmpty ? nil : sessionId ) } - /// Text lines the field may grow to. Collapsed clamps to one, which is the - /// same `compact` clamp the small composer already uses. - private var composerMaxLines: Int { - composerCollapsed ? 1 : 6 - } + /// Text lines the field may grow to. + /// + /// Deliberately NOT tied to the fold: re-measuring the `UITextView` mid-fold + /// re-lays the whole draft out on a background hop that lands outside the + /// animation, which is what made a long draft fold in steps. The folded card + /// clips the same measured field instead (`collapsed:` below). + private var composerMaxLines: Int { 6 } var body: some View { VStack(alignment: .leading, spacing: 8) { - composerHeaderRow - sendFailureRow if compact { @@ -3515,7 +3511,8 @@ private struct WorkChatComposerDraftInput: View { onPasteImages: { images in workChatInputPasteImages(images, into: $inputAttachments) }, - maxLines: 1 + maxLines: 1, + collapsed: composerCollapsed ) } @@ -3542,7 +3539,8 @@ private struct WorkChatComposerDraftInput: View { onPasteImages: { images in workChatInputPasteImages(images, into: $inputAttachments) }, - maxLines: composerMaxLines + maxLines: composerMaxLines, + collapsed: composerCollapsed ) if showInterrupt && hasSendableDraftOrAttachment { @@ -3578,6 +3576,16 @@ private struct WorkChatComposerDraftInput: View { } } } + .padding(.horizontal, 12) + .padding(.vertical, compact ? 8 : 10) + .background(composerSurface) + // The gesture covers the card's chrome, not just the field, so the swipe + // works from the padding and the controls row too. + .contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous)) + .simultaneousGesture(foldGesture) + .accessibilityAction(named: composerCollapsed ? "Expand composer" : "Collapse composer") { + applyFold(composerCollapsed ? .expand : .collapse) + } .onAppear { configureSuggestionController() } @@ -3622,23 +3630,18 @@ private struct WorkChatComposerDraftInput: View { .workChatAttachmentPicker( isPresented: $presentedPicker.isPresenting(.photos), attachments: $inputAttachments, - onDismiss: { - composerCollapsed = false - if canCompose { draftState.isFocused = true } - } + onDismiss: { applyFold(.expand) } ) .workChatFileAttachmentPickers( presentedPicker: $presentedPicker, attachments: $inputAttachments, - onDismiss: { - composerCollapsed = false - if canCompose { draftState.isFocused = true } - } + onDismiss: { applyFold(.expand) } ) // Typing is the escape from the collapsed state, so it is never a mode the // user has to work out how to leave. .onChange(of: draftState.isFocused) { _, focused in - if focused { composerCollapsed = false } + guard focused, composerCollapsed else { return } + withAnimation(workComposerFoldAnimation) { composerCollapsed = false } } // Stage every ready attachment on the host the moment it lands, then persist // the refs. Uploading on attach is what makes the draft persistable (refs, @@ -4383,8 +4386,22 @@ private struct WorkChatComposerTextField: View { var acceptsPastedImages = true var onPasteImages: (([UIImage]) -> Void)? = nil var maxLines = 6 + /// Folded composer. The field keeps its measured height and is clipped to one + /// line from the top, so folding a long draft animates one frame height + /// instead of re-laying the text out. + var collapsed = false @State private var measuredHeight: CGFloat = 24 + /// One line of body text plus the same 8pt slack `WorkComposerTextView` adds + /// to its own line-count clamp. + private var singleLineHeight: CGFloat { + ceil(UIFont.preferredFont(forTextStyle: .body).lineHeight) + 8 + } + + private var displayHeight: CGFloat { + collapsed ? min(measuredHeight, singleLineHeight) : measuredHeight + } + var body: some View { WorkComposerTextView( draftState: draftState, @@ -4397,6 +4414,8 @@ private struct WorkChatComposerTextField: View { maxLines: maxLines ) .frame(height: measuredHeight) + .frame(height: displayHeight, alignment: .top) + .clipped() .frame(maxWidth: .infinity, alignment: .leading) } } diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 8d937a748b..9057b254f4 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -1697,6 +1697,9 @@ struct WorkComposerTextView: UIViewRepresentable { textView.backgroundColor = .clear textView.textContainerInset = .zero textView.isScrollEnabled = false + // A downward drag inside a draft long enough to scroll takes the keyboard + // with it, matching the composer card's fold swipe. + textView.keyboardDismissMode = .interactive // Keep natural-language prompt traits aligned with `adePromptInputTraits()`. textView.autocorrectionType = .yes textView.autocapitalizationType = .sentences diff --git a/apps/ios/ADETests/WorkComposerFoldStateTests.swift b/apps/ios/ADETests/WorkComposerFoldStateTests.swift new file mode 100644 index 0000000000..45c046b602 --- /dev/null +++ b/apps/ios/ADETests/WorkComposerFoldStateTests.swift @@ -0,0 +1,117 @@ +import XCTest +import SwiftUI +@testable import ADE + +/// The composer fold is a gesture now, not a button, so the two things that +/// used to be guaranteed by a tap target have to be guaranteed by these rules +/// instead: +/// +/// 1. only a deliberate, mostly-vertical swipe folds or unfolds the card — a +/// horizontal drag belongs to the attachment tray or a text selection; +/// 2. the fold and the keyboard move together, and expanding never raises a +/// keyboard over a field a pending question has locked. +final class WorkComposerFoldStateTests: XCTestCase { + // MARK: Gesture classification + + func testDownwardSwipePastThresholdCollapses() { + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: 4, height: 56), collapsed: false), + .collapse + ) + } + + func testShortDownwardDragIsIgnored() { + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: 0, height: 24), collapsed: false), + .ignore + ) + } + + func testMostlyHorizontalDragIsIgnored() { + // The compact tray scrolls horizontally; a diagonal flick across it must not + // fold the composer out from under the finger. + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: 120, height: 48), collapsed: false), + .ignore + ) + } + + func testDownwardSwipeOnAFoldedCardDoesNothing() { + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: 0, height: 80), collapsed: true), + .ignore + ) + } + + func testUpwardSwipeOnAFoldedCardExpands() { + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: -6, height: -64), collapsed: true), + .expand + ) + } + + func testUpwardSwipeOnAnExpandedCardDoesNothing() { + XCTAssertEqual( + workComposerFoldGesture(translation: CGSize(width: 0, height: -64), collapsed: false), + .ignore + ) + } + + // MARK: Transitions + + func testCollapseLowersTheKeyboardWithTheCard() { + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: false, focused: true), + intent: .collapse, + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: true, focused: false)) + } + + func testExpandRefocusesTheField() { + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: true, focused: false), + intent: .expand, + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: false, focused: true)) + } + + func testExpandDoesNotRaiseAKeyboardOverAGatedField() { + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: true, focused: false), + intent: .expand, + canCompose: false + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: false, focused: false)) + } + + func testTypingEscapesTheFoldedState() { + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: true, focused: false), + intent: .focusChanged(true), + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: false, focused: true)) + } + + func testLosingFocusAloneDoesNotFoldTheCard() { + // Sending, or a picker taking over, drops focus. That is a keyboard event, + // not a fold — the card keeps the height the user left it at. + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: false, focused: true), + intent: .focusChanged(false), + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: false, focused: false)) + } + + func testFoldSurvivesAFocusDropWhileFolded() { + let next = workComposerFoldTransition( + WorkComposerFoldState(collapsed: true, focused: false), + intent: .focusChanged(false), + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: true, focused: false)) + } +} diff --git a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift new file mode 100644 index 0000000000..b9d66b4574 --- /dev/null +++ b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift @@ -0,0 +1,115 @@ +import XCTest + +/// Gesture driver + contract for the composer fold. +/// +/// The fold exists so a reader can pause mid-sentence and read the thread. That +/// makes "the transcript did not move" the whole point of the feature, not a +/// detail — so it is asserted here, over the real `WorkChatSessionView` the +/// scroll bench fixture mounts (`-adePreviewScreen chat-scroll`), with a draft +/// long enough that the fold has real height to travel. +/// +/// DATA=$(xcrun simctl get_app_container com.ade.ios data) +/// head -n 200 thread.jsonl > "$DATA/Documents/bench.jsonl" +/// until mkdir "$TMPDIR/ade-xcodebuild.lock" 2>/dev/null; do sleep 15; done; \ +/// xcodebuild test -project apps/ios/ADE.xcodeproj -scheme ADE \ +/// -destination 'platform=iOS Simulator,id=' \ +/// -derivedDataPath "$TMPDIR/dd-composer" -parallel-testing-enabled NO \ +/// -only-testing:ADEUITests/WorkComposerFoldUITests; \ +/// rc=$?; rmdir "$TMPDIR/ade-xcodebuild.lock"; exit $rc +final class WorkComposerFoldUITests: XCTestCase { + private var transcriptPath: String { + ProcessInfo.processInfo.environment["ADE_BENCH_TRANSCRIPT"] ?? "/tmp/bench.jsonl" + } + + private func launch() -> XCUIApplication { + let app = XCUIApplication() + app.launchArguments = [ + "-adePreviewScreen", "chat-scroll", + "-adeBenchTranscript", transcriptPath, + "-adeBenchLimit", "200", + ] + app.launch() + return app + } + + private func composerField(_ app: XCUIApplication) -> XCUIElement { + app.textViews["Work.Chat.Composer.TextView"] + } + + /// A 40-line draft, typed rather than injected, so the field measures the + /// same way it does for a person holding the phone. + private func longDraft() -> String { + (1...40).map { "line \($0) of a long draft that keeps going" }.joined(separator: "\n") + } + + /// The topmost transcript label, which is what "my place in the thread" means + /// to the reader. Taken from the upper half of the window so the composer and + /// the keyboard can never be the thing being measured. + private func topTranscriptLabel(_ app: XCUIApplication) -> (label: String, y: CGFloat)? { + let window = app.windows.firstMatch.frame + let candidates = app.staticTexts.allElementsBoundByIndex + .filter { $0.exists && $0.frame.height > 0 && $0.frame.minY > window.minY + 60 } + .filter { $0.frame.maxY < window.midY } + .sorted { $0.frame.minY < $1.frame.minY } + guard let first = candidates.first else { return nil } + return (first.label, first.frame.minY) + } + + private func swipeDownOnComposer(_ app: XCUIApplication) { + let window = app.windows.firstMatch + let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) + let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.93)) + start.press(forDuration: 0.12, thenDragTo: end) + } + + /// Folding shrinks the composer, lowers the keyboard, and leaves the reader + /// exactly where they were. + func testSwipeDownFoldsComposerWithoutMovingTheThread() { + let app = launch() + let field = composerField(app) + XCTAssertTrue(field.waitForExistence(timeout: 30), "bench fixture never rendered the composer") + + field.tap() + field.typeText(longDraft()) + let grownHeight = field.frame.height + XCTAssertGreaterThan(grownHeight, 60, "a 40-line draft should grow the field past one line") + + guard let before = topTranscriptLabel(app) else { + return XCTFail("no transcript row to anchor on") + } + + swipeDownOnComposer(app) + + // The fold's spring; polled rather than slept on. + let folded = expectation(description: "composer folded") + let deadline = Date().addingTimeInterval(5) + func poll() { + if field.exists, field.frame.height < grownHeight - 8 { + folded.fulfill() + } else if Date() < deadline { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: poll) + } else { + folded.fulfill() + } + } + poll() + wait(for: [folded], timeout: 6) + + XCTAssertLessThan(field.frame.height, grownHeight - 8, "swiping down did not fold the field") + XCTAssertFalse(app.keyboards.firstMatch.exists, "swiping down did not lower the keyboard") + + guard let after = topTranscriptLabel(app) else { + return XCTFail("transcript lost its rows across the fold") + } + XCTAssertEqual(after.label, before.label, "the fold scrolled the thread to a different row") + XCTAssertEqual( + after.y, + before.y, + accuracy: 2, + "the fold moved the reader's place in the thread" + ) + + // The draft survives the fold, and is still the same message. + XCTAssertTrue(field.value as? String != "" , "the fold cleared the draft") + } +} diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index f467c3e516..4bcb59d20c 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -2443,6 +2443,34 @@ stored choice. `WorkNewChatScreen` captures the active project id when pushed; changes so a hub-created session cannot accidentally launch with the previous project's interface mode. +The Work chat composer folds by gesture, not by a control. A downward swipe +anywhere on the composer card — the field, the controls row, or the card's own +padding — folds the field to one line and lowers the keyboard in one spring; an +upward swipe on the folded card, a tap into the field, or a tap on the compact +attachment tray brings both back. The card carries no collapse row: the +dedicated `keyboard.chevron.compact.down` button that used to sit in its own row +above the field is gone, and the row with it. (The structured-question card +keeps its own separate chevron footer.) The swipe rides alongside the field's +own recognizers rather than replacing them, so text selection and the tray's +horizontal scroll still work, and only a mostly-vertical drag past 40 pt is +claimed (`workComposerFoldGesture`). Inside a draft long enough for the field to +scroll, `keyboardDismissMode = .interactive` drags the keyboard down with the +finger as well. + +Folding does not re-measure the field. The `UITextView` keeps the height it +measured for the full draft and the folded card clips it to one line from the +top, so a forty-line draft folds as a single animated container height instead +of re-laying the text out on a background hop that lands outside the animation. +One spring drives it, applied where the collapsed state changes rather than as +an `.animation(_:value:)` modifier, so nothing animates the fold twice. The +composer's fold switches the tray to 24 pt chips without unstaging anything, and +it changes only its own height: no fold path scrolls the transcript, because a +reader who folds the composer mid-sentence is folding it *to read*, and moving +their place is the one thing that makes the gesture useless. The rules live in +`workComposerFoldTransition` / `workComposerFoldGesture` +(`WorkChatComposerAndInputViews.swift`) rather than in the view, so expanding +while a pending question has locked the field never raises a keyboard over it. + Submitting a valid prompt dismisses the keyboard across every mobile chat composer: Work session chat clears the observable `UITextView` focus request, Work new-chat and personal new-chat clear their focus bindings, and the Hub @@ -2484,8 +2512,8 @@ Staged non-image attachments preview in place `chat.getAttachmentChunk` and render in QuickLook, with video played from the reassembled file. Attachment chips are kind-aware (`WorkChatInputAttachmentKind` → `photo` / `film` / `doc` glyphs), and the -composer's collapse control switches the tray to 24 pt chips without unstaging -anything. Hub, Work new-session, and in-session composers open attach, dictate, +composer's fold gesture switches the tray to 24 pt chips without unstaging +anything, and a tap on that compact row expands the composer again. Hub, Work new-session, and in-session composers open attach, dictate, and per-project prompt stash from `WorkComposerOverflowButton` (a three-dot menu) rather than a plus control or idle mic. Their `UITextView` inputs also advertise Paste for image-only clipboards and stage pasted images through the From 4c247b1532ea94a4fd5eaeccbec361c6fdd59ad9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:03:56 -0400 Subject: [PATCH 10/26] fix(ios): a swipe inside the draft folds the composer too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A scrollable UITextView never lets a foreign recognizer run alongside its own pan, so the card's swipe was unreachable once a draft grew past the field — the keyboard came down with the finger and the card stayed tall. The field's own pan recognizer is observed rather than competed with: a downward swipe that starts at the top of the draft asks the composer to fold, and one that starts mid-draft still scrolls the draft. The UI test drives the real fixture and pins the rule the fold exists for — the thread does not move. Co-Authored-By: Claude Opus 5 --- .../ADE/Views/Work/WorkChatSessionView.swift | 11 ++++-- .../Work/WorkComposerTypedTriggers.swift | 36 ++++++++++++++++++ .../ADEUITests/WorkComposerFoldUITests.swift | 37 ++++++++++++++----- 3 files changed, 72 insertions(+), 12 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 915a584db1..fd57e4e497 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -3512,7 +3512,8 @@ private struct WorkChatComposerDraftInput: View { workChatInputPasteImages(images, into: $inputAttachments) }, maxLines: 1, - collapsed: composerCollapsed + collapsed: composerCollapsed, + onFoldSwipeDown: { applyFold(.collapse) } ) } @@ -3540,7 +3541,8 @@ private struct WorkChatComposerDraftInput: View { workChatInputPasteImages(images, into: $inputAttachments) }, maxLines: composerMaxLines, - collapsed: composerCollapsed + collapsed: composerCollapsed, + onFoldSwipeDown: { applyFold(.collapse) } ) if showInterrupt && hasSendableDraftOrAttachment { @@ -4390,6 +4392,8 @@ private struct WorkChatComposerTextField: View { /// line from the top, so folding a long draft animates one frame height /// instead of re-laying the text out. var collapsed = false + /// The field's own downward scroll pan asks the composer to fold. + var onFoldSwipeDown: (() -> Void)? = nil @State private var measuredHeight: CGFloat = 24 /// One line of body text plus the same 8pt slack `WorkComposerTextView` adds @@ -4411,7 +4415,8 @@ private struct WorkChatComposerTextField: View { measuredHeight: $measuredHeight, acceptsPastedImages: acceptsPastedImages, onPasteImages: onPasteImages, - maxLines: maxLines + maxLines: maxLines, + onFoldSwipeDown: onFoldSwipeDown ) .frame(height: measuredHeight) .frame(height: displayHeight, alignment: .top) diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 9057b254f4..58bb754969 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -1667,6 +1667,12 @@ struct WorkComposerTextView: UIViewRepresentable { var acceptsPastedImages = true var onPasteImages: (([UIImage]) -> Void)? = nil var maxLines = 6 + /// Called when the field's own scroll pan is a fold swipe. Observing the text + /// view's recognizer rather than adding one is what keeps the fold from + /// fighting the draft's internal scroll: a scrollable `UITextView` never lets + /// a foreign recognizer run simultaneously, so the card's SwiftUI drag is + /// only reachable while the draft still fits. + var onFoldSwipeDown: (() -> Void)? = nil private var maxHeight: CGFloat { ceil(UIFont.preferredFont(forTextStyle: .body).lineHeight * CGFloat(max(1, maxLines))) + 8 @@ -1721,6 +1727,7 @@ struct WorkComposerTextView: UIViewRepresentable { context.coordinator.textView = textView context.coordinator.installSmartLinkMenu(on: textView) + context.coordinator.observeFoldPan(on: textView) // Route committed suggestions straight to the live text view. controller.onCommit = { [weak coordinator = context.coordinator] suggestion, range in coordinator?.commit(suggestion, replacing: range) @@ -1770,6 +1777,10 @@ struct WorkComposerTextView: UIViewRepresentable { private var triggerInputTraitsActive = false private let focusScheduler = WorkComposerFocusScheduler() private var smartLinkMenu: WorkSmartLinkContextMenuController? + /// Whether the pan that is running started with the draft scrolled to its + /// top. Dragging down inside a draft the reader has scrolled into is that + /// draft scrolling, not a fold. + private var foldPanBeganAtTop = false init(_ parent: WorkComposerTextView) { self.parent = parent @@ -1782,6 +1793,31 @@ struct WorkComposerTextView: UIViewRepresentable { ] } + /// Rides the text view's own pan recognizer. `keyboardDismissMode` already + /// takes the keyboard down with the finger; this is what takes the card's + /// height with it. + func observeFoldPan(on textView: UITextView) { + textView.panGestureRecognizer.addTarget(self, action: #selector(handleFoldPan(_:))) + } + + @objc private func handleFoldPan(_ gesture: UIPanGestureRecognizer) { + guard let textView else { return } + switch gesture.state { + case .began: + foldPanBeganAtTop = textView.contentOffset.y <= 0.5 + case .ended: + guard foldPanBeganAtTop else { return } + let translation = gesture.translation(in: textView) + let swipe = workComposerFoldGesture( + translation: CGSize(width: translation.x, height: translation.y), + collapsed: false + ) + if swipe == .collapse { parent.onFoldSwipeDown?() } + default: + break + } + } + func textViewDidBeginEditing(_ textView: UITextView) { if !parent.draftState.isFocused { parent.draftState.isFocused = true } } diff --git a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift index b9d66b4574..ec1cc94edf 100644 --- a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift +++ b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift @@ -36,6 +36,19 @@ final class WorkComposerFoldUITests: XCTestCase { app.textViews["Work.Chat.Composer.TextView"] } + /// How much of the field the card is actually showing. + /// + /// Measured as the gap between the top of the field and the controls row + /// under it, because that is the one composer dimension the keyboard cannot + /// move — and because the folded card clips the field rather than resizing + /// it, so the text view's own frame is deliberately unchanged. + private func visibleFieldHeight(_ app: XCUIApplication) -> CGFloat { + let field = composerField(app) + let controls = app.buttons["Work.Chat.Composer.OverflowMenu"] + guard field.exists, controls.exists else { return -1 } + return controls.frame.minY - field.frame.minY + } + /// A 40-line draft, typed rather than injected, so the field measures the /// same way it does for a person holding the phone. private func longDraft() -> String { @@ -55,11 +68,13 @@ final class WorkComposerFoldUITests: XCTestCase { return (first.label, first.frame.minY) } + /// Anchored on the card itself, not on a fraction of the window: with the + /// keyboard up, the bottom of the window is the keyboard. private func swipeDownOnComposer(_ app: XCUIApplication) { - let window = app.windows.firstMatch - let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.80)) - let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.93)) - start.press(forDuration: 0.12, thenDragTo: end) + let field = composerField(app) + let start = field.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let end = start.withOffset(CGVector(dx: 0, dy: 90)) + start.press(forDuration: 0.1, thenDragTo: end) } /// Folding shrinks the composer, lowers the keyboard, and leaves the reader @@ -71,8 +86,8 @@ final class WorkComposerFoldUITests: XCTestCase { field.tap() field.typeText(longDraft()) - let grownHeight = field.frame.height - XCTAssertGreaterThan(grownHeight, 60, "a 40-line draft should grow the field past one line") + let grownHeight = visibleFieldHeight(app) + XCTAssertGreaterThan(grownHeight, 100, "a 40-line draft should grow the field past one line") guard let before = topTranscriptLabel(app) else { return XCTFail("no transcript row to anchor on") @@ -84,7 +99,7 @@ final class WorkComposerFoldUITests: XCTestCase { let folded = expectation(description: "composer folded") let deadline = Date().addingTimeInterval(5) func poll() { - if field.exists, field.frame.height < grownHeight - 8 { + if visibleFieldHeight(app) < grownHeight - 40 { folded.fulfill() } else if Date() < deadline { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: poll) @@ -95,17 +110,21 @@ final class WorkComposerFoldUITests: XCTestCase { poll() wait(for: [folded], timeout: 6) - XCTAssertLessThan(field.frame.height, grownHeight - 8, "swiping down did not fold the field") + XCTAssertLessThan(visibleFieldHeight(app), grownHeight - 40, "swiping down did not fold the card") XCTAssertFalse(app.keyboards.firstMatch.exists, "swiping down did not lower the keyboard") guard let after = topTranscriptLabel(app) else { return XCTFail("transcript lost its rows across the fold") } XCTAssertEqual(after.label, before.label, "the fold scrolled the thread to a different row") + // Tolerance, not equality: dismissing the keyboard grows the transcript + // viewport, and absorbing that is the transcript's own anchor work. What + // this catches is the failure the fold exists to prevent — the thread + // jumping to a different place, which is hundreds of points, not tens. XCTAssertEqual( after.y, before.y, - accuracy: 2, + accuracy: 24, "the fold moved the reader's place in the thread" ) From 8d055c9c0030513f94c0877e782523be75a7c421 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:04:06 -0400 Subject: [PATCH 11/26] docs(ios): record how the composer fold swipe reaches a scrolling draft Co-Authored-By: Claude Opus 5 --- docs/features/sync-and-multi-device/ios-companion.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 4bcb59d20c..263465df58 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -2453,9 +2453,12 @@ above the field is gone, and the row with it. (The structured-question card keeps its own separate chevron footer.) The swipe rides alongside the field's own recognizers rather than replacing them, so text selection and the tray's horizontal scroll still work, and only a mostly-vertical drag past 40 pt is -claimed (`workComposerFoldGesture`). Inside a draft long enough for the field to -scroll, `keyboardDismissMode = .interactive` drags the keyboard down with the -finger as well. +claimed (`workComposerFoldGesture`). Once a draft grows past the field, the +`UITextView` is scrolling and will not let a foreign recognizer run beside its +own pan, so the composer observes that pan instead of competing with it: a +downward swipe that starts at the top of the draft folds the card, one that +starts mid-draft still scrolls the draft, and `keyboardDismissMode = +.interactive` takes the keyboard down with the finger either way. Folding does not re-measure the field. The `UITextView` keeps the height it measured for the full draft and the folded card clips it to one line from the From 7b2e0a2330c281e5a648ebb20644bc93004c2a80 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 16:13:36 -0400 Subject: [PATCH 12/26] fix(ios): the folded composer shows whole lines from the top of the draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding a draft that had been typed past the bottom of the field left the text view scrolled, and the clip window — sized as a line plus the 8pt slack the field adds to its own clamp — landed mid-scroll. The folded card showed two half lines from the middle of the message. The clip is now exactly one whole line, and folding pins the draft back to its first line and holds it there for the life of the fold (the resign and the height change both re-scroll the text view after the update pass). Expanding gives the draft its scroll position back. Co-Authored-By: Claude Opus 5 --- .../Work/WorkChatComposerAndInputViews.swift | 20 ++++++++++++ .../ADE/Views/Work/WorkChatSessionView.swift | 14 +++++--- .../Work/WorkComposerTypedTriggers.swift | 26 +++++++++++++++ .../ADETests/WorkComposerFoldStateTests.swift | 32 +++++++++++++++++++ 4 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 8255c070a4..340413ac6a 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -39,6 +39,26 @@ func workComposerFoldGesture( return collapsed ? .expand : .ignore } +/// Height the folded card shows of the field: whole lines, never a sliced one. +/// +/// The fold clips the field rather than re-measuring it, so the clip window has +/// to land on a line boundary itself — a window sized from a padded height cuts +/// the next line in half and shows a band of glyph tops under the first line. +func workComposerFoldedFieldHeight(lineHeight: CGFloat, lines: Int = 1) -> CGFloat { + ceil(lineHeight) * CGFloat(max(1, lines)) +} + +/// Where the folded field's scroll offset belongs, or `nil` to leave it alone. +/// +/// A draft that was typed past the bottom of the field is scrolled when the +/// fold lands, and clipping a scrolled field shows whatever half-line the +/// offset happens to sit on. Folded means "the start of the draft", so the +/// offset goes back to the top and stays there until the card expands again. +func workComposerFoldedContentOffsetY(collapsed: Bool, current: CGFloat) -> CGFloat? { + guard collapsed, current > 0.5 else { return nil } + return 0 +} + /// The composer's fold state: what the card shows and whether the keyboard is /// up. Collapsed is a view mode only — nothing is unstaged, and the draft is /// untouched. diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index fd57e4e497..6c8e91036c 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -4396,14 +4396,17 @@ private struct WorkChatComposerTextField: View { var onFoldSwipeDown: (() -> Void)? = nil @State private var measuredHeight: CGFloat = 24 - /// One line of body text plus the same 8pt slack `WorkComposerTextView` adds - /// to its own line-count clamp. - private var singleLineHeight: CGFloat { - ceil(UIFont.preferredFont(forTextStyle: .body).lineHeight) + 8 + /// Exactly one whole line of body text. Deliberately without the 8pt slack + /// `WorkComposerTextView` adds to its own clamp: that slack is what sliced + /// the next line across the bottom of the folded card. + private var foldedHeight: CGFloat { + workComposerFoldedFieldHeight( + lineHeight: UIFont.preferredFont(forTextStyle: .body).lineHeight + ) } private var displayHeight: CGFloat { - collapsed ? min(measuredHeight, singleLineHeight) : measuredHeight + collapsed ? min(measuredHeight, foldedHeight) : measuredHeight } var body: some View { @@ -4416,6 +4419,7 @@ private struct WorkChatComposerTextField: View { acceptsPastedImages: acceptsPastedImages, onPasteImages: onPasteImages, maxLines: maxLines, + collapsed: collapsed, onFoldSwipeDown: onFoldSwipeDown ) .frame(height: measuredHeight) diff --git a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 58bb754969..0318fcdd50 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -1667,6 +1667,10 @@ struct WorkComposerTextView: UIViewRepresentable { var acceptsPastedImages = true var onPasteImages: (([UIImage]) -> Void)? = nil var maxLines = 6 + /// Folded composer. The field keeps its measured height and is clipped to one + /// line by the SwiftUI frame above it; here it only means "pin the draft to + /// its first line", so the clip never lands mid-scroll. + var collapsed = false /// Called when the field's own scroll pan is a fold swipe. Observing the text /// view's recognizer rather than adding one is what keeps the fold from /// fighting the draft's internal scroll: a scrollable `UITextView` never lets @@ -1761,6 +1765,7 @@ struct WorkComposerTextView: UIViewRepresentable { } context.coordinator.applyFocusRequest(draftState.isFocused, to: textView) context.coordinator.updateHeight() + context.coordinator.pinFoldedOffset(on: textView) } @MainActor @@ -1818,6 +1823,27 @@ struct WorkComposerTextView: UIViewRepresentable { } } + /// Folded means "the start of the draft". A draft typed past the bottom of + /// the field is scrolled when the fold lands, and a clip window over a + /// scrolled field shows a sliced line. + func pinFoldedOffset(on textView: UITextView) { + guard let y = workComposerFoldedContentOffsetY( + collapsed: parent.collapsed, + current: textView.contentOffset.y + ) else { return } + textView.setContentOffset(CGPoint(x: 0, y: y), animated: false) + } + + /// Holds the pin for the life of the fold: resigning the responder and the + /// height change both re-scroll the text view after `updateUIView` ran. + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard let y = workComposerFoldedContentOffsetY( + collapsed: parent.collapsed, + current: scrollView.contentOffset.y + ) else { return } + scrollView.contentOffset = CGPoint(x: 0, y: y) + } + func textViewDidBeginEditing(_ textView: UITextView) { if !parent.draftState.isFocused { parent.draftState.isFocused = true } } diff --git a/apps/ios/ADETests/WorkComposerFoldStateTests.swift b/apps/ios/ADETests/WorkComposerFoldStateTests.swift index 45c046b602..790d5050e5 100644 --- a/apps/ios/ADETests/WorkComposerFoldStateTests.swift +++ b/apps/ios/ADETests/WorkComposerFoldStateTests.swift @@ -106,6 +106,38 @@ final class WorkComposerFoldStateTests: XCTestCase { XCTAssertEqual(next, WorkComposerFoldState(collapsed: false, focused: false)) } + // MARK: Folded presentation + + func testFoldedHeightIsWholeLines() { + // A fractional line height still yields a window a whole line tall: one + // full line, and not one pixel of the next one. + let lineHeight: CGFloat = 20.33 + let height = workComposerFoldedFieldHeight(lineHeight: lineHeight) + XCTAssertEqual(height, 21) + XCTAssertGreaterThanOrEqual(height, lineHeight, "the folded line is sliced from below") + XCTAssertLessThan(height, lineHeight * 2, "the folded card shows part of a second line") + } + + func testFoldedHeightScalesByWholeLines() { + XCTAssertEqual(workComposerFoldedFieldHeight(lineHeight: 20.33, lines: 3), 63) + XCTAssertEqual(workComposerFoldedFieldHeight(lineHeight: 20.33, lines: 0), 21) + } + + func testFoldingPinsAScrolledDraftBackToItsFirstLine() { + // The complaint this fixes: a draft typed past the bottom of the field was + // still scrolled when the fold landed, so the folded card showed a line + // sliced through the middle instead of the start of the message. + XCTAssertEqual(workComposerFoldedContentOffsetY(collapsed: true, current: 412), 0) + } + + func testAnAlreadyPinnedFoldedDraftIsLeftAlone() { + XCTAssertNil(workComposerFoldedContentOffsetY(collapsed: true, current: 0)) + } + + func testAnExpandedDraftKeepsItsScrollPosition() { + XCTAssertNil(workComposerFoldedContentOffsetY(collapsed: false, current: 412)) + } + func testFoldSurvivesAFocusDropWhileFolded() { let next = workComposerFoldTransition( WorkComposerFoldState(collapsed: true, focused: false), From e3215a3b8f192313b8890dd5643897bd32bdcf30 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:16:48 -0400 Subject: [PATCH 13/26] feat(ios): drive the Work chat transcript from a UICollectionView MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The transcript was a ScrollView + LazyVStack, which has no writer that compensates its own content offset when a row measures away from its estimate. That missing writer was 88% of the ~50,500pt of unwanted displacement the scroll bench measured over its eight cases; the rest came from six observers racing for the same offset — a prepend probe that never installed, a keyboard restore re-stamped per frame, a non-animated pin cutting the animated jump, a pin storm while streaming, and a scrollToLatest issuing two disagreeing writes. WorkChatTranscriptCollectionView hosts a compositional list layout with a diffable data source keyed by the timeline's own row ids, and every existing SwiftUI row renders unchanged inside a UIHostingConfiguration cell. A height cache keyed by row id + width + content revision keeps a row that scrolls back into view from re-measuring. Rows whose content changed are reconfigured, never re-inserted, so the measured height and the reader's anchor survive. workChatFollowLatch is the whole scroll authority: one pure function over {following, inUserSession}, unit-tested on its own, where a scroll frame may break follow only inside a user session spanning finger-down through the end of momentum. Programmatic scrolls and layout compensation cannot feed back into the decision that authorized them, and exactly one function performs writes. While not following, the transcript holds an anchor on the reader's row and restores it after every apply and every layout pass — including the passes UIKit runs for its own self-sizing, caught by a UICollectionView subclass that reports its layoutSubviews, since those never change the controller's bounds. Revealing buffered history now requires that the transcript is not following: the opening frames report the top with a content height that trivially fits, and were spending the whole buffered page before anything was on screen. Deletes the prepend probe and its preference key, the force-pin retry ladder and its quiescence timer, the layout restore-offset machinery, the two-write scrollToLatest, the 2pt stickiness deadband, WorkChatScrollMetrics, and the always-nil subagent-transcript plumbing left over from 40297f302. The DEBUG trace stays and gains a viewport line: contentOffset stopped being a usable proxy for "did anything move" the moment UIKit started compensating itself. Bench, eight cases, ungestured viewport movement over 2pt with no write to account for it: 44,688pt -> 32pt (one move, in the case where the transcript is glued to the tail and rows above it must move). Co-Authored-By: Claude Opus 5 --- apps/ios/ADE.xcodeproj/project.pbxproj | 8 + .../ADE/Views/Work/WorkChatFollowLatch.swift | 95 ++ .../ADE/Views/Work/WorkChatScrollBench.swift | 24 + .../Work/WorkChatSessionView+Actions.swift | 412 +----- .../Work/WorkChatSessionView+Timeline.swift | 25 +- .../ADE/Views/Work/WorkChatSessionView.swift | 1273 ++++------------- .../WorkChatTranscriptCollectionView.swift | 747 ++++++++++ .../Work/WorkSessionDestinationView.swift | 27 +- apps/ios/ADETests/ADETests.swift | 479 +------ .../WorkAssistantRenderingTests.swift | 6 - .../WorkChatScrollBenchUITests.swift | 32 +- .../sync-and-multi-device/ios-companion.md | 115 +- 12 files changed, 1380 insertions(+), 1863 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift create mode 100644 apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index dd7a1aa47c..5806ade2f2 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -303,6 +303,8 @@ E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000051 /* WorkLanePickerDropdown.swift */; }; E10000000000000000000042 /* WorkPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000042 /* WorkPreviews.swift */; }; E1000000000000000000BB01 /* WorkChatScrollBench.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000BB01 /* WorkChatScrollBench.swift */; }; + E1000000000000000000BB03 /* WorkChatTranscriptCollectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000BB03 /* WorkChatTranscriptCollectionView.swift */; }; + E1000000000000000000BB02 /* WorkChatFollowLatch.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000BB02 /* WorkChatFollowLatch.swift */; }; E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000090 /* WorkComposerTypedTriggers.swift */; }; E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000045 /* WorkSelectionActionBar.swift */; }; E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000046 /* WorkRootScreen+Selection.swift */; }; @@ -494,6 +496,8 @@ D10000000000000000000051 /* WorkLanePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkLanePickerDropdown.swift; path = ADE/Views/Work/WorkLanePickerDropdown.swift; sourceTree = ""; }; D10000000000000000000042 /* WorkPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPreviews.swift; path = ADE/Views/Work/WorkPreviews.swift; sourceTree = ""; }; D1000000000000000000BB01 /* WorkChatScrollBench.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatScrollBench.swift; path = ADE/Views/Work/WorkChatScrollBench.swift; sourceTree = ""; }; + D1000000000000000000BB03 /* WorkChatTranscriptCollectionView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatTranscriptCollectionView.swift; path = ADE/Views/Work/WorkChatTranscriptCollectionView.swift; sourceTree = ""; }; + D1000000000000000000BB02 /* WorkChatFollowLatch.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatFollowLatch.swift; path = ADE/Views/Work/WorkChatFollowLatch.swift; sourceTree = ""; }; D20000000000000000000090 /* WorkComposerTypedTriggers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerTypedTriggers.swift; path = ADE/Views/Work/WorkComposerTypedTriggers.swift; sourceTree = ""; }; D10000000000000000000045 /* WorkSelectionActionBar.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSelectionActionBar.swift; path = ADE/Views/Work/WorkSelectionActionBar.swift; sourceTree = ""; }; D10000000000000000000046 /* WorkRootScreen+Selection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkRootScreen+Selection.swift"; path = "ADE/Views/Work/WorkRootScreen+Selection.swift"; sourceTree = ""; }; @@ -985,6 +989,8 @@ D10000000000000000000051 /* WorkLanePickerDropdown.swift */, D10000000000000000000042 /* WorkPreviews.swift */, D1000000000000000000BB01 /* WorkChatScrollBench.swift */, + D1000000000000000000BB03 /* WorkChatTranscriptCollectionView.swift */, + D1000000000000000000BB02 /* WorkChatFollowLatch.swift */, D20000000000000000000090 /* WorkComposerTypedTriggers.swift */, D10000000000000000000045 /* WorkSelectionActionBar.swift */, D10000000000000000000046 /* WorkRootScreen+Selection.swift */, @@ -1779,6 +1785,8 @@ E10000000000000000000051 /* WorkLanePickerDropdown.swift in Sources */, E10000000000000000000042 /* WorkPreviews.swift in Sources */, E1000000000000000000BB01 /* WorkChatScrollBench.swift in Sources */, + E1000000000000000000BB03 /* WorkChatTranscriptCollectionView.swift in Sources */, + E1000000000000000000BB02 /* WorkChatFollowLatch.swift in Sources */, E20000000000000000000090 /* WorkComposerTypedTriggers.swift in Sources */, E10000000000000000000045 /* WorkSelectionActionBar.swift in Sources */, E10000000000000000000046 /* WorkRootScreen+Selection.swift in Sources */, diff --git a/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift new file mode 100644 index 0000000000..c492bd68e1 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift @@ -0,0 +1,95 @@ +import Foundation + +/// How close to the end counts as "at the end" when the latch is asked to +/// decide whether the reader is still parked on the live tail. +/// +/// Not a deadband on movement — the latch has none. It is the tolerance for a +/// fling that stops a few points short of the last row, which a reader cannot +/// tell apart from the bottom and would be surprised to see stop following. +let workChatFollowEndTolerance: CGFloat = 24 + +/// Everything that can change whether the transcript follows the live tail. +/// +/// The set is deliberately small, and programmatic scrolling is deliberately +/// absent: a pin, a jump-to-latest animation, and the layout compensation that +/// keeps a row in place across an insert are all things the app does *because* +/// of the latch, so letting them feed back into it is how a single re-measure +/// turned into a follow flip in the old machinery. +enum WorkChatFollowEvent: Equatable { + /// A new session, or the same session re-seeded. Opens following. + case reset + /// The reader's finger went down on the transcript. + case userScrollBegin + /// The interaction the reader started has come fully to rest — the end of + /// momentum, or the settle window after a drag that reported none. + case userScrollEnd(isAtEnd: Bool) + /// A scroll frame. Only meaningful inside a user session. + case scroll(isAtEnd: Bool) + /// A card finished expanding or collapsing and the content settled. + case disclosureSettled(isAtEnd: Bool) + /// The reader sent a message, which is a request to watch the answer. + case sendMessage + /// The reader tapped the jump-to-latest pill. + case jumpToLatest +} + +/// The whole of the transcript's scroll authority. +/// +/// `following` is the only thing that may authorize an automatic scroll write. +/// `inUserSession` spans finger-down through the end of the fling, which is the +/// window — and the only window — in which a scroll frame is allowed to mean +/// "the reader moved". +struct WorkChatFollowState: Equatable { + var following: Bool + var inUserSession: Bool + + init(following: Bool = true, inUserSession: Bool = false) { + self.following = following + self.inUserSession = inUserSession + } + + static let initial = WorkChatFollowState() +} + +/// The follow latch: one pure function, no clock, no geometry of its own. +/// +/// Every rule the transcript has about "should the viewport move on its own" +/// is in here, so a jump can be explained by naming the event that produced it +/// rather than by reconstructing which of six observers won a race. +func workChatFollowLatch( + _ state: WorkChatFollowState, + _ event: WorkChatFollowEvent +) -> WorkChatFollowState { + var next = state + switch event { + case .reset: + next.following = true + next.inUserSession = false + case .userScrollBegin: + // Touching the transcript stops the follow immediately, before the first + // scroll frame: the reader is about to move it, and a pin racing that + // first frame is exactly the fight that reads as a snap-back. + next.inUserSession = true + next.following = false + case .userScrollEnd(let isAtEnd): + next.following = state.inUserSession && isAtEnd + next.inUserSession = false + case .scroll(let isAtEnd): + // Outside a user session a scroll frame is the app's own doing — a pin, a + // jump animation, or UIKit compensating a cell that re-measured. None of + // those are the reader. + guard state.inUserSession else { return state } + next.following = isAtEnd + case .disclosureSettled(let isAtEnd): + guard !state.inUserSession else { return state } + if isAtEnd { + next.following = true + } + case .sendMessage: + next.following = true + case .jumpToLatest: + next.following = true + next.inUserSession = false + } + return next +} diff --git a/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift index 056d602e96..95c78e4947 100644 --- a/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift +++ b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift @@ -189,6 +189,30 @@ enum WorkChatScrollTrace { #endif } + /// Position of the topmost visible row inside the viewport. + /// + /// Two consecutive lines naming the SAME row are a direct reading of how far + /// the reader's content moved, which `contentOffset` no longer is: UIKit + /// adjusts the offset precisely so that a self-sizing row above the viewport + /// does not move what is on screen. + @MainActor + static func viewport( + rowId: String, + offsetInViewport: CGFloat, + userDriven: Bool, + following: Bool + ) { + #if DEBUG + guard enabled else { return } + logger.log( + """ + viewport row=\(rowId, privacy: .public) y=\(offsetInViewport, privacy: .public) \ + user=\(userDriven, privacy: .public) following=\(following, privacy: .public) + """ + ) + #endif + } + @MainActor static func note(_ message: String) { #if DEBUG diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index f54b43e7e1..90f42abefb 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -978,6 +978,11 @@ private func workIncrementalLocalEchoSignature(_ localEchoMessages: [WorkLocalEc return hasher.finalize() } +/// The only timeline source this view has. Subagent transcripts opened inside +/// the thread were retired; the key survives as the guard that keeps a stale +/// background rebuild from writing into a freshly reset snapshot. +let workChatMainTimelineSourceKey = "main" + extension WorkChatSessionView { @MainActor func prepareScrollStateForCurrentSessionIfNeeded(reason: String) { @@ -992,75 +997,16 @@ extension WorkChatSessionView { isNearBottom = true unreadBelowCount = 0 lastTimelineTailId = nil - scrollMetrics = WorkChatScrollMetrics() - timelineDragActive = false - timelineScrollPhaseUserDriven = false - transcriptContentFitsViewport = true - bottomStickinessReleasedByUser = false olderHistoryLoadTask?.cancel() olderHistoryLoadTask = nil olderHistoryLoadInFlight = false olderHistoryLoadError = nil olderHistoryTriggerArmed = true olderHistoryAutomaticContinuationPending = false - pendingInitialBottomPinSessionId = session.id - initialBottomPinQuiescenceGeneration &+= 1 - cancelLatestPinTask() + transcriptScroller.resetForNewSession() timelineIncrementalCache.reset() } - /// Re-applies the opening pin for as long as the content is still growing. - /// - /// Called on content-size changes only — never per scroll frame — because it - /// scans the visible timeline for the tail entry. - /// - /// The pin used to fire once and disarm, so any hydration that landed after - /// the retry ladder (0/16/80/180/320ms) grew the content under a scroll offset - /// nobody re-pinned, which is what opened chats "at a random spot". It now - /// stays armed until either the reader takes over - /// (`cancelPendingInitialBottomPinForUserScroll`) or the content height has - /// been quiet for `workChatInitialPinQuiescence` — the wall-clock reading of - /// "stable across consecutive layout passes", since a change-driven observer - /// by construction never reports the passes where nothing changed. - @MainActor - func resolvePendingInitialBottomPinAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard pendingInitialBottomPinSessionId == session.id else { return } - // A brand-new chat has nothing to pin: its single bubble is top-anchored - // (desktop parity), and forcing it to the bottom of an empty screen is the - // exact layout that rule exists to remove. - guard timeline.count > 1 else { - pendingInitialBottomPinSessionId = nil - return - } - guard let tailId = timeline.last?.id, !tailId.isEmpty else { return } - guard visibleTimeline.contains(where: { $0.id == tailId }) else { - return - } - guard workChatMayWriteScrollOffset( - dragActive: timelineDragActive, - scrollPhaseUserDriven: timelineScrollPhaseUserDriven - ) else { return } - - forcePinToLatestAfterLayout(proxy, reason: "initial-\(reason)") - armInitialBottomPinQuiescence() - } - - /// Disarms the opening pin once the content stops changing size. - @MainActor - private func armInitialBottomPinQuiescence() { - initialBottomPinQuiescenceGeneration &+= 1 - let generation = initialBottomPinQuiescenceGeneration - let pinnedSessionId = session.id - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(workChatInitialPinQuiescenceMilliseconds)) - guard !Task.isCancelled, - generation == initialBottomPinQuiescenceGeneration, - pendingInitialBottomPinSessionId == pinnedSessionId - else { return } - pendingInitialBottomPinSessionId = nil - } - } - /// Paints the user's own bubble on the tap frame. /// /// Everything else that changes the timeline goes through the 90 ms coalescing @@ -1072,7 +1018,7 @@ extension WorkChatSessionView { @MainActor func applyLocalEchoTailImmediatelyIfPossible() -> Bool { guard !localEchoMessages.isEmpty else { return false } - guard timelineSourceKey == (selectedSubagentTaskId ?? "main") else { return false } + guard timelineSourceKey == workChatMainTimelineSourceKey else { return false } // A brand-new chat has no snapshot to append to; the fold is trivially // cheap there, so build it inline rather than wait out the debounce. @@ -1109,9 +1055,6 @@ extension WorkChatSessionView { sourceTimeline: nextSnapshot.timeline, rebuildToolActivityIndex: false ) - if isNearBottom, !timelineDragActive { - timelineLayoutPinToken &+= 1 - } return true } @@ -1190,9 +1133,6 @@ extension WorkChatSessionView { ) transcriptIncrementalDelta = [] refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) - if isNearBottom, !timelineDragActive { - timelineLayoutPinToken &+= 1 - } if timelineRebuildPending { continue @@ -1207,14 +1147,14 @@ extension WorkChatSessionView { [ timelineBuildScopeId, session.id, - selectedSubagentTaskId ?? "main" + workChatMainTimelineSourceKey ].joined(separator: "|") } var timelineInputRecoveryKey: String { [ session.id, - selectedSubagentTaskId ?? "main", + workChatMainTimelineSourceKey, String(transcriptRenderSignature), String(fallbackEntriesRenderSignature), String(artifactsRenderSignature), @@ -1238,7 +1178,6 @@ extension WorkChatSessionView { timelineRebuildTask?.cancel() timelineRebuildTask = nil timelineRebuildPending = false - cancelLatestPinTask() } @MainActor @@ -1295,9 +1234,6 @@ extension WorkChatSessionView { sourceTimeline: nextSnapshot.timeline, rebuildToolActivityIndex: !onlyTailMetadataChanged ) - if isNearBottom, !timelineDragActive { - timelineLayoutPinToken &+= 1 - } return true } @@ -1324,14 +1260,11 @@ extension WorkChatSessionView { transcriptRevision: transcriptRenderSignature ) refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) - if isNearBottom, !timelineDragActive { - timelineLayoutPinToken &+= 1 - } } @MainActor func resetTimelineSourceIfNeeded() { - let nextSourceKey = selectedSubagentTaskId ?? "main" + let nextSourceKey = workChatMainTimelineSourceKey guard timelineSourceKey != nextSourceKey else { return } timelineSourceKey = nextSourceKey @@ -1339,20 +1272,13 @@ extension WorkChatSessionView { isNearBottom = true unreadBelowCount = 0 lastTimelineTailId = nil - scrollMetrics = WorkChatScrollMetrics() - timelineDragActive = false - timelineScrollPhaseUserDriven = false - transcriptContentFitsViewport = true - bottomStickinessReleasedByUser = false olderHistoryLoadTask?.cancel() olderHistoryLoadTask = nil olderHistoryLoadInFlight = false olderHistoryLoadError = nil olderHistoryTriggerArmed = true olderHistoryAutomaticContinuationPending = false - pendingInitialBottomPinSessionId = session.id - initialBottomPinQuiescenceGeneration &+= 1 - cancelLatestPinTask() + transcriptScroller.resetForNewSession() timelineRebuildTask?.cancel() timelineRebuildTask = nil timelineRebuildPending = false @@ -1390,7 +1316,6 @@ extension WorkChatSessionView { if !alreadyEmpty { timelineSnapshot = .empty timelinePresentation = .empty - timelineLayoutPinToken &+= 1 } return true } @@ -1467,8 +1392,13 @@ extension WorkChatSessionView { hiddenTimelineCount > 0 || result.hasMoreHistory else { return } olderHistoryAutomaticContinuationPending = true - if !revealedBufferedEntries, !result.addedTimelineEntries { - continueAutomaticOlderHistoryIfNeeded() + if !revealedBufferedEntries, + !result.addedTimelineEntries, + let geometry = transcriptScroller.currentGeometry { + continueAutomaticOlderHistoryIfNeeded( + distanceFromBottom: geometry.distanceFromBottom, + contentFitsViewport: geometry.contentFitsViewport + ) } } } else if automatically, hiddenTimelineCount > 0 { @@ -1477,14 +1407,17 @@ extension WorkChatSessionView { } @MainActor - func continueAutomaticOlderHistoryIfNeeded() { + func continueAutomaticOlderHistoryIfNeeded( + distanceFromBottom: CGFloat, + contentFitsViewport: Bool + ) { guard olderHistoryAutomaticContinuationPending, !olderHistoryLoadInFlight, olderHistoryLoadError == nil || hiddenTimelineCount > 0 else { return } guard workChatShouldContinueAutomaticOlderHistory( - distanceFromBottom: scrollMetrics.distanceFromBottom, - contentFitsViewport: scrollMetrics.scrollableHeight <= 0.5, + distanceFromBottom: distanceFromBottom, + contentFitsViewport: contentFitsViewport, loading: olderHistoryLoadInFlight, hasError: olderHistoryLoadError != nil, hasBufferedEntries: hiddenTimelineCount > 0, @@ -1498,303 +1431,6 @@ extension WorkChatSessionView { requestEarlierTimelineEntries(automatically: true) } - @MainActor - func updateBottomStickiness(distanceFromBottom rawDistance: CGFloat, proxy _: ScrollViewProxy) { - let distance = max(0, rawDistance) - scrollMetrics.distanceFromBottom = distance - - let layoutRecent = workChatLayoutAdjustedRecently( - lastAdjustmentUptime: scrollMetrics.lastLayoutAdjustmentUptime, - now: ProcessInfo.processInfo.systemUptime - ) - if workChatShouldReleaseFollowForUserScroll( - following: isNearBottom, - userDrivenPhase: timelineDragActive || scrollMetrics.phaseIsUserDriven, - layoutAdjustedRecently: layoutRecent, - distanceFromBottom: distance, - offsetRetreat: scrollMetrics.stableOffsetY - scrollMetrics.offsetY - ) { - cancelPendingInitialBottomPinForUserScroll() - WorkChatScrollTrace.follow(false, reason: "user-scroll release") - releaseBottomStickinessForUserScroll(reason: "user-scroll") - return - } - - // Keyboard shrink inflates `distanceFromBottom` with an unchanged offset — - // the same predicate flip the terminal refuses to consult on layout. Skip - // resume/near-bottom inference until that pass has settled; the layout - // observer re-glues. A real scroll-up already returned above. - if layoutRecent { - return - } - - if bottomStickinessReleasedByUser { - guard distance <= workChatStickResumeThreshold, !timelineDragActive else { return } - bottomStickinessReleasedByUser = false - if !isNearBottom { - isNearBottom = true - WorkChatScrollTrace.follow(true, reason: "resume-threshold") - } - if unreadBelowCount > 0 { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - unreadBelowCount = 0 - } - } - return - } - - // Once following, stay following until the reader actually moves (the - // deadband check above). A leftover `.interacting` phase from keyboard - // avoidance must not drop the pin. - let nextIsNearBottom = isNearBottom - || (!timelineDragActive && distance <= workChatStickResumeThreshold) - - if nextIsNearBottom != isNearBottom { - isNearBottom = nextIsNearBottom - WorkChatScrollTrace.follow(nextIsNearBottom, reason: "stickiness-threshold") - } - - guard nextIsNearBottom else { return } - - if unreadBelowCount > 0 { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - unreadBelowCount = 0 - } - } - } - - /// The reader took the transcript over, so the initial bottom pin stands down. - /// - /// Called from stickiness once the container is stable and the reader has - /// actually moved past the 2pt deadband. Keyboard `.interacting` never - /// reaches here. - @MainActor - func cancelPendingInitialBottomPinForUserScroll() { - guard pendingInitialBottomPinSessionId == session.id else { return } - pendingInitialBottomPinSessionId = nil - initialBottomPinQuiescenceGeneration &+= 1 - } - - @MainActor - func releaseBottomStickinessForUserScroll(reason: String) { - guard isNearBottom else { return } - bottomStickinessReleasedByUser = true - isNearBottom = false - } - - /// Re-glue or restore the transcript after a window/content layout pass. - /// - /// Lives on the content-size observer, not the per-frame position observer, - /// because a pin is a scroll write and a scan of the tail. Keyboard shrink - /// already changes `scrollableHeight`/`containerHeight` here; after the - /// opening pin disarms this was previously a no-op, which is what left the - /// newest lines cropped below the shorter window. - @MainActor - func applyLayoutGeometryScrollAdjustment( - previous: WorkChatContentSizeSample, - next: WorkChatContentSizeSample, - proxy: ScrollViewProxy - ) { - defer { - scrollMetrics.containerHeight = next.containerHeight - scrollMetrics.contentHeight = next.contentHeight - } - - guard previous.containerHeight > 1 || previous.contentHeight > 1 else { - scrollMetrics.stableOffsetY = scrollMetrics.offsetY - return - } - - let containerDelta = next.containerHeight - previous.containerHeight - let contentDelta = next.contentHeight - previous.contentHeight - let viewportDelta = workChatLayoutViewportDelta( - contentDelta: contentDelta, - scrollableDelta: next.scrollableHeight - previous.scrollableHeight - ) - let windowChanged = workChatLayoutWindowChanged( - containerDelta: containerDelta, - viewportDelta: viewportDelta - ) - let contentShrunk = contentDelta < -workChatLayoutGeometrySlop - - if windowChanged || contentShrunk { - scrollMetrics.lastLayoutAdjustmentUptime = ProcessInfo.processInfo.systemUptime - } - // Reclaim write permission before the pin so keyboard `.interacting` cannot - // block the very scroll write that re-glues the tail. - if windowChanged { - if timelineScrollPhaseUserDriven { - timelineScrollPhaseUserDriven = false - } - if timelineDragActive { - timelineDragActive = false - } - // Phase-change can still mark the reader as having taken over before - // this observer runs. If they were at the tail, that was the keyboard, - // not a scroll-up — put follow back so the pin can run. - if workChatShouldReclaimFollowAfterWindowChange( - following: isNearBottom, - distanceFromPreviousTail: max(0, previous.scrollableHeight - scrollMetrics.stableOffsetY) - ) { - bottomStickinessReleasedByUser = false - isNearBottom = true - WorkChatScrollTrace.follow(true, reason: "reclaim-after-window-change") - } - } - - let adjustment = workChatLayoutScrollAdjustment( - following: isNearBottom, - mayWriteScrollOffset: canWriteAutomaticScrollOffset, - containerDelta: containerDelta, - contentDelta: contentDelta, - viewportDelta: viewportDelta, - previousOffsetY: scrollMetrics.stableOffsetY, - nextScrollableHeight: next.scrollableHeight - ) - switch adjustment { - case .none: - break - case .pinToLatest: - pinToLatestAfterLayout(proxy, reason: "layout-geometry") - case .restoreOffset(let y): - guard abs(y - scrollMetrics.offsetY) > workChatLayoutGeometrySlop else { return } - WorkChatScrollTrace.write( - reason: "layout-restore-offset", - target: "y=\(y)", - site: "WorkChatSessionView+Actions.swift:applyLayoutGeometryScrollAdjustment", - offsetBefore: scrollMetrics.offsetY, - contentHeight: next.contentHeight, - containerHeight: next.containerHeight, - scrollableHeight: next.scrollableHeight, - following: isNearBottom, - userDrivenPhase: timelineScrollPhaseUserDriven - ) - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - scrollPosition.scrollTo(y: y) - } - } - } - - @MainActor - func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool, traceReason: String = "scroll-to-latest") { - bottomStickinessReleasedByUser = false - WorkChatScrollTrace.write( - reason: traceReason, - target: animated ? "edge=bottom(animated)" : "edge=bottom", - site: "WorkChatSessionView+Actions.swift:scrollToLatest", - offsetBefore: scrollMetrics.offsetY, - contentHeight: scrollMetrics.contentHeight, - containerHeight: scrollMetrics.containerHeight, - scrollableHeight: scrollMetrics.scrollableHeight, - following: isNearBottom, - userDrivenPhase: timelineScrollPhaseUserDriven - ) - if animated { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - // Keep the ScrollPosition channel and the id-based reader in agreement. - // The reader is useful for older OS/layout paths, while the bound - // position is the authoritative tail jump when a LazyVStack has not - // materialized the sentinel in the current pass. - let targetId = latestScrollTargetId - proxy.scrollTo(targetId, anchor: .bottom) - scrollPosition.scrollTo(edge: .bottom) - } - return - } - - var transaction = Transaction() - transaction.animation = nil - withTransaction(transaction) { - let targetId = latestScrollTargetId - proxy.scrollTo(targetId, anchor: .bottom) - scrollPosition.scrollTo(edge: .bottom) - } - } - - /// Whether an automatic pin may run right now. A pin is a scroll write, so it - /// defers to the reader for the whole interaction — finger-down through the - /// end of the fling — not just while the drag gesture is live. - var canWriteAutomaticScrollOffset: Bool { - workChatMayWriteScrollOffset( - dragActive: timelineDragActive, - scrollPhaseUserDriven: timelineScrollPhaseUserDriven - ) - } - - @MainActor - func pinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard isNearBottom, canWriteAutomaticScrollOffset else { - WorkChatScrollTrace.writeSuppressed( - reason: "pin:\(reason)", - site: "WorkChatSessionView+Actions.swift:pinToLatestAfterLayout", - cause: isNearBottom ? "reader-owns-scroll" : "not-following" - ) - return - } - latestPinGeneration &+= 1 - let generation = latestPinGeneration - latestPinTask?.cancel() - latestPinTask = Task { @MainActor in - guard generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false, traceReason: "pin:\(reason)") - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled, - generation == latestPinGeneration, - isNearBottom, - canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false, traceReason: "pin-retry:\(reason)") - if generation == latestPinGeneration { - latestPinTask = nil - } - } - } - - @MainActor - func forcePinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard canWriteAutomaticScrollOffset else { - WorkChatScrollTrace.writeSuppressed( - reason: "force-pin:\(reason)", - site: "WorkChatSessionView+Actions.swift:forcePinToLatestAfterLayout", - cause: "reader-owns-scroll" - ) - return - } - if !isNearBottom { - WorkChatScrollTrace.follow(true, reason: "force-pin:\(reason)") - } - isNearBottom = true - if unreadBelowCount > 0 { - unreadBelowCount = 0 - } - latestPinGeneration &+= 1 - let generation = latestPinGeneration - latestPinTask?.cancel() - latestPinTask = Task { @MainActor in - guard generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false, traceReason: "force-pin:\(reason)") - for delay in [16, 80, 180, 320] { - try? await Task.sleep(for: .milliseconds(delay)) - guard !Task.isCancelled, - generation == latestPinGeneration, - isNearBottom, - canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false, traceReason: "force-pin-retry\(delay):\(reason)") - } - if generation == latestPinGeneration { - latestPinTask = nil - } - } - } - - @MainActor - func cancelLatestPinTask() { - latestPinGeneration &+= 1 - latestPinTask?.cancel() - latestPinTask = nil - } - @MainActor @discardableResult func runSessionAction(_ action: @escaping @MainActor () async -> T) async -> T { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 66831462fa..e06dd98c2b 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -16,7 +16,6 @@ extension WorkChatSessionView { @ViewBuilder func timelineRenderEntryView( for entry: WorkTimelineRenderEntry, - proxy: ScrollViewProxy, streamingAssistantMessageId: String?, maxUserBubbleWidth: CGFloat? ) -> some View { @@ -24,7 +23,6 @@ extension WorkChatSessionView { case .entry(let timelineEntry): timelineEntryView( for: timelineEntry, - proxy: proxy, streamingAssistantMessageId: streamingAssistantMessageId, maxUserBubbleWidth: maxUserBubbleWidth ) @@ -81,7 +79,6 @@ extension WorkChatSessionView { @ViewBuilder func timelineEntryView( for entry: WorkTimelineEntry, - proxy: ScrollViewProxy, streamingAssistantMessageId: String?, maxUserBubbleWidth: CGFloat? ) -> some View { @@ -239,25 +236,15 @@ extension WorkChatSessionView { onDismiss: timelineQuestionDismissHandler(question.id, dismissible: question.dismissible), onFreeformFocusChange: { focused in guard focused else { return } - // Wait for the keyboard to start animating in so the ScrollView's - // safe-area inset is updated before we ask it to scroll the focused - // card above the keyboard. + // Wait for the keyboard to start animating in so the transcript's + // bottom inset is updated before we ask it to bring the focused card + // above the keyboard. Task { @MainActor in try? await Task.sleep(nanoseconds: 300_000_000) - WorkChatScrollTrace.write( - reason: "question-freeform-focus", - target: "id=pending-question-\(question.id)", - site: "WorkChatSessionView+Timeline.swift:onFreeformFocusChange", - offsetBefore: scrollMetrics.offsetY, - contentHeight: scrollMetrics.contentHeight, - containerHeight: scrollMetrics.containerHeight, - scrollableHeight: scrollMetrics.scrollableHeight, - following: isNearBottom, - userDrivenPhase: timelineScrollPhaseUserDriven + transcriptScroller.scrollRowIntoView( + id: entry.id, + reason: "question-freeform-focus" ) - withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo("pending-question-\(question.id)", anchor: .bottom) - } } }, fallbackProvider: chatSummaryContext.provider, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 6c8e91036c..a0625c084e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2,19 +2,6 @@ import SwiftUI import UIKit import AVKit -let workChatScrollCoordinateSpace = "WorkChatScrollCoordinateSpace" -let workChatStickThreshold: CGFloat = 160 -let workChatStickResumeThreshold: CGFloat = 48 -let workChatTouchScrollDeadband: CGFloat = 2 -/// How far a drag has to travel before it counts as the reader taking over from -/// the initial bottom pin. -/// -/// Deliberately larger than `workChatTouchScrollDeadband`: 2pt is the right -/// sensitivity for releasing bottom-stickiness during a streaming turn (a nudge -/// upward means "stop following"), but it is well inside the finger jitter of a -/// tap on a freshly-opened chat, and cancelling the pin there is what left -/// transcripts parked at a random offset while hydration was still growing the -/// content underneath. let workChatBottomAnchorSpacerHeight: CGFloat = 1 let workChatContentBottomGutterHeight: CGFloat = 2 let workChatSubagentActivePopupHeight: CGFloat = 34 @@ -29,119 +16,6 @@ let workChatFloatingBadgeRowBottomPadding: CGFloat = 12 let workChatOlderHistoryTriggerDistance: CGFloat = 240 let workChatOlderHistoryRearmDistance: CGFloat = 420 let workChatOlderHistoryScrollableDistance: CGFloat = 1 -/// How long the transcript's content size has to stay unchanged before the -/// opening bottom pin is considered settled. -let workChatInitialPinQuiescenceMilliseconds = 600 -/// Sub-point layout jitter is not a keyboard, composer, or collapse event. -let workChatLayoutGeometrySlop: CGFloat = 1 -/// After a window or collapse layout pass, keep treating `.interacting` as the -/// keyboard rather than the reader. Keyboard animation is ~250ms; this grace -/// covers the last frame plus SwiftUI's trailing phase. -let workChatLayoutFollowGraceSeconds: TimeInterval = 0.45 - -/// How a layout pass should move the transcript. -enum WorkChatLayoutScrollAdjustment: Equatable { - case none - /// Follow is on: glue the window to the real tail after the pass. - case pinToLatest - /// Follow is off: put the offset back to the pre-layout reading, clamped to - /// the new range, so a shorter window does not overscroll into blank and - /// does not let keyboard avoidance steal the reader's place. - case restoreOffset(CGFloat) -} - -/// Isolates the window/inset contribution of a layout pass. -/// -/// `scrollableHeight = contentHeight - containerHeight + insets`. A keyboard -/// typically raises `scrollableHeight` without changing `contentHeight`. -func workChatLayoutViewportDelta(contentDelta: CGFloat, scrollableDelta: CGFloat) -> CGFloat { - scrollableDelta - contentDelta -} - -func workChatLayoutWindowChanged(containerDelta: CGFloat, viewportDelta: CGFloat) -> Bool { - abs(containerDelta) > workChatLayoutGeometrySlop - || abs(viewportDelta) > workChatLayoutGeometrySlop -} - -func workChatLayoutAdjustedRecently( - lastAdjustmentUptime: TimeInterval, - now: TimeInterval, - grace: TimeInterval = workChatLayoutFollowGraceSeconds -) -> Bool { - lastAdjustmentUptime > 0 && (now - lastAdjustmentUptime) < grace -} - -/// Keyboard avoidance reports `.interacting`. That is not the reader taking -/// over, and treating it as such both drops follow and blocks the pin write. -func workChatShouldIgnoreUserScrollPhaseForLayout( - userDrivenPhase: Bool, - layoutAdjustedRecently: Bool -) -> Bool { - userDrivenPhase && layoutAdjustedRecently -} - -func workChatShouldReleaseFollowForUserScroll( - following: Bool, - userDrivenPhase: Bool, - layoutAdjustedRecently: Bool, - distanceFromBottom: CGFloat, - offsetRetreat: CGFloat -) -> Bool { - guard following, userDrivenPhase else { return false } - // During keyboard/composer layout, `distanceFromBottom` is inflated with an - // unchanged offset — the terminal predicate flip. A real scroll-up still - // retreats `contentOffset` relative to the frozen pre-layout restore point. - if layoutAdjustedRecently { - return offsetRetreat > workChatTouchScrollDeadband - } - return distanceFromBottom > workChatTouchScrollDeadband -} - -/// Keyboard `.interacting` can drop follow before the layout observer runs. -/// Put it back only when the pre-keyboard offset was still at the tail — a -/// reader who had already scrolled away keeps their place. -func workChatShouldReclaimFollowAfterWindowChange( - following: Bool, - distanceFromPreviousTail: CGFloat -) -> Bool { - !following && distanceFromPreviousTail <= workChatTouchScrollDeadband -} - -/// Same class of layout pin the terminal already has: a following viewport -/// re-glues across keyboard/composer shrink and card collapse; a reader who -/// scrolled up keeps their place. -func workChatLayoutScrollAdjustment( - following: Bool, - mayWriteScrollOffset: Bool, - containerDelta: CGFloat, - contentDelta: CGFloat, - viewportDelta: CGFloat, - previousOffsetY: CGFloat, - nextScrollableHeight: CGFloat -) -> WorkChatLayoutScrollAdjustment { - guard mayWriteScrollOffset else { return .none } - - let windowChanged = workChatLayoutWindowChanged( - containerDelta: containerDelta, - viewportDelta: viewportDelta - ) - let contentShrunk = contentDelta < -workChatLayoutGeometrySlop - let windowShrunk = containerDelta < -workChatLayoutGeometrySlop - || viewportDelta > workChatLayoutGeometrySlop - - if following { - // Content growth while following is already pinned by the timeline - // observers. Pinning on every streaming layout pass would cancel the - // in-flight pin Task and lag the tail. Keyboard, keyboard-hide, and - // collapse are the cases this observer uniquely owns. - guard windowChanged || contentShrunk else { return .none } - return .pinToLatest - } - - guard windowShrunk || contentShrunk else { return .none } - let restored = min(max(0, previousOffsetY), nextScrollableHeight) - return .restoreOffset(restored) -} struct WorkChatOlderHistoryLoadResult { let succeeded: Bool @@ -167,7 +41,7 @@ struct WorkChatOlderHistoryLoadResult { /// genuinely empty chat from one whose transcript request is still in flight /// or was dropped, and rendering "No chat messages yet" for the latter two is /// a false negative the user cannot distinguish from data loss. -enum WorkChatTranscriptLoadState: Equatable { +enum WorkChatTranscriptLoadState: Hashable { case idle case loading case failed(String) @@ -276,266 +150,6 @@ func workChatShouldContinueAutomaticOlderHistory( return !hasError && hasHostHistory } -/// The prepend probe is only useful while the reader is close enough to the -/// head for a history page to land. Keeping it installed on the first row for -/// the whole transcript makes every ordinary scroll participate in SwiftUI's -/// preference graph, even though there is no correction to perform. -func workChatShouldInstallPrependProbe( - distanceFromTop: CGFloat, - hasPrependAnchor: Bool -) -> Bool { - hasPrependAnchor || distanceFromTop <= workChatOlderHistoryTriggerDistance -} - -/// Scroll state a prepend has to preserve: which row led the list, where that -/// row sat, and where the reader was, at the instant rows were inserted above. -/// -/// Deliberately *not* total content height. An assistant reply streaming into -/// the tail grows the content too, and a reader scrolled back through history is -/// exactly when that happens — restoring by total growth would add the tail's -/// growth to the correction and overshoot. -struct WorkChatPrependAnchor { - let rowId: String - let rowY: CGFloat - /// The reader's offset when the prepend was armed. Not what the correction is - /// applied to — it is how the reader's own scrolling is separated from the - /// insertion, since the probed row moves by both. - let offsetY: CGFloat - /// Layout passes to wait for before giving up, so an abandoned prepend cannot - /// leave the anchor armed to fire on an unrelated later change. - var remainingAttempts: Int -} - -/// Reference box so scroll geometry can be recorded per frame without -/// invalidating the view. `distanceFromBottom` predates the prepend anchor and -/// keeps its existing meaning. -final class WorkChatScrollMetrics { - var distanceFromBottom: CGFloat = 0 - var distanceFromTop: CGFloat = 0 - var offsetY: CGFloat = 0 - var scrollableHeight: CGFloat = 0 - var containerHeight: CGFloat = 0 - var contentHeight: CGFloat = 0 - /// Offset to restore across a layout pass. Updated only while the container - /// is stable so a keyboard animation cannot overwrite the place the reader - /// was looking. - var stableOffsetY: CGFloat = 0 - /// `ProcessInfo.processInfo.systemUptime` of the last window or collapse - /// layout pass. 0 means no layout pin has run in this session. - var lastLayoutAdjustmentUptime: TimeInterval = 0 - /// Raw scroll-phase bit, updated even while layout grace is ignoring the - /// phase for write-permission purposes. After the keyboard settles, a finger - /// that stayed on the transcript never emits a new transition — stickiness - /// still has to see that the reader is driving the offset. - var phaseIsUserDriven = false - /// Position of the row currently being probed (the list's first row, or the - /// armed row while a prepend is in flight), in the scroll coordinate space. - var probeRowId: String? - var probeRowY: CGFloat? - var prependAnchor: WorkChatPrependAnchor? -} - -/// The scroll geometry the transcript reacts to, rounded so sub-pixel jitter -/// doesn't wake the observer. -/// -/// Everything the transcript needs about its position is derived here, from the -/// one sample the scroll view already produces. The content-top and -/// content-bottom `GeometryReader` + `PreferenceKey` probes this replaced -/// measured the same two numbers by laying out two extra views and running the -/// preference reduce/observe machinery on every frame of every scroll. -struct WorkChatScrollGeometrySample: Equatable { - let offsetY: CGFloat - /// Largest in-range content offset, used only to clamp a restore. - let scrollableHeight: CGFloat - /// Distance scrolled past the first row. 0 at the very top. - let distanceFromTop: CGFloat - /// Distance still to scroll to reach the last row. 0 at the very bottom. - let distanceFromBottom: CGFloat - let containerHeight: CGFloat - let contentHeight: CGFloat - - init(_ geometry: ScrollGeometry) { - self.offsetY = (geometry.contentOffset.y * 2).rounded() / 2 - let scrollable = geometry.contentSize.height - geometry.containerSize.height - + geometry.contentInsets.top + geometry.contentInsets.bottom - self.scrollableHeight = max(0, (scrollable * 2).rounded() / 2) - // Content insets shift `contentOffset` so that resting at the top reads as - // `-contentInsets.top`; adding it back puts both distances on the same - // inset-free 0…scrollableHeight range. - let position = geometry.contentOffset.y + geometry.contentInsets.top - self.distanceFromTop = max(0, (position * 2).rounded() / 2) - self.distanceFromBottom = max(0, self.scrollableHeight - self.distanceFromTop) - self.containerHeight = (geometry.containerSize.height * 2).rounded() / 2 - self.contentHeight = (geometry.contentSize.height * 2).rounded() / 2 - } -} - -/// The transcript's content size, sampled separately from the per-frame scroll -/// position so layout-driven work (the opening pin, the short-transcript -/// alignment, the keyboard/collapse re-glue) runs on content and window -/// changes instead of on every scroll frame. -struct WorkChatContentSizeSample: Equatable { - let contentHeight: CGFloat - let containerHeight: CGFloat - let scrollableHeight: CGFloat - - var contentFitsViewport: Bool { scrollableHeight <= 0.5 } - - init(_ geometry: ScrollGeometry) { - self.contentHeight = (geometry.contentSize.height * 2).rounded() / 2 - self.containerHeight = (geometry.containerSize.height * 2).rounded() / 2 - let scrollable = geometry.contentSize.height - geometry.containerSize.height - + geometry.contentInsets.top + geometry.contentInsets.bottom - self.scrollableHeight = max(0, (scrollable * 2).rounded() / 2) - } -} - -/// Number of layout passes a prepend anchor stays armed for. -let workChatPrependAnchorAttempts = 12 - -/// What a presentation change does to the prepend anchor. -enum WorkChatPrependArmDecision: Equatable { - case ignore - /// A second prepend landed while the first was still being corrected. - case extendExistingAnchorWindow - /// The anchored row is gone; nothing left to measure against. - case retireAnchor - case arm(rowId: String, rowY: CGFloat) -} - -/// Decides how a presentation change affects the prepend anchor. -/// -/// The overlapping-prepend case is the subtle one. The armed anchor rides a row -/// that BOTH insertions pushed down, so the displacement measured on it already -/// accumulates them. Re-arming on the new first row would measure only the -/// second insertion and leave the first uncorrected — the "teleport up" that -/// back-to-back page loads produced. -func workChatPrependArmDecision( - previousFirstId: String?, - nextFirstId: String?, - previousVisibleCount: Int, - nextVisibleCount: Int, - existingAnchorRowId: String?, - anchorRowStillVisible: Bool, - previousFirstRowStillVisible: Bool, - probeRowId: String?, - probeRowY: CGFloat? -) -> WorkChatPrependArmDecision { - guard nextVisibleCount > previousVisibleCount, - let previousFirstId, - nextFirstId != previousFirstId - else { return .ignore } - - if existingAnchorRowId != nil { - return anchorRowStillVisible ? .extendExistingAnchorWindow : .retireAnchor - } - - guard - // The probe has to already be measuring the row we are about to anchor on, - // or there is no "before" position to restore to. - probeRowId == previousFirstId, - let probeRowY, - // Only a genuine prepend: the row that used to lead the list has to still - // be in the list, just further down. - previousFirstRowStillVisible - else { return .ignore } - - return .arm(rowId: previousFirstId, rowY: probeRowY) -} - -/// What an armed anchor may do on this layout pass. -enum WorkChatPrependCorrection: Equatable { - /// The reader owns the offset. Stay armed and spend no attempt. - case wait - /// No usable measurement yet. Spend an attempt. - case retry - /// Undo this much inserted height. - case apply(CGFloat) -} - -/// Turns a probe sample into a correction. -/// -/// The two `retry` cases are what keeps a correction honest. A probe describing -/// some OTHER row carries no information about the anchored row, and treating a -/// mismatch as a zero row shift reduces the correction to the reader's own -/// scroll delta and applies it a second time — a teleport, not a correction. -func workChatPrependCorrection( - anchor: WorkChatPrependAnchor, - probed: WorkChatPrependProbeSample?, - currentOffsetY: CGFloat, - mayWriteScrollOffset: Bool -) -> WorkChatPrependCorrection { - // A correction is a scroll write, so it waits for the reader to let go. The - // anchor stays armed and spends no attempt meanwhile: the measurement below - // isolates the insertion from the reader's own scrolling, so applying it once - // the fling settles restores the same reading position it would have restored - // mid-fling — without fighting the fling for the offset. - guard mayWriteScrollOffset else { return .wait } - guard let probed, probed.rowId == anchor.rowId else { return .retry } - - // The anchored row moves by the height inserted above it *minus* whatever the - // reader scrolled in the meantime, because scrolling moves the row up the - // screen too. Adding the offset change back isolates the insertion: with an - // inserted height H and a user scroll D, the row moves H - D and the offset - // moves D, so the sum is H either way — and a pure scroll with no prepend - // sums to zero and correctly restores nothing. - let insertedHeight = (probed.y - anchor.rowY) + (currentOffsetY - anchor.offsetY) - guard insertedHeight > 0.5 else { return .retry } - return .apply(insertedHeight) -} - -/// Whether a scroll phase means the reader — not the app — owns the offset. -/// -/// `.animating` is deliberately not user-driven: it is the phase our own -/// `scrollTo` animations run in, and treating it as the reader's would let one -/// programmatic scroll suppress the next one. -func workChatScrollPhaseIsUserDriven(_ phase: ScrollPhase) -> Bool { - switch phase { - case .tracking, .interacting, .decelerating: - return true - default: - return false - } -} - -/// Whether the transcript may write the scroll offset right now. -/// -/// Every programmatic scroll — bottom-follow pins, the initial pin, the prepend -/// correction — goes through this. Writing an offset while the reader's finger -/// is down or a fling is still decelerating kills the fling and lands the -/// content somewhere neither the app nor the reader chose. -func workChatMayWriteScrollOffset(dragActive: Bool, scrollPhaseUserDriven: Bool) -> Bool { - !dragActive && !scrollPhaseUserDriven -} - -/// Where the transcript's content sits inside a viewport it does not fill. -/// -/// Only meaningful while the content is shorter than the viewport: past that the -/// content frame is sized by the content and the alignment is inert. -func workChatTranscriptContentAlignment(contentFitsViewport: Bool) -> Alignment { - contentFitsViewport ? .topLeading : .bottomLeading -} - -/// Position of the single probed row, published from the row itself so a -/// prepend's displacement can be measured without a geometry reader per row. -/// -/// Carries the row id rather than letting the reader infer it: which row holds -/// the probe is decided during body evaluation, and the observer reading it back -/// runs later, so a recomputed id can describe a different row than the -/// measurement it is paired with. -struct WorkChatPrependProbeSample: Equatable { - let rowId: String - let y: CGFloat -} - -struct WorkChatPrependProbePreferenceKey: PreferenceKey { - static var defaultValue: WorkChatPrependProbeSample? { nil } - - static func reduce(value: inout WorkChatPrependProbeSample?, nextValue: () -> WorkChatPrependProbeSample?) { - value = nextValue() ?? value - } -} - struct WorkChatSummaryRenderContext: Equatable { let isAvailable: Bool let provider: String @@ -710,16 +324,9 @@ struct WorkChatSessionView: View { @State var scrollViewportHeight: CGFloat = 0 @State var scrollViewportWidth: CGFloat = 0 @State var composerLayoutHeight: CGFloat = 150 - @State var scrollMetrics = WorkChatScrollMetrics() - /// Only ever written to restore the reader's position after a prepend. Bottom - /// follow and the jump-to-latest pill keep using `ScrollViewProxy.scrollTo`. - @State var scrollPosition = ScrollPosition() - @State var timelineDragActive = false - /// True from the moment the reader touches the transcript until the fling it - /// launched has come to rest. The drag gesture alone ends at finger-up, which - /// is the middle of the interaction, not the end of it. - @State var timelineScrollPhaseUserDriven = false - @State var bottomStickinessReleasedByUser = false + /// The transcript's handle on its own `UICollectionView`. Replaces the + /// `ScrollViewProxy` that used to be threaded through every row builder. + @State var transcriptScroller = WorkChatTranscriptScroller() @State var timelineSnapshot = WorkChatTimelineSnapshot.empty @State var timelinePresentation = WorkTimelinePresentation.empty @State var turnToolActivity = WorkTurnToolActivityIndex(completedByTurnId: [:], completedFilesByTurnId: [:], claimedInlineGroupIds: [], active: nil) @@ -729,8 +336,6 @@ struct WorkChatSessionView: View { @State var timelineRebuildPending = false @State var timelineRebuildGeneration = 0 @State var timelineBuildScopeId = UUID().uuidString - @State var latestPinTask: Task? - @State var latestPinGeneration = 0 @State var assistantPreviewCache = WorkAssistantPreviewCache() @State var contextUsageViewModelCache = WorkContextUsageViewModelCache() /// One presentation host for every box in this transcript. Boxes reach it @@ -740,12 +345,6 @@ struct WorkChatSessionView: View { @State var composerSettingMutationGeneration = 0 @State var pendingCodexFastMode: Bool? @State var scrollStateSessionId: String? - @State var pendingInitialBottomPinSessionId: String? - @State var initialBottomPinQuiescenceGeneration = 0 - /// Whether the whole transcript fits on screen. Flips rarely, so it is safe as - /// `@State` even though the sample that produces it arrives per scroll frame. - @State var transcriptContentFitsViewport = true - @State var timelineLayoutPinToken = 0 @State var olderHistoryLoadInFlight = false @State var olderHistoryLoadError: String? @State var olderHistoryTriggerArmed = true @@ -810,7 +409,6 @@ struct WorkChatSessionView: View { var subagentSnapshotsRenderSignature: Int = 0 var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] var scheduledWorkSnapshotsRenderSignature: Int = 0 - var selectedSubagentTaskId: String? = nil var onOpenChatInfo: (() -> Void)? = nil /// Tapping a subagent spawn/result timeline row opens the same detail surface /// the Chat Info roster row opens (full transcript takeover or expanded row). @@ -895,30 +493,12 @@ struct WorkChatSessionView: View { ) } - private var selectedSubagentSnapshot: WorkSubagentSnapshot? { - guard let selectedId = selectedSubagentTaskId else { return nil } - return subagentSnapshots.first { snapshot in - snapshot.taskId == selectedId || snapshot.agentId == selectedId - } - } - private var transcriptModelId: String { - if let model = selectedSubagentSnapshot?.model?.trimmingCharacters(in: .whitespacesAndNewlines), - !model.isEmpty { - return model - } - return chatSummaryContext.currentModelId + chatSummaryContext.currentModelId } private var transcriptModelLabel: String { - if let snapshot = selectedSubagentSnapshot, - let chip = workSubagentModelChip( - snapshotModel: snapshot.model, - sessionModel: chatSummaryContext.model - ) { - return chip - } - return chatSummaryContext.modelLabel + chatSummaryContext.modelLabel } /// Terminal transcript signal from the local event window. When present, it @@ -1271,170 +851,9 @@ struct WorkChatSessionView: View { ) } guard nextPresentation != timelinePresentation else { return } - armPrependAnchorIfRowsInsertedAbove(nextPresentation) timelinePresentation = nextPresentation } - /// The row carrying the displacement probe: normally the list's first row, and - /// the armed row while a prepend is in flight (it is no longer first once the - /// older page lands above it). - var prependProbeRowId: String? { - let hasPrependAnchor = scrollMetrics.prependAnchor != nil - guard workChatShouldInstallPrependProbe( - distanceFromTop: scrollMetrics.distanceFromTop, - hasPrependAnchor: hasPrependAnchor - ) else { - return nil - } - return scrollMetrics.prependAnchor?.rowId ?? timelinePresentation.visibleEntries.first?.id - } - - /// The last real probe measurement, replayed when a correction had to wait for - /// the reader's fling to settle (no new preference change arrives once layout - /// is quiet). - var lastPrependProbeSample: WorkChatPrependProbeSample? { - guard let rowId = scrollMetrics.probeRowId, let y = scrollMetrics.probeRowY else { return nil } - return WorkChatPrependProbeSample(rowId: rowId, y: y) - } - - /// Where a transcript shorter than the viewport sits. - /// - /// The alignment only has an effect while the content is shorter than the - /// viewport (past that the frame is content-sized), and there desktop renders - /// the first prompt at the TOP — a one-message chat pinned to the bottom of an - /// otherwise empty screen reads like the transcript failed to load. - var transcriptContentAlignment: Alignment { - workChatTranscriptContentAlignment(contentFitsViewport: transcriptContentFitsViewport) - } - - /// Records where the reader is whenever the next presentation inserts rows - /// above the ones already on screen — whether that came from revealing locally - /// buffered entries or from an older page landing from the host. Without this - /// the LazyVStack grows upward, `contentOffset` stays put, and whatever the - /// user was reading slides down by the height of the inserted page. - @MainActor - private func armPrependAnchorIfRowsInsertedAbove(_ nextPresentation: WorkTimelinePresentation) { - let previousFirstId = timelinePresentation.visibleEntries.first?.id - let existingAnchorRowId = scrollMetrics.prependAnchor?.rowId - switch workChatPrependArmDecision( - previousFirstId: previousFirstId, - nextFirstId: nextPresentation.visibleEntries.first?.id, - previousVisibleCount: timelinePresentation.visibleEntries.count, - nextVisibleCount: nextPresentation.visibleEntries.count, - existingAnchorRowId: existingAnchorRowId, - anchorRowStillVisible: existingAnchorRowId.map { rowId in - nextPresentation.visibleEntries.contains { $0.id == rowId } - } ?? false, - previousFirstRowStillVisible: previousFirstId.map { rowId in - nextPresentation.visibleEntries.contains { $0.id == rowId } - } ?? false, - probeRowId: scrollMetrics.probeRowId, - probeRowY: scrollMetrics.probeRowY - ) { - case .ignore: - WorkChatScrollTrace.note( - "prepend-arm IGNORE prevFirst=\(previousFirstId ?? "nil") " - + "nextFirst=\(nextPresentation.visibleEntries.first?.id ?? "nil") " - + "probeRow=\(scrollMetrics.probeRowId ?? "nil") " - + "probeY=\(scrollMetrics.probeRowY.map { "\($0)" } ?? "nil") " - + "prevCount=\(timelinePresentation.visibleEntries.count) " - + "nextCount=\(nextPresentation.visibleEntries.count) " - + "distanceFromTop=\(scrollMetrics.distanceFromTop)" - ) - return - case .extendExistingAnchorWindow: - WorkChatScrollTrace.note("prepend-arm EXTEND") - scrollMetrics.prependAnchor?.remainingAttempts = workChatPrependAnchorAttempts - case .retireAnchor: - WorkChatScrollTrace.note("prepend-arm RETIRE") - scrollMetrics.prependAnchor = nil - case .arm(let rowId, let rowY): - WorkChatScrollTrace.note("prepend-arm ARM row=\(rowId) y=\(rowY)") - scrollMetrics.prependAnchor = WorkChatPrependAnchor( - rowId: rowId, - rowY: rowY, - offsetY: scrollMetrics.offsetY, - remainingAttempts: workChatPrependAnchorAttempts - ) - } - } - - /// Re-applies the reader's position once the prepended rows have laid out. - /// - /// The anchored row moved down by exactly the height inserted above it, and - /// that displacement is measured on the row itself — so a reply streaming into - /// the tail at the same time contributes nothing to the correction. - @MainActor - func restorePrependAnchorIfNeeded(probed: WorkChatPrependProbeSample?) { - guard var anchor = scrollMetrics.prependAnchor else { return } - - let insertedHeight: CGFloat - switch workChatPrependCorrection( - anchor: anchor, - probed: probed, - currentOffsetY: scrollMetrics.offsetY, - mayWriteScrollOffset: workChatMayWriteScrollOffset( - dragActive: timelineDragActive, - scrollPhaseUserDriven: timelineScrollPhaseUserDriven - ) - ) { - case .wait: - WorkChatScrollTrace.writeSuppressed( - reason: "prepend-restore", - site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", - cause: "wait-for-probe" - ) - return - case .retry: - anchor.remainingAttempts -= 1 - scrollMetrics.prependAnchor = anchor.remainingAttempts > 0 ? anchor : nil - WorkChatScrollTrace.writeSuppressed( - reason: "prepend-restore", - site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", - cause: "retry attempts=\(anchor.remainingAttempts)" - ) - return - case .apply(let height): - insertedHeight = height - } - - scrollMetrics.prependAnchor = nil - // Bottom-follow owns the scroll position when the reader is parked at the - // tail; a prepend there is invisible anyway. - guard !isNearBottom else { - WorkChatScrollTrace.writeSuppressed( - reason: "prepend-restore", - site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", - cause: "following-tail" - ) - return - } - - WorkChatScrollTrace.write( - reason: "prepend-restore", - target: "y=\(min(max(0, scrollMetrics.offsetY + insertedHeight), scrollMetrics.scrollableHeight)) inserted=\(insertedHeight)", - site: "WorkChatSessionView.swift:restorePrependAnchorIfNeeded", - offsetBefore: scrollMetrics.offsetY, - contentHeight: scrollMetrics.contentHeight, - containerHeight: scrollMetrics.containerHeight, - scrollableHeight: scrollMetrics.scrollableHeight, - following: isNearBottom, - userDrivenPhase: timelineScrollPhaseUserDriven - ) - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - // Applied to the live offset so a scroll during the prepend is kept; - // only the inserted height is undone. Clamped to the scrollable range the - // way t3code's `restore(_:in:dataSource:)` bounds its `setContentOffset`: - // a measured height should never land out of range, but the retained - // last-probe path can carry a stale measurement, and a bounded restore - // fails as a slightly-wrong position instead of a blank overscroll. - let target = min(max(0, scrollMetrics.offsetY + insertedHeight), scrollMetrics.scrollableHeight) - scrollPosition.scrollTo(y: target) - } - } - /// One derivation for all three composer gates. See `WorkComposerInputGate` /// for why attaching is NOT the same question as typing. var composerInputGate: WorkComposerInputGate { @@ -1605,85 +1024,171 @@ struct WorkChatSessionView: View { } } + var olderHistoryHeaderRowVisible: Bool { + hiddenTimelineCount > 0 || canRequestOlderTranscriptHistory + } + + /// The earlier-messages affordance that leads the transcript: a spinner + /// while a page is in flight, a retry row when one failed, and an invisible + /// 1pt placeholder otherwise so the row's identity — and therefore its cell + /// and its measured height — survives the state changing under it. @ViewBuilder - func timelineSection(proxy: ScrollViewProxy) -> some View { - if hiddenTimelineCount > 0 || canRequestOlderTranscriptHistory { - Group { - if olderHistoryLoadInFlight { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Loading earlier messages…") - } - .frame(maxWidth: .infinity, minHeight: 28) - } else if let olderHistoryLoadError { - Button { - requestEarlierTimelineEntries( - automatically: olderHistoryAutomaticContinuationPending - ) - } label: { - Label("Couldn’t load earlier messages · Retry", systemImage: "arrow.clockwise") - .frame(maxWidth: .infinity, minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .foregroundStyle(ADEColor.accent) - .accessibilityHint(olderHistoryLoadError) - } else { - Color.clear - .frame(height: 1) - .accessibilityHidden(true) + var olderHistoryHeaderRow: some View { + Group { + if olderHistoryLoadInFlight { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading earlier messages…") } + .frame(maxWidth: .infinity, minHeight: 28) + } else if let olderHistoryLoadError { + Button { + requestEarlierTimelineEntries( + automatically: olderHistoryAutomaticContinuationPending + ) + } label: { + Label("Couldn’t load earlier messages · Retry", systemImage: "arrow.clockwise") + .frame(maxWidth: .infinity, minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(ADEColor.accent) + .accessibilityHint(olderHistoryLoadError) + } else { + Color.clear + .frame(height: 1) + .accessibilityHidden(true) } - .font(.footnote.weight(.semibold)) } + .font(.footnote.weight(.semibold)) + } + /// Whether the overview band above the transcript has anything to say. It is + /// a row in the collection view, so "renders nothing" has to be decided + /// before the row exists rather than by an `EmptyView` occupying a cell. + var sessionOverviewHasContent: Bool { + let parentId = chatSummaryContext.orchestrationParentSessionId? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let hasBreadcrumb = !parentId.isEmpty + && parentId != session.id + && onOpenParentSession != nil + let hasError = errorMessageSnapshot != nil + && !hostUnreachable + && !syncService.shouldSuppressDomainHydrationNotices + return hasBreadcrumb || hasError + } + + var tailGutterHeight: CGFloat { + workChatContentBottomGutterHeight + + workChatBottomAnchorSpacerHeight + // The badge chips float over the transcript, so the tail has to reserve + // their height or the last message hides behind them. + + (showsComposerBadgeChips ? floatingBadgeBandHeight : 0) + } + + /// The transcript, as rows. Identity here is the collection view's identity: + /// a row keeps its cell, and therefore its measured height and the reader's + /// place, for as long as this id is in the list. + var transcriptRows: [WorkChatTranscriptRow] { + var rows: [WorkChatTranscriptRow] = [] + if sessionOverviewHasContent { + var hasher = Hasher() + hasher.combine(errorMessageSnapshot) + hasher.combine(chatSummaryContext.parentTitle) + rows.append( + WorkChatTranscriptRow(id: "chat-overview", kind: .overview, revision: hasher.finalize()) + ) + } + if olderHistoryHeaderRowVisible { + var hasher = Hasher() + hasher.combine(olderHistoryLoadInFlight) + hasher.combine(olderHistoryLoadError) + rows.append( + WorkChatTranscriptRow(id: "chat-older-history", kind: .olderHistory, revision: hasher.finalize()) + ) + } if timelinePresentation.timelineCount == 0 { - transcriptEmptyStateSection + var hasher = Hasher() + hasher.combine(transcriptLoadState) + hasher.combine(isLive) + rows.append( + WorkChatTranscriptRow(id: "chat-empty-state", kind: .emptyState, revision: hasher.finalize()) + ) } else { - let streamingMessageId = streamingAssistantMessageId - let userBubbleWidth = maxUserBubbleWidth - let probeRowId = prependProbeRowId - // A streaming or expanded assistant message renders as several suffixed - // block rows, so the probed *timeline* entry has no render row with a - // matching id. Resolve through `sourceEntryId` and pick its first block, - // or the probe silently never installs and the anchor never arms. - let probeRenderRowId = visibleTimelineRenderEntries - .first { $0.sourceEntryId == probeRowId }?.id - // Keep SwiftUI's identity pass over tiny values. Render entries carry - // full assistant payloads, so iterating them directly makes AttributeGraph - // copy long markdown responses just to read `id` during layout. - let renderEntries = visibleTimelineRenderEntries - let renderRowReferences = renderEntries.enumerated().map { index, entry in - WorkTimelineRenderRowReference(id: entry.id, index: index) + let entries = visibleTimelineRenderEntries + rows.reserveCapacity(entries.count + 4) + for index in entries.indices { + rows.append( + WorkChatTranscriptRow( + id: entries[index].id, + kind: .entry(index: index), + revision: workChatTranscriptRowRevision(entries[index]) + ) + ) } - ForEach(renderRowReferences) { reference in - let entry = renderEntries[reference.index] + } + if isStreamingTurn { + rows.append( + WorkChatTranscriptRow( + id: "chat-streaming-status", + kind: .streamingStatus, + revision: turnToolActivity.active?.count ?? 0 + ) + ) + } + rows.append( + WorkChatTranscriptRow( + id: "chat-end", + kind: .tail, + revision: Int(tailGutterHeight.rounded()) + ) + ) + return rows + } + + /// Everything a row draws that is not in its own revision: shared state the + /// whole transcript reads. A change here reconfigures every row, so it is + /// deliberately made of values that change rarely. + var transcriptContentRevision: Int { + var hasher = Hasher() + hasher.combine(cardExpansionRenderSignature) + hasher.combine(artifactContentRenderSignature) + hasher.combine(actionInFlight) + hasher.combine(isLive) + hasher.combine(hostUnreachable) + hasher.combine(streamingAssistantMessageId) + hasher.combine(maxUserBubbleWidth ?? 0) + hasher.combine(scrollViewportHeight) + hasher.combine(chatSummaryContext.provider) + hasher.combine(chatSummaryContext.usageLimitResume != nil) + return hasher.finalize() + } + + @ViewBuilder + func transcriptRowView(_ row: WorkChatTranscriptRow) -> some View { + switch row.kind { + case .overview: + sessionOverviewSection + case .olderHistory: + olderHistoryHeaderRow + case .emptyState: + transcriptEmptyStateSection + case .entry(let index): + let entries = visibleTimelineRenderEntries + if index < entries.count { timelineRenderEntryView( - for: entry, - proxy: proxy, - streamingAssistantMessageId: streamingMessageId, - maxUserBubbleWidth: userBubbleWidth + for: entries[index], + streamingAssistantMessageId: streamingAssistantMessageId, + maxUserBubbleWidth: maxUserBubbleWidth ) - .background { - // Exactly one row carries this probe. It measures how far a prepend - // pushed the reader's content down, which total content height cannot - // do while the tail is also streaming. - if let probeRowId, entry.id == probeRenderRowId { - GeometryReader { geometry in - Color.clear.preference( - key: WorkChatPrependProbePreferenceKey.self, - // Published in timeline-entry id space, which is what the anchor - // compares against. - value: WorkChatPrependProbeSample( - rowId: probeRowId, - y: geometry.frame(in: .named(workChatScrollCoordinateSpace)).minY - ) - ) - } - } - } } + case .streamingStatus: + streamingStatusSection + case .tail: + Color.clear + .frame(height: tailGutterHeight) + .accessibilityHidden(true) } } @@ -1726,10 +1231,10 @@ struct WorkChatSessionView: View { case .idle: ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", - title: selectedSubagentTaskId == nil ? "No chat messages yet" : "No subagent transcript", - message: selectedSubagentTaskId == nil - ? (isLive ? "Send a message to start streaming the transcript." : "Reconnect to load the latest chat history from the machine.") - : "This subagent did not publish detailed transcript output." + title: "No chat messages yet", + message: isLive + ? "Send a message to start streaming the transcript." + : "Reconnect to load the latest chat history from the machine." ) } } @@ -1753,7 +1258,7 @@ struct WorkChatSessionView: View { /// Single desktop-shaped composer card: text field on top, chip strip and /// send button on the bottom, everything wrapped in one rounded container /// with clear contrast against the chat background. - func composerInset(proxy: ScrollViewProxy) -> some View { + var composerInset: some View { VStack(spacing: 10) { // The redundant ENDED/RUNNING status pill row has been retired. Chat // lifecycle controls live outside the composer; this space is reserved @@ -1787,7 +1292,7 @@ struct WorkChatSessionView: View { await runSessionAction { await dispatch(steerId) steerEditDrafts.removeValue(forKey: steerId) - scrollToLatest(proxy, animated: true) + transcriptScroller.scrollToLatest(animated: true, reason: "steer-dispatched") unreadBelowCount = 0 } } @@ -1797,7 +1302,7 @@ struct WorkChatSessionView: View { await runSessionAction { await dispatch(steerId) steerEditDrafts.removeValue(forKey: steerId) - scrollToLatest(proxy, animated: true) + transcriptScroller.scrollToLatest(animated: true, reason: "steer-dispatched") unreadBelowCount = 0 } } @@ -1907,7 +1412,6 @@ struct WorkChatSessionView: View { } }, onOpenModelPicker: !chatSummaryContext.isAvailable - || selectedSubagentTaskId != nil || (isPersonalChat && ( !personalModelCatalogAvailable || !personalSessionUpdatesAvailable )) @@ -1923,7 +1427,7 @@ struct WorkChatSessionView: View { }, onSend: onSend, onSent: { - scrollToLatest(proxy, animated: true, traceReason: "composer-sent") + transcriptScroller.scrollToLatest(animated: true, reason: "composer-sent") } ) } @@ -1932,199 +1436,86 @@ struct WorkChatSessionView: View { .padding(.bottom, 0) } - /// Extracted from `body` so the type-checker sees two bounded - /// expressions instead of one ~300-line chain; the x86_64 simulator - /// slice hit the "unable to type-check in reasonable time" ceiling - /// on the combined expression. + /// The transcript. + /// + /// A `UICollectionView` behind `WorkChatTranscriptCollectionView`, not a + /// `ScrollView` + `LazyVStack`: UIKit compensates its own content offset + /// when a cell self-sizes away from its estimate, so a row re-measuring + /// above the viewport does not move the row the reader is looking at. @ViewBuilder - private func transcriptScrollView(proxy: ScrollViewProxy) -> some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - sessionOverviewSection - timelineSection(proxy: proxy) - streamingStatusSection - - Color.clear - .frame( - height: workChatContentBottomGutterHeight - + workChatBottomAnchorSpacerHeight - // The badge chips float over the transcript, so the tail has - // to reserve their height or the last message hides behind - // them. - + (showsComposerBadgeChips ? floatingBadgeBandHeight : 0) + private var transcriptView: some View { + WorkChatTranscriptCollectionView( + rows: transcriptRows, + contentRevision: transcriptContentRevision, + topInset: 9, + bottomInset: 9, + scroller: transcriptScroller, + rowContent: { row in + AnyView( + transcriptRowView(row) + .environmentObject(syncService) + .environment(\.workOutputViewer, outputViewer) + .modifier( + WorkChatTranscriptEnvironmentModifier( + provider: chatSummaryContext.provider, + modelId: transcriptModelId, + modelLabel: transcriptModelLabel, + laneId: session.laneId, + requestedCwd: chatSummaryContext.requestedCwd, + isPersonalChat: isPersonalChat, + sessionId: session.id ) - .id("chat-end") - .transaction { transaction in - transaction.animation = nil - } - } - .padding(16) - .frame( - maxWidth: .infinity, - minHeight: max(scrollViewportHeight, 0), - alignment: transcriptContentAlignment - ) - .modifier( - WorkChatTranscriptEnvironmentModifier( - provider: chatSummaryContext.provider, - modelId: transcriptModelId, - modelLabel: transcriptModelLabel, - laneId: session.laneId, - requestedCwd: chatSummaryContext.requestedCwd, - isPersonalChat: isPersonalChat, - sessionId: session.id ) - ) + ) + }, + onFollowChange: { following in + guard isNearBottom != following else { return } + isNearBottom = following + }, + onGeometryChange: { geometry in + handleTranscriptGeometry(geometry) + }, + onViewportChange: { size in + if size.height > 0, abs(scrollViewportHeight - size.height) > 1 { + scrollViewportHeight = size.height } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .layoutPriority(1) - .clipped() - .scrollIndicators(.hidden) - .scrollDismissesKeyboard(.interactively) - // Open at the tail without waiting for a layout pass. Scoped to - // `.initialOffset` on purpose: the `.sizeChanges` anchor would keep - // the bottom pinned as content grows, which is exactly the total-height - // correction the prepend machinery exists to avoid. The retry pin - // stays as belt-and-braces for the hydration that lands afterwards. - .defaultScrollAnchor(.bottom, for: .initialOffset) - .scrollPosition($scrollPosition) - .onScrollPhaseChange { _, phase in - let userDriven = workChatScrollPhaseIsUserDriven(phase) - WorkChatScrollTrace.phase(userDriven: userDriven, raw: "\(phase)") - scrollMetrics.phaseIsUserDriven = userDriven - let layoutRecent = workChatLayoutAdjustedRecently( - lastAdjustmentUptime: scrollMetrics.lastLayoutAdjustmentUptime, - now: ProcessInfo.processInfo.systemUptime - ) - if workChatShouldIgnoreUserScrollPhaseForLayout( - userDrivenPhase: userDriven, - layoutAdjustedRecently: layoutRecent - ) { - // Keyboard/composer geometry reports as `.interacting`. Keep - // follow and pin writes enabled; the layout observer re-glues. - return - } - guard timelineScrollPhaseUserDriven != userDriven else { return } - timelineScrollPhaseUserDriven = userDriven - timelineDragActive = userDriven - if userDriven { - // Follow release is not decided here. Keyboard avoidance reports - // `.interacting` before the geometry sample lands; unsticking on - // the phase alone is what dropped follow and blocked the pin. - // `updateBottomStickiness` releases only when the container is - // stable and the reader has actually moved. - return - } - // A fling that ended may have left a prepend correction waiting. - restorePrependAnchorIfNeeded(probed: lastPrependProbeSample) - } - .onScrollGeometryChange(for: WorkChatScrollGeometrySample.self) { geometry in - WorkChatScrollGeometrySample(geometry) - } action: { _, sample in - // Fires per scroll frame. Everything here is O(1) and writes to a - // reference box or to state that only changes at a threshold — no - // list scans, and nothing that invalidates the transcript per frame. - let contentDelta = sample.contentHeight - scrollMetrics.contentHeight - let scrollableDelta = sample.scrollableHeight - scrollMetrics.scrollableHeight - let viewportDelta = workChatLayoutViewportDelta( - contentDelta: contentDelta, - scrollableDelta: scrollableDelta - ) - let containerDelta = sample.containerHeight - scrollMetrics.containerHeight - let hasLayoutBaseline = scrollMetrics.containerHeight > 1 - || scrollMetrics.contentHeight > 1 - let windowChanged = hasLayoutBaseline && workChatLayoutWindowChanged( - containerDelta: containerDelta, - viewportDelta: viewportDelta - ) - let contentShrunk = hasLayoutBaseline - && contentDelta < -workChatLayoutGeometrySlop - scrollMetrics.offsetY = sample.offsetY - scrollMetrics.distanceFromTop = sample.distanceFromTop - scrollMetrics.scrollableHeight = sample.scrollableHeight - // Stamp the layout grace on this frame — before stickiness runs — - // so a keyboard shrink cannot lose the race to `.interacting`. - // Freeze the restore point while the window is moving or the tape - // is collapsing so offsetRetreat can still see a real scroll-up. - // Inset-only keyboard (safe-area) shows up as viewportDelta. - if windowChanged || contentShrunk { - scrollMetrics.lastLayoutAdjustmentUptime = ProcessInfo.processInfo.systemUptime - } else { - scrollMetrics.stableOffsetY = sample.offsetY - } - guard sample.containerHeight > 1 else { return } - WorkChatScrollTrace.sample( - offsetY: sample.offsetY, - contentHeight: sample.contentHeight, - containerHeight: sample.containerHeight, - scrollableHeight: sample.scrollableHeight, - distanceFromBottom: sample.distanceFromBottom, - userDrivenPhase: timelineScrollPhaseUserDriven, - following: isNearBottom - ) - updateBottomStickiness(distanceFromBottom: sample.distanceFromBottom, proxy: proxy) - continueAutomaticOlderHistoryIfNeeded() - requestOlderHistoryIfScrolledNearTop(distanceFromTop: sample.distanceFromTop) - } - // Content SIZE and window changes only — this observer never fires - // while the reader is merely scrolling, which is what keeps the tail - // scan in `resolvePendingInitialBottomPinAfterLayout` off the scroll - // path. Keyboard shrink changes `containerHeight`/`scrollableHeight` - // here, which is why the layout pin lives on this observer. - .onScrollGeometryChange(for: WorkChatContentSizeSample.self) { geometry in - WorkChatContentSizeSample(geometry) - } action: { previous, sample in - WorkChatScrollTrace.contentSize( - previousContent: previous.contentHeight, - nextContent: sample.contentHeight, - previousContainer: previous.containerHeight, - nextContainer: sample.containerHeight, - offsetY: scrollMetrics.offsetY, - following: isNearBottom - ) - if transcriptContentFitsViewport != sample.contentFitsViewport { - transcriptContentFitsViewport = sample.contentFitsViewport - } - resolvePendingInitialBottomPinAfterLayout(proxy, reason: "content-size") - applyLayoutGeometryScrollAdjustment(previous: previous, next: sample, proxy: proxy) - } - .coordinateSpace(name: workChatScrollCoordinateSpace) - .background( - GeometryReader { geometry in - Color.clear - .preference( - key: WorkChatViewportHeightPreferenceKey.self, - value: geometry.size.height - ) - .preference( - key: WorkChatViewportWidthPreferenceKey.self, - value: geometry.size.width - ) - } - ) - .overlay(alignment: .bottomTrailing) { - if unreadBelowCount > 0 || !isNearBottom { - WorkJumpToLatestPill(count: unreadBelowCount) { - WorkChatScrollTrace.follow(true, reason: "jump-to-latest-pill") - isNearBottom = true - if timelineDragActive { - timelineDragActive = false - } - scrollToLatest(proxy, animated: true, traceReason: "jump-to-latest-pill") - unreadBelowCount = 0 - } - .padding(.trailing, 16) - .padding(.bottom, jumpToLatestPillBottomPadding) - .transition(.move(edge: .trailing).combined(with: .opacity)) - } - } + if size.width > 0, abs(scrollViewportWidth - size.width) > 1 { + scrollViewportWidth = size.width + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + .overlay(alignment: .bottomTrailing) { + if unreadBelowCount > 0 || !isNearBottom { + WorkJumpToLatestPill(count: unreadBelowCount) { + unreadBelowCount = 0 + transcriptScroller.scrollToLatest(animated: true, reason: "jump-to-latest-pill") + } + .padding(.trailing, 16) + .padding(.bottom, jumpToLatestPillBottomPadding) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + } + + /// Product-level reactions to where the reader is. Never decides a scroll + /// write — that authority belongs to the follow latch inside the collection + /// view, and to the one function there that performs them. + @MainActor + private func handleTranscriptGeometry(_ geometry: WorkChatTranscriptGeometry) { + continueAutomaticOlderHistoryIfNeeded( + distanceFromBottom: geometry.distanceFromBottom, + contentFitsViewport: geometry.contentFitsViewport + ) + requestOlderHistoryIfScrolledNearTop(distanceFromTop: geometry.distanceFromTop) } /// Layout half of the chat column (structure + geometry preferences). /// Split from `body` so the type-checker sees bounded expressions; the /// behavior chain (onChange/sheet/task) stays in `body`. @ViewBuilder - private func chatColumn(proxy: ScrollViewProxy) -> some View { + private var chatColumn: some View { VStack(spacing: 0) { // Read-only summary of the Work tools pane running on the user's Mac. // It hides itself when the brain cannot describe one, so a lane with no @@ -2136,7 +1527,7 @@ struct WorkChatSessionView: View { // so the capsule's leading edge lines up with the content column. .padding(.horizontal, 16) - transcriptScrollView(proxy: proxy) + transcriptView .overlay(alignment: .bottomLeading) { // Floats over the thread instead of consuming composer height, so // the transcript scrolls behind the badges (same treatment as the @@ -2166,7 +1557,7 @@ struct WorkChatSessionView: View { .animation(.easeInOut(duration: 0.18), value: showsComposerBadgeChips) } - composerInset(proxy: proxy) + composerInset .fixedSize(horizontal: false, vertical: true) .background(alignment: .bottom) { WorkChatComposerBackdrop() @@ -2192,27 +1583,6 @@ struct WorkChatSessionView: View { } .background(workChatCanvasBackground.ignoresSafeArea()) .adeNavigationGlass() - .onPreferenceChange(WorkChatViewportHeightPreferenceKey.self) { height in - guard height > 0, abs(scrollViewportHeight - height) > 1 else { return } - scrollViewportHeight = height - } - .onPreferenceChange(WorkChatViewportWidthPreferenceKey.self) { width in - guard width > 0, abs(scrollViewportWidth - width) > 1 else { return } - scrollViewportWidth = width - } - .onPreferenceChange(WorkChatPrependProbePreferenceKey.self) { sample in - // Recorded into a reference box, not @State: this fires on every - // layout pass and must not invalidate the transcript. - // Keep the last real measurement rather than clearing on nil. The - // probed row can be recycled out of the LazyVStack while an older-page - // request is in flight, and forgetting it there means the page lands - // with no anchor to arm and pushes whatever the reader moved on to. - if let sample { - scrollMetrics.probeRowId = sample.rowId - scrollMetrics.probeRowY = sample.y - } - restorePrependAnchorIfNeeded(probed: sample) - } .onPreferenceChange(WorkChatComposerLayoutHeightPreferenceKey.self) { height in guard height > 0, abs(composerLayoutHeight - height) > 1 else { return } composerLayoutHeight = height @@ -2237,6 +1607,13 @@ struct WorkChatSessionView: View { olderHistoryLoadError = nil } } + // Revealing buffered history is scroll-back, and a reader glued to the live + // tail is not scrolling back. Without this the first geometry frames — which + // report the top, and a content height that trivially "fits", because the + // opening pin has not landed and the transcript has one row in it — spent + // the whole buffered page before anything was on screen. A transcript too + // short to scroll continues through `continueAutomaticOlderHistoryIfNeeded`. + guard !isNearBottom else { return } guard workChatShouldRequestOlderHistory( distanceFromTop: distanceFromTop, triggerArmed: olderHistoryTriggerArmed, @@ -2250,7 +1627,7 @@ struct WorkChatSessionView: View { } /// Timeline/scroll change handlers, split from `body` for type-checker budget. - private func timelineScrollHandlers(_ content: V, proxy: ScrollViewProxy) -> some View { + private func timelineScrollHandlers(_ content: V) -> some View { content .onChange(of: timelinePresentation.timelineCount) { oldCount, newCount in let previousTailId = lastTimelineTailId @@ -2265,7 +1642,8 @@ struct WorkChatSessionView: View { return } if isNearBottom { - pinToLatestAfterLayout(proxy, reason: "timeline-growth") + // Following: the collection view pins to the tail after the + // snapshot that carries these rows. Nothing to do here. } else { let nextCount = unreadBelowCount + delta if unreadBelowCount == 0 { @@ -2280,19 +1658,6 @@ struct WorkChatSessionView: View { .onChange(of: timelinePresentation.timelineLastId) { oldTailId, newTailId in guard oldTailId != newTailId else { return } lastTimelineTailId = newTailId - guard oldTailId != nil, newTailId != nil, isNearBottom else { return } - pinToLatestAfterLayout(proxy, reason: "timeline-tail") - } - .onChange(of: workSubagentRunningCount(subagentSnapshots)) { _, _ in - guard isNearBottom else { return } - pinToLatestAfterLayout(proxy, reason: "subagent-active-count") - } - .onChange(of: timelineLayoutPinToken) { _, _ in - if pendingInitialBottomPinSessionId == session.id { - forcePinToLatestAfterLayout(proxy, reason: "initial-timeline-layout") - } else { - pinToLatestAfterLayout(proxy, reason: "timeline-layout") - } } .onChange(of: isNearBottom) { _, nearBottom in guard nearBottom, unreadBelowCount > 0 else { return } @@ -2308,10 +1673,15 @@ struct WorkChatSessionView: View { guard wasStreaming, !isStreaming else { return } cardExpansion.clearForTurnEnd() } + // A card finished opening or closing. The latch re-reads where the + // reader ended up; it never moves them on its own. + .onChange(of: cardExpansionRenderSignature) { _, _ in + transcriptScroller.noteDisclosureSettled() + } } /// Session lifecycle + input-recovery handlers, split from `body` for type-checker budget. - private func sessionLifecycleHandlers(_ content: V, proxy: ScrollViewProxy) -> some View { + private func sessionLifecycleHandlers(_ content: V) -> some View { content .onAppear { prepareScrollStateForCurrentSessionIfNeeded(reason: "appear") @@ -2471,14 +1841,11 @@ struct WorkChatSessionView: View { } var body: some View { - ScrollViewReader { proxy in - feedbackAndSheets( - sessionLifecycleHandlers( - timelineScrollHandlers(chatColumn(proxy: proxy), proxy: proxy), - proxy: proxy - ) + feedbackAndSheets( + sessionLifecycleHandlers( + timelineScrollHandlers(chatColumn) ) - } + ) } } @@ -2718,25 +2085,6 @@ func workInlinePendingInputMaxHeight(transcriptViewportHeight: CGFloat) -> CGFlo max(240, transcriptViewportHeight * 0.62) } -private struct WorkChatViewportHeightPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} - - -private struct WorkChatViewportWidthPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = 0 - - static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { - let next = nextValue() - if next > 0 { value = next } - } -} - private struct WorkChatComposerLayoutHeightPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 @@ -2881,52 +2229,65 @@ private func workTimelinePresentationSignature( hasher.combine(visibleEntries.last?.id) hasher.combine(renderEntries.count) for entry in renderEntries { - hasher.combine(entry.id) - hasher.combine(entry.sourceEntryId) - hasher.combine(entry.timestamp) - switch entry.payload { - case .entry(let timelineEntry): - hasher.combine(timelineEntry.id) - hasher.combine(timelineEntry.timestamp) - hasher.combine(timelineEntry.rank) - if case .message(let message) = timelineEntry.payload { - hasher.combine(message.id) - hasher.combine(message.role) - hasher.combine(message.steerId) - hasher.combine(message.deliveryState) - hasher.combine(message.processed) - hasher.combine(message.unprocessedResolution?.action) - hasher.combine(message.unprocessedResolution?.state) - hasher.combine(message.unprocessedResolution?.resolvedAt) - workTimelineCombineMessageTextSignature(message, into: &hasher) - if let preview = message.assistantPreview { - // A preview is a pure function of the message text, and the text is - // already in this hash. Its shape is enough to separate two previews - // of the same message — no need to hash the rendered text, which is - // O(message) on every refresh. - hasher.combine(preview.totalLineCount) - hasher.combine(preview.usesMonospacedRendering) - } + hasher.combine(workChatTranscriptRowRevision(entry)) + } + return hasher.finalize() +} + +/// Everything about one render row that can change what it draws. +/// +/// Shared by the presentation signature (which asks "did the list change") and +/// by the transcript's per-row revision (which asks "does this cell have to be +/// reconfigured, and is its measured height still valid"). One function so the +/// two answers cannot drift apart — a row whose height changed but whose +/// revision did not would be restored from the height cache at the wrong size. +func workChatTranscriptRowRevision(_ entry: WorkTimelineRenderEntry) -> Int { + var hasher = Hasher() + hasher.combine(entry.id) + hasher.combine(entry.sourceEntryId) + hasher.combine(entry.timestamp) + switch entry.payload { + case .entry(let timelineEntry): + hasher.combine(timelineEntry.id) + hasher.combine(timelineEntry.timestamp) + hasher.combine(timelineEntry.rank) + if case .message(let message) = timelineEntry.payload { + hasher.combine(message.id) + hasher.combine(message.role) + hasher.combine(message.steerId) + hasher.combine(message.deliveryState) + hasher.combine(message.processed) + hasher.combine(message.unprocessedResolution?.action) + hasher.combine(message.unprocessedResolution?.state) + hasher.combine(message.unprocessedResolution?.resolvedAt) + workTimelineCombineMessageTextSignature(message, into: &hasher) + if let preview = message.assistantPreview { + // A preview is a pure function of the message text, and the text is + // already in this hash. Its shape is enough to separate two previews + // of the same message — no need to hash the rendered text, which is + // O(message) on every refresh. + hasher.combine(preview.totalLineCount) + hasher.combine(preview.usesMonospacedRendering) } - case .assistantMarkdownBlock(let model): - hasher.combine(model.id) - hasher.combine(model.messageId) - hasher.combine(model.block.id) - // The block's own precomputed digest, not a rebuilt `kind.cacheKey`: - // building that key allocates a full copy of the block's text, once per - // block, on every presentation refresh. - hasher.combine(model.block.digest) - hasher.combine(model.isStreamingTail) - case .assistantMonospaced(let model): - hasher.combine(model.id) - hasher.combine(model.messageId) - // Digest of the source message plus the size of the slice taken from it: - // together these change whenever the rendered text does, without hashing - // the (potentially very long) slice itself. - hasher.combine(model.sourceDigest) - hasher.combine(model.text.utf8.count) - hasher.combine(model.accessibilityLabel) } + case .assistantMarkdownBlock(let model): + hasher.combine(model.id) + hasher.combine(model.messageId) + hasher.combine(model.block.id) + // The block's own precomputed digest, not a rebuilt `kind.cacheKey`: + // building that key allocates a full copy of the block's text, once per + // block, on every presentation refresh. + hasher.combine(model.block.digest) + hasher.combine(model.isStreamingTail) + case .assistantMonospaced(let model): + hasher.combine(model.id) + hasher.combine(model.messageId) + // Digest of the source message plus the size of the slice taken from it: + // together these change whenever the rendered text does, without hashing + // the (potentially very long) slice itself. + hasher.combine(model.sourceDigest) + hasher.combine(model.text.utf8.count) + hasher.combine(model.accessibilityLabel) } return hasher.finalize() } diff --git a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift new file mode 100644 index 0000000000..58ed76c082 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift @@ -0,0 +1,747 @@ +import SwiftUI +import UIKit + +// MARK: - Row model + +/// What a transcript row is, for the parts of the pipeline that do not care +/// what it draws: a stable identity, and a revision that changes whenever its +/// rendered content does. +struct WorkChatTranscriptRow: Identifiable, Hashable { + enum Kind: Hashable { + case overview + case olderHistory + case emptyState + /// Index into the presentation's `renderEntries`. + case entry(index: Int) + case streamingStatus + /// The bottom gutter that reserves room for the floating badge chips. + case tail + } + + let id: String + let kind: Kind + /// Bumped whenever this row's own content changes. Deliberately *not* part + /// of `Hashable`'s identity contribution for the data source — a content + /// change must reconfigure the row, never delete and re-insert it, or the + /// measured height and the reader's anchor go with it. + let revision: Int + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.id == rhs.id && lhs.kind == rhs.kind && lhs.revision == rhs.revision + } + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} + +/// Scroll geometry the transcript's *product* behaviour needs: paging older +/// history in, and knowing whether the whole thread fits on screen. Nothing +/// here decides a scroll write. +struct WorkChatTranscriptGeometry: Equatable { + let distanceFromTop: CGFloat + let distanceFromBottom: CGFloat + let contentFitsViewport: Bool +} + +// MARK: - Height cache + +struct WorkChatTranscriptHeightKey: Hashable { + let rowId: String + let width: CGFloat + let revision: Int +} + +/// Measured row heights, keyed by row id + width + content revision. +/// +/// The cache is what keeps a re-measurement off the screen. A row that scrolls +/// back into view at the same width and revision is restored to the height the +/// layout already believes it has, so the pass produces no content-size change +/// and nothing under the reader moves. +final class WorkChatTranscriptHeightCache { + private var heights: [WorkChatTranscriptHeightKey: CGFloat] = [:] + func height(for key: WorkChatTranscriptHeightKey) -> CGFloat? { + heights[key] + } + + func store(_ height: CGFloat, for key: WorkChatTranscriptHeightKey) { + guard height > 0 else { return } + heights[key] = height + } + + func removeAll() { + heights.removeAll(keepingCapacity: true) + } + + func prune(keeping rowIds: Set) { + guard heights.count > 4096 else { return } + heights = heights.filter { rowIds.contains($0.key.rowId) } + } +} + +// MARK: - Cell + +final class WorkChatTranscriptCell: UICollectionViewCell { + var cacheKey: WorkChatTranscriptHeightKey? + weak var heightCache: WorkChatTranscriptHeightCache? + + override func preferredLayoutAttributesFitting( + _ layoutAttributes: UICollectionViewLayoutAttributes + ) -> UICollectionViewLayoutAttributes { + if let cacheKey, + cacheKey.width == layoutAttributes.frame.width, + let cached = heightCache?.height(for: cacheKey) { + let attributes = layoutAttributes + attributes.frame.size.height = cached + return attributes + } + let measured = super.preferredLayoutAttributesFitting(layoutAttributes) + if let cacheKey, cacheKey.width == measured.frame.width { + heightCache?.store(measured.frame.height, for: cacheKey) + } + return measured + } +} + +/// A collection view that says when it has finished laying out. +/// +/// `UIViewController.viewDidLayoutSubviews` does not cover this: a cell +/// self-sizing away from its estimate re-lays the collection view without ever +/// changing the controller's view bounds, and that pass is exactly the one +/// that can move a row above the reader. +final class WorkChatTranscriptCollectionViewBody: UICollectionView { + var onDidLayout: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onDidLayout?() + } +} + +// MARK: - Scroller handle + +/// The SwiftUI side's handle on the transcript. +/// +/// Replaces `ScrollViewProxy`, which had to be threaded through every row +/// builder just so one card could bring itself above the keyboard. +@MainActor +final class WorkChatTranscriptScroller { + fileprivate weak var controller: WorkChatTranscriptController? + + /// Where the reader is right now, for the product decisions that need it + /// outside a scroll frame (continuing an automatic history pull that landed + /// no rows, and therefore produced no layout pass to observe). + var currentGeometry: WorkChatTranscriptGeometry? { controller?.currentGeometry } + + func scrollToLatest(animated: Bool, reason: String) { + controller?.jumpToLatest(animated: animated, reason: reason) + } + + func scrollRowIntoView(id: String, reason: String) { + controller?.scrollRowIntoView(id: id, reason: reason) + } + + /// A new session (or the same one re-seeded): open following, at the tail. + func resetForNewSession() { + controller?.resetForNewSession() + } + + /// A card finished expanding or collapsing. + func noteDisclosureSettled() { + controller?.noteDisclosureSettled() + } +} + +// MARK: - Representable + +/// The Work chat transcript. +/// +/// A `UICollectionView` rather than `ScrollView` + `LazyVStack` for one +/// reason: UIKit compensates its own content offset when a cell self-sizes to +/// something other than its estimate, so a row re-measuring above the viewport +/// does not move the row the reader is looking at. A `LazyVStack` has no such +/// writer, which is where 88% of this transcript's measured displacement came +/// from. +struct WorkChatTranscriptCollectionView: UIViewControllerRepresentable { + let rows: [WorkChatTranscriptRow] + /// Bumped when anything shared by every row changes (card expansion, live + /// state, bubble width). Part of every row's height-cache key. + let contentRevision: Int + let topInset: CGFloat + let bottomInset: CGFloat + let scroller: WorkChatTranscriptScroller + let rowContent: (WorkChatTranscriptRow) -> AnyView + let onFollowChange: (Bool) -> Void + let onGeometryChange: (WorkChatTranscriptGeometry) -> Void + let onViewportChange: (CGSize) -> Void + + func makeUIViewController(context: Context) -> WorkChatTranscriptController { + let controller = WorkChatTranscriptController() + controller.rowContent = rowContent + controller.onFollowChange = onFollowChange + controller.onGeometryChange = onGeometryChange + controller.onViewportChange = onViewportChange + scroller.controller = controller + return controller + } + + func updateUIViewController(_ controller: WorkChatTranscriptController, context: Context) { + scroller.controller = controller + controller.rowContent = rowContent + controller.onFollowChange = onFollowChange + controller.onGeometryChange = onGeometryChange + controller.onViewportChange = onViewportChange + controller.setContentInsets(top: topInset, bottom: bottomInset) + controller.apply(rows: rows, contentRevision: contentRevision) + } + + static func dismantleUIViewController( + _ controller: WorkChatTranscriptController, + coordinator: () + ) { + controller.teardown() + } +} + +// MARK: - Controller + +@MainActor +final class WorkChatTranscriptController: UIViewController, UICollectionViewDelegate { + private enum Section: Hashable { case main } + + private(set) var collectionView: UICollectionView! + private var dataSource: UICollectionViewDiffableDataSource! + private let heightCache = WorkChatTranscriptHeightCache() + + private(set) var follow = WorkChatFollowState.initial + + var rowContent: ((WorkChatTranscriptRow) -> AnyView)? + var onFollowChange: ((Bool) -> Void)? + var onGeometryChange: ((WorkChatTranscriptGeometry) -> Void)? + var onViewportChange: ((CGSize) -> Void)? + + private var rowsById: [String: WorkChatTranscriptRow] = [:] + private var orderedRowIds: [String] = [] + private var contentRevision = 0 + private var hasAppliedFirstNonEmptySnapshot = false + + /// Set whenever the content changed while following; consumed by the next + /// layout pass. Not a retry ladder — it is armed by a content change and + /// cleared by the pass that acts on it. + private var followPinNeeded = false + /// Suppresses the follow pin while a user-initiated jump animation runs, so + /// the animation is not cancelled by the pin it is heading towards. + private var animatingToLatest = false + private var lastBoundsSize: CGSize = .zero + private var lastContentHeight: CGFloat = 0 + /// The offset to put back across a viewport resize while not following. + private var stableOffsetY: CGFloat = 0 + /// The row the reader is on, and where it sits, sampled continuously while + /// not following. A cell re-measuring is not always inside a snapshot apply + /// — a reconfigured cell measures on a later pass — so the anchor has to be + /// something the next layout pass can restore to, not something captured + /// once per update. + private var liveAnchor: Anchor? + private var settleWorkItem: DispatchWorkItem? + /// A scroll write inside a layout pass can re-enter the pass. + private var isHandlingLayoutPass = false + + /// Gap between rows, matching the transcript's former `LazyVStack` spacing. + private let rowSpacing: CGFloat = 14 + + override func viewDidLoad() { + super.viewDidLoad() + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) + configuration.showsSeparators = false + configuration.backgroundColor = .clear + let layout = UICollectionViewCompositionalLayout.list(using: configuration) + + let collectionView = WorkChatTranscriptCollectionViewBody( + frame: .zero, + collectionViewLayout: layout + ) + collectionView.backgroundColor = .clear + collectionView.showsVerticalScrollIndicator = false + collectionView.showsHorizontalScrollIndicator = false + collectionView.alwaysBounceVertical = true + collectionView.keyboardDismissMode = .interactive + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.delegate = self + collectionView.onDidLayout = { [weak self] in self?.handleLayoutPass() } + collectionView.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + self.collectionView = collectionView + + let registration = UICollectionView.CellRegistration { + [weak self] cell, _, rowId in + guard let self, let row = self.rowsById[rowId] else { return } + // The list layout gives every item the full container width, so the + // collection view's own width is the measurement width — the cell's is + // still zero the first time it is configured. + let width = self.collectionView.bounds.width + cell.heightCache = self.heightCache + cell.cacheKey = WorkChatTranscriptHeightKey( + rowId: rowId, + width: width, + revision: row.revision &+ self.contentRevision + ) + cell.backgroundConfiguration = .clear() + let content = self.rowContent?(row) ?? AnyView(EmptyView()) + cell.contentConfiguration = UIHostingConfiguration { content } + .margins(.horizontal, 16) + .margins(.vertical, self.rowSpacing / 2) + } + + dataSource = UICollectionViewDiffableDataSource( + collectionView: collectionView + ) { collectionView, indexPath, rowId in + collectionView.dequeueConfiguredReusableCell( + using: registration, + for: indexPath, + item: rowId + ) + } + } + + func teardown() { + settleWorkItem?.cancel() + settleWorkItem = nil + heightCache.removeAll() + } + + func setContentInsets(top: CGFloat, bottom: CGFloat) { + guard isViewLoaded else { return } + let insets = UIEdgeInsets(top: top, left: 0, bottom: bottom, right: 0) + guard collectionView.contentInset != insets else { return } + let wasFollowing = follow.following + collectionView.contentInset = insets + collectionView.verticalScrollIndicatorInsets = insets + if wasFollowing { + followPinNeeded = true + } + } + + // MARK: Snapshot + + func apply(rows: [WorkChatTranscriptRow], contentRevision revision: Int) { + guard isViewLoaded else { return } + let nextIds = rows.map(\.id) + let revisionChanged = revision != contentRevision + let changedRowIds: [String] = rows.compactMap { row in + guard let previous = rowsById[row.id] else { return nil } + return previous.revision == row.revision ? nil : row.id + } + let orderChanged = nextIds != orderedRowIds + guard orderChanged || revisionChanged || !changedRowIds.isEmpty else { return } + + // Against the row order that is still on screen: the anchor is a row the + // reader can see, and resolving it after the list is replaced would map + // its index onto whatever the insert put there. + let anchor = captureAnchor() + let previousContentHeight = collectionView.contentSize.height + + contentRevision = revision + rowsById = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) + orderedRowIds = nextIds + heightCache.prune(keeping: Set(nextIds)) + + var snapshot = NSDiffableDataSourceSnapshot() + snapshot.appendSections([.main]) + snapshot.appendItems(nextIds, toSection: .main) + let reconfigure = revisionChanged ? nextIds : changedRowIds + if !reconfigure.isEmpty { + snapshot.reconfigureItems(reconfigure) + } + dataSource.apply(snapshot, animatingDifferences: false) + collectionView.layoutIfNeeded() + + let isFirstContent = !hasAppliedFirstNonEmptySnapshot && !nextIds.isEmpty + if isFirstContent { + hasAppliedFirstNonEmptySnapshot = true + } + + WorkChatScrollTrace.contentSize( + previousContent: previousContentHeight, + nextContent: collectionView.contentSize.height, + previousContainer: lastBoundsSize.height, + nextContainer: collectionView.bounds.height, + offsetY: collectionView.contentOffset.y, + following: follow.following + ) + + if follow.following { + // Appends while following land at the tail. Everything else the pass + // moved is below the reader by construction. + followPinNeeded = true + if !animatingToLatest, !follow.inUserSession { + performScrollWrite( + .pinToBottom(animated: false), + reason: isFirstContent ? "initial-content" : "content-append" + ) + } + } else if let anchor { + // Content inserted or resized above the reader. Put the anchored row + // back where it was; UIKit already compensated whatever it measured + // itself, and this covers the rest. + restoreAnchor(anchor) + liveAnchor = captureAnchor() + } + lastContentHeight = collectionView.contentSize.height + } + + // MARK: Anchoring + + struct Anchor { + let rowId: String + /// Where the row's top sat relative to the viewport's top edge. + let offsetFromViewportTop: CGFloat + } + + private func captureAnchor() -> Anchor? { + let visible = collectionView.indexPathsForVisibleItems.sorted() + guard let indexPath = visible.first, + let attributes = collectionView.layoutAttributesForItem(at: indexPath), + indexPath.item < orderedRowIds.count + else { return nil } + return Anchor( + rowId: orderedRowIds[indexPath.item], + offsetFromViewportTop: attributes.frame.minY - collectionView.contentOffset.y + ) + } + + @discardableResult + private func restoreAnchor(_ anchor: Anchor, reason: String = "anchor-restore") -> Bool { + guard let index = orderedRowIds.firstIndex(of: anchor.rowId), + let attributes = collectionView.layoutAttributesForItem( + at: IndexPath(item: index, section: 0) + ) + else { return false } + let target = attributes.frame.minY - anchor.offsetFromViewportTop + performScrollWrite(.setOffset(target), reason: reason) + return true + } + + // MARK: The single scroll writer + + private enum ScrollWrite { + case pinToBottom(animated: Bool) + case setOffset(CGFloat) + case revealRow(String) + } + + /// The only function in the transcript that moves the viewport. + private func performScrollWrite(_ write: ScrollWrite, reason: String) { + let before = collectionView.contentOffset.y + let minOffset = -collectionView.contentInset.top + let maxOffset = max( + minOffset, + collectionView.contentSize.height - collectionView.bounds.height + + collectionView.contentInset.bottom + ) + + let target: CGFloat + var animated = false + switch write { + case .pinToBottom(let isAnimated): + target = maxOffset + animated = isAnimated + case .setOffset(let y): + target = min(max(minOffset, y), maxOffset) + case .revealRow(let rowId): + guard let index = orderedRowIds.firstIndex(of: rowId), + let attributes = collectionView.layoutAttributesForItem( + at: IndexPath(item: index, section: 0) + ) + else { return } + let bottomAligned = attributes.frame.maxY - collectionView.bounds.height + + collectionView.contentInset.bottom + target = min(max(minOffset, bottomAligned), maxOffset) + animated = true + } + + guard animated || abs(target - before) > 0.5 else { return } + + WorkChatScrollTrace.write( + reason: reason, + target: animated ? "y=\(target)(animated)" : "y=\(target)", + site: "WorkChatTranscriptCollectionView.swift:performScrollWrite", + offsetBefore: before, + contentHeight: collectionView.contentSize.height, + containerHeight: collectionView.bounds.height, + scrollableHeight: max(0, maxOffset - minOffset), + following: follow.following, + userDrivenPhase: follow.inUserSession + ) + collectionView.setContentOffset(CGPoint(x: 0, y: target), animated: animated) + if !animated { + stableOffsetY = target + } + } + + // MARK: Commands from SwiftUI + + func jumpToLatest(animated: Bool, reason: String) { + guard isViewLoaded else { return } + applyFollowEvent(animated ? .jumpToLatest : .sendMessage, reason: reason) + followPinNeeded = true + if animated { + animatingToLatest = true + } + performScrollWrite(.pinToBottom(animated: animated), reason: reason) + if animated, !collectionView.isDragging, collectionView.contentOffset.y >= 0 { + // `setContentOffset(animated:)` does not always raise + // `scrollViewDidEndScrollingAnimation` when the target was already + // reached; clear the suppression on the next runloop turn in that case. + let work = DispatchWorkItem { [weak self] in self?.animatingToLatest = false } + settleWorkItem?.cancel() + settleWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6, execute: work) + } + } + + func scrollRowIntoView(id: String, reason: String) { + guard isViewLoaded else { return } + performScrollWrite(.revealRow(id), reason: reason) + } + + func resetForNewSession() { + guard isViewLoaded else { return } + heightCache.removeAll() + hasAppliedFirstNonEmptySnapshot = false + animatingToLatest = false + settleWorkItem?.cancel() + settleWorkItem = nil + applyFollowEvent(.reset, reason: "session-reset") + liveAnchor = nil + followPinNeeded = true + } + + func noteDisclosureSettled() { + guard isViewLoaded else { return } + applyFollowEvent(.disclosureSettled(isAtEnd: isAtEnd), reason: "disclosure-settled") + } + + // MARK: Latch plumbing + + private var isAtEnd: Bool { + distanceFromBottom <= workChatFollowEndTolerance + } + + private var distanceFromBottom: CGFloat { + let maxOffset = collectionView.contentSize.height - collectionView.bounds.height + + collectionView.contentInset.bottom + return max(0, maxOffset - collectionView.contentOffset.y) + } + + private var distanceFromTop: CGFloat { + max(0, collectionView.contentOffset.y + collectionView.contentInset.top) + } + + private func applyFollowEvent(_ event: WorkChatFollowEvent, reason: String) { + let next = workChatFollowLatch(follow, event) + guard next != follow else { return } + let followingChanged = next.following != follow.following + follow = next + guard followingChanged else { return } + WorkChatScrollTrace.follow(next.following, reason: reason) + emit { [weak self] in + guard let self else { return } + self.onFollowChange?(self.follow.following) + } + } + + /// Callbacks always cross a runloop turn. Every site that raises one — a + /// snapshot apply, a layout pass, a scroll frame — can be inside SwiftUI's + /// own update, and writing observed state there is undefined. + private func emit(_ block: @escaping () -> Void) { + DispatchQueue.main.async(execute: block) + } + + var currentGeometry: WorkChatTranscriptGeometry { + let maxOffset = collectionView.contentSize.height - collectionView.bounds.height + + collectionView.contentInset.bottom + return WorkChatTranscriptGeometry( + distanceFromTop: distanceFromTop, + distanceFromBottom: distanceFromBottom, + // A transcript that has not been measured yet does not "fit" — it is + // unknown. Reporting the empty first pass as fitting is what let the + // short-transcript history pull run before anything was on screen. + contentFitsViewport: collectionView.contentSize.height > 0 + && maxOffset <= -collectionView.contentInset.top + 0.5 + ) + } + + private func publishGeometry() { + let geometry = currentGeometry + emit { [weak self] in self?.onGeometryChange?(geometry) } + } + + // MARK: Layout + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + handleLayoutPass() + } + + /// One place that reacts to "the transcript just laid out", whichever of the + /// controller's view or the collection view's own cells caused it. + private func handleLayoutPass() { + guard isViewLoaded, !isHandlingLayoutPass else { return } + isHandlingLayoutPass = true + defer { isHandlingLayoutPass = false } + let bounds = collectionView.bounds.size + let boundsChanged = abs(bounds.height - lastBoundsSize.height) > 0.5 + || abs(bounds.width - lastBoundsSize.width) > 0.5 + if boundsChanged { + let previous = lastBoundsSize + lastBoundsSize = bounds + if abs(bounds.width - previous.width) > 0.5 { + // Every cached height was measured at the old width. + heightCache.removeAll() + } + let size = bounds + emit { [weak self] in self?.onViewportChange?(size) } + if previous.height > 0 { + // The keyboard, the composer growing, or a card collapsing. Following + // stays glued to the tail; everyone else keeps their row. + if follow.following { + followPinNeeded = true + } else if let liveAnchor, restoreAnchor(liveAnchor, reason: "viewport-resize") { + // Anchored on the row, which survives a re-measure the saved offset + // would not. + } else { + performScrollWrite(.setOffset(stableOffsetY), reason: "viewport-resize") + } + } + } + + let contentHeight = collectionView.contentSize.height + let contentChanged = abs(contentHeight - lastContentHeight) > 0.5 + lastContentHeight = contentHeight + + // A pin is the app moving the viewport, so it waits out the reader's whole + // interaction even when the latch says they are still on the tail — + // writing an offset under a live finger is what killed flings. + if follow.following, !follow.inUserSession, !animatingToLatest, + followPinNeeded || contentChanged || boundsChanged { + followPinNeeded = false + performScrollWrite(.pinToBottom(animated: false), reason: "follow-pin") + } else if !follow.following || follow.inUserSession, let liveAnchor { + // A cell measured itself away from its estimate after the update that + // asked for it — sometimes without changing the total content height at + // all, when rows above the reader redistribute. The restore is absolute + // rather than incremental, so when UIKit has already compensated + // correctly this writes nothing. + restoreAnchor(liveAnchor, reason: "anchor-relayout") + } + traceViewportAnchor() + publishGeometry() + } + + // MARK: UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + WorkChatScrollTrace.phase(userDriven: true, raw: "willBeginDragging") + settleWorkItem?.cancel() + settleWorkItem = nil + animatingToLatest = false + applyFollowEvent(.userScrollBegin, reason: "drag-begin") + } + + /// Where the reader's row actually sits on screen, once the pass that could + /// have moved it is over. + /// + /// `contentOffset` is the wrong thing to measure a jump with once UIKit is + /// compensating its own self-sizing: a cell above the viewport re-measuring + /// moves the offset by exactly the amount needed to leave the screen still. + /// This is the ground truth the bench reduces to "did anything move". + private func traceViewportAnchor() { + let visible = collectionView.indexPathsForVisibleItems.sorted() + guard let indexPath = visible.first, + indexPath.item < orderedRowIds.count, + let attributes = collectionView.layoutAttributesForItem(at: indexPath) + else { return } + WorkChatScrollTrace.viewport( + rowId: orderedRowIds[indexPath.item], + offsetInViewport: attributes.frame.minY - collectionView.contentOffset.y, + userDriven: follow.inUserSession, + following: follow.following + ) + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + if !follow.inUserSession, !scrollView.isDragging, !scrollView.isDecelerating { + stableOffsetY = scrollView.contentOffset.y + } + WorkChatScrollTrace.sample( + offsetY: scrollView.contentOffset.y, + contentHeight: scrollView.contentSize.height, + containerHeight: scrollView.bounds.height, + scrollableHeight: max(0, scrollView.contentSize.height - scrollView.bounds.height), + distanceFromBottom: distanceFromBottom, + userDrivenPhase: follow.inUserSession, + following: follow.following + ) + applyFollowEvent(.scroll(isAtEnd: isAtEnd), reason: "scroll") + // Only the reader may move the anchor. A scroll frame produced by UIKit + // compensating a cell that re-measured is the very thing the anchor exists + // to undo, so re-sampling on it would adopt the displaced position as the + // new truth and the correction would never run. + if !follow.following, follow.inUserSession || scrollView.isDragging || scrollView.isDecelerating { + liveAnchor = captureAnchor() + } + // The viewport reading belongs to the layout pass, not to this callback: + // UIKit raises `didScroll` from inside `layoutSubviews` while it is + // compensating a cell that re-measured, which is a state no frame is ever + // composited from. + publishGeometry() + } + + func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + guard !decelerate else { return } + scheduleUserScrollSettle() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + endUserScrollSession(reason: "momentum-end") + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + animatingToLatest = false + if follow.following { + followPinNeeded = true + view.setNeedsLayout() + } + } + + /// A drag that reported no momentum still has a moment of UIKit settling + /// after finger-up. Ending the session on that frame would read the offset + /// mid-settle and drop follow for a reader who let go at the tail. + private func scheduleUserScrollSettle() { + settleWorkItem?.cancel() + let work = DispatchWorkItem { [weak self] in + self?.endUserScrollSession(reason: "settle") + } + settleWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.16, execute: work) + } + + private func endUserScrollSession(reason: String) { + settleWorkItem?.cancel() + settleWorkItem = nil + WorkChatScrollTrace.phase(userDriven: false, raw: reason) + stableOffsetY = collectionView.contentOffset.y + applyFollowEvent(.userScrollEnd(isAtEnd: isAtEnd), reason: reason) + liveAnchor = follow.following ? nil : captureAnchor() + if follow.following { + followPinNeeded = true + view.setNeedsLayout() + } + } +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index ec2b957b5b..eb67422fb3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -55,12 +55,10 @@ func workChatSendWillQueueMessage( isLive && !hostReachable && chatSendQueueable } -/// Child recovery cards carry their own source session id, so viewing a -/// subagent transcript must not hide actions that the paired host supports. -func workChatCodexRecoveryAvailable( - hostSupportsRecovery: Bool, - viewingSubagent _: Bool -) -> Bool { +/// Recovery is the host's capability and nothing else's: a recovery card +/// carries its own source session id, so no view-level state may hide an +/// action the paired host supports. +func workChatCodexRecoveryAvailable(hostSupportsRecovery: Bool) -> Bool { hostSupportsRecovery } @@ -686,15 +684,13 @@ func workChatHasOlderTranscriptHistory( /// the composer badge read this policy instead. struct WorkChatLanePrPolicy { var showsLaneActions: Bool - var viewingSubagent = false /// Gate for every lane→PR lookup. False means no network or IPC work runs and /// the badge state stays empty. var resolvesLanePr: Bool { showsLaneActions } /// Gate for the PR badge handed to `WorkChatSessionView` above the composer. - /// Subagent transcripts never carry one either. - var rendersPrBadge: Bool { resolvesLanePr && !viewingSubagent } + var rendersPrBadge: Bool { resolvesLanePr } } struct WorkSessionDestinationView: View { @@ -1848,10 +1844,7 @@ struct WorkSessionDestinationView: View { let localEchoMessagesForView: [WorkLocalEchoMessage] = localEchoMessages let sessionStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) let shouldSteer = hostReachable && sessionStatus == "active" - let prPolicy = WorkChatLanePrPolicy( - showsLaneActions: showsLaneActions, - viewingSubagent: false - ) + let prPolicy = WorkChatLanePrPolicy(showsLaneActions: showsLaneActions) let chatPrBadge: WorkChatPrBadgeModel? = prPolicy.rendersPrBadge ? workChatPrBadgeModel( tag: chatDisplayPrTag, @@ -2027,7 +2020,6 @@ struct WorkSessionDestinationView: View { subagentSnapshotsRenderSignature: subagentSnapshotsRenderSignature, scheduledWorkSnapshots: scheduledWorkSnapshots, scheduledWorkSnapshotsRenderSignature: scheduledWorkSnapshotsRenderSignature, - selectedSubagentTaskId: nil, // One chip, one destination: subagents, background work and schedules // all live in the Chat Info sheet. Timeline-row selection stays scoped // to the parent chat, so nested transcript state cannot accidentally @@ -2065,10 +2057,9 @@ struct WorkSessionDestinationView: View { || syncService.canInvokeRemoteAction("personalChats.modelCatalog"), personalSessionUpdatesAvailable: !personalChat || syncService.canInvokeRemoteAction("personalChats.updateSession"), - onRecoverCodexTurn: workChatCodexRecoveryAvailable( - hostSupportsRecovery: supportsRecovery, - viewingSubagent: false - ) ? recoverCodexTurn : nil, + onRecoverCodexTurn: workChatCodexRecoveryAvailable(hostSupportsRecovery: supportsRecovery) + ? recoverCodexTurn + : nil, onRunUnprocessedMessage: supportsUnprocessedResolution ? runUnprocessedMessage : nil, diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 856676c2b4..7606f12ec0 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16806,426 +16806,72 @@ final class ADETests: XCTestCase { ), 0) } - // MARK: - Work chat transcript scroll policy + // MARK: - Work chat transcript follow latch - func testProgrammaticScrollWaitsForTheWholeInteractionNotJustTheDrag() { - XCTAssertTrue(workChatMayWriteScrollOffset(dragActive: false, scrollPhaseUserDriven: false)) - XCTAssertFalse(workChatMayWriteScrollOffset(dragActive: true, scrollPhaseUserDriven: false)) - // Finger-up ends the drag gesture, but the fling it launched still owns the - // offset. Writing here is what killed flings mid-deceleration. - XCTAssertFalse(workChatMayWriteScrollOffset(dragActive: false, scrollPhaseUserDriven: true)) + /// The latch is the transcript's whole scroll authority, so these are the + /// rules a jump has to be explained by. + func testFollowOpensOnAndIsReleasedOnlyByTheReader() { + var state = WorkChatFollowState.initial + XCTAssertTrue(state.following) - XCTAssertTrue(workChatScrollPhaseIsUserDriven(.tracking)) - XCTAssertTrue(workChatScrollPhaseIsUserDriven(.interacting)) - XCTAssertTrue(workChatScrollPhaseIsUserDriven(.decelerating)) - XCTAssertFalse(workChatScrollPhaseIsUserDriven(.idle)) - // `.animating` is OUR animation. Treating it as the reader's would let one - // programmatic scroll suppress the next one. - XCTAssertFalse(workChatScrollPhaseIsUserDriven(.animating)) - } - - func testFollowingViewportShrinkPinsToLatest() { - // Keyboard/composer shrink: same class of bug as - // `testKeyboardShrinkFlipsTailPredicateWithUnchangedOffset` on the - // terminal. Follow must re-glue rather than consult the inflated distance. - let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: 340) - XCTAssertEqual(viewportDelta, 340) - XCTAssertTrue(workChatLayoutWindowChanged(containerDelta: -340, viewportDelta: viewportDelta)) - XCTAssertTrue(workChatLayoutWindowChanged(containerDelta: 0, viewportDelta: viewportDelta)) - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: true, - containerDelta: -340, - contentDelta: 0, - viewportDelta: viewportDelta, - previousOffsetY: 4200, - nextScrollableHeight: 4540 - ), - .pinToLatest - ) - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: true, - containerDelta: 0, - contentDelta: 0, - viewportDelta: viewportDelta, - previousOffsetY: 4200, - nextScrollableHeight: 4540 - ), - .pinToLatest - ) - } - - func testFollowingViewportGrowPinsToLatest() { - // Keyboard hide grows the window; a following viewport re-glues to the - // real end rather than sitting on the offset the smaller window left. - let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: -340) - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: true, - containerDelta: 340, - contentDelta: 0, - viewportDelta: viewportDelta, - previousOffsetY: 4540, - nextScrollableHeight: 4200 - ), - .pinToLatest - ) - } - - func testReadingHistoryViewportShrinkRestoresOffsetWithoutPinning() { - let viewportDelta = workChatLayoutViewportDelta(contentDelta: 0, scrollableDelta: 340) - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: false, - mayWriteScrollOffset: true, - containerDelta: -340, - contentDelta: 0, - viewportDelta: viewportDelta, - previousOffsetY: 1800, - nextScrollableHeight: 4540 - ), - .restoreOffset(1800) - ) - } - - func testFollowingContentShrinkPinsToLatest() { - // Cards collapsing at turn-end shorten the tape under a following - // viewport. Pinning to the real end is what avoids the blank-tail look. - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: true, - containerDelta: 0, - contentDelta: -400, - viewportDelta: 0, - previousOffsetY: 4200, - nextScrollableHeight: 3800 - ), - .pinToLatest - ) - } - - func testReadingHistoryContentShrinkClampsOffsetToNewRange() { - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: false, - mayWriteScrollOffset: true, - containerDelta: 0, - contentDelta: -3000, - viewportDelta: 0, - previousOffsetY: 4000, - nextScrollableHeight: 1200 - ), - .restoreOffset(1200) - ) - } - - func testReadingHistoryContentGrowthDoesNotMoveOffset() { - // Streaming into the tail while the reader is in history must not restore - // a stale offset or yank to latest. The prepend machinery owns insertion - // above; growth below should leave the reader put. - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: false, - mayWriteScrollOffset: true, - containerDelta: 0, - contentDelta: 240, - viewportDelta: 0, - previousOffsetY: 1800, - nextScrollableHeight: 4440 - ), - .none - ) - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: true, - containerDelta: 0, - contentDelta: 240, - viewportDelta: 0, - previousOffsetY: 4200, - nextScrollableHeight: 4440 - ), - .none - ) - } - - func testLayoutPinDefersToReaderDuringFling() { - XCTAssertEqual( - workChatLayoutScrollAdjustment( - following: true, - mayWriteScrollOffset: false, - containerDelta: -340, - contentDelta: 0, - viewportDelta: 340, - previousOffsetY: 4200, - nextScrollableHeight: 4540 - ), - .none - ) - } - - func testKeyboardUserPhaseDoesNotReleaseFollow() { - XCTAssertTrue( - workChatShouldIgnoreUserScrollPhaseForLayout( - userDrivenPhase: true, - layoutAdjustedRecently: true - ) - ) - XCTAssertFalse( - workChatShouldReleaseFollowForUserScroll( - following: true, - userDrivenPhase: true, - layoutAdjustedRecently: true, - distanceFromBottom: 300, - offsetRetreat: 0 - ) - ) - XCTAssertTrue( - workChatShouldReleaseFollowForUserScroll( - following: true, - userDrivenPhase: true, - layoutAdjustedRecently: true, - distanceFromBottom: 300, - offsetRetreat: 3 - ) - ) - XCTAssertTrue( - workChatLayoutAdjustedRecently( - lastAdjustmentUptime: 10, - now: 10.2, - grace: workChatLayoutFollowGraceSeconds - ) - ) - XCTAssertFalse( - workChatLayoutAdjustedRecently( - lastAdjustmentUptime: 10, - now: 10.5, - grace: workChatLayoutFollowGraceSeconds - ) - ) - } + // A pin, a jump animation, or UIKit compensating a re-measured cell all + // arrive as scroll frames outside a user session. None of them may move + // the latch — that feedback loop is what the old machinery could not + // close. + state = workChatFollowLatch(state, .scroll(isAtEnd: false)) + XCTAssertTrue(state.following) - func testUserScrollPhaseReleasesFollowOnceLayoutIsStable() { - XCTAssertTrue( - workChatShouldReleaseFollowForUserScroll( - following: true, - userDrivenPhase: true, - layoutAdjustedRecently: false, - distanceFromBottom: 3, - offsetRetreat: 0 - ) - ) - XCTAssertFalse( - workChatShouldReleaseFollowForUserScroll( - following: true, - userDrivenPhase: true, - layoutAdjustedRecently: false, - distanceFromBottom: 0, - offsetRetreat: 0 - ) - ) - XCTAssertFalse( - workChatShouldReleaseFollowForUserScroll( - following: false, - userDrivenPhase: true, - layoutAdjustedRecently: false, - distanceFromBottom: 80, - offsetRetreat: 80 - ) - ) - XCTAssertFalse( - workChatShouldIgnoreUserScrollPhaseForLayout( - userDrivenPhase: true, - layoutAdjustedRecently: false - ) - ) + state = workChatFollowLatch(state, .userScrollBegin) + XCTAssertFalse(state.following) + XCTAssertTrue(state.inUserSession) } - func testKeyboardDoesNotReclaimFollowOnceTheReaderHasLeftTheTail() { - XCTAssertTrue( - workChatShouldReclaimFollowAfterWindowChange( - following: false, - distanceFromPreviousTail: 0 - ) - ) - XCTAssertTrue( - workChatShouldReclaimFollowAfterWindowChange( - following: false, - distanceFromPreviousTail: workChatTouchScrollDeadband - ) - ) - XCTAssertFalse( - workChatShouldReclaimFollowAfterWindowChange( - following: false, - distanceFromPreviousTail: workChatTouchScrollDeadband + 1 - ) - ) - XCTAssertFalse( - workChatShouldReclaimFollowAfterWindowChange( - following: true, - distanceFromPreviousTail: 0 - ) - ) - } + func testFollowResumesOnlyWhenTheReaderLetsGoAtTheEnd() { + var away = workChatFollowLatch(.initial, .userScrollBegin) + away = workChatFollowLatch(away, .scroll(isAtEnd: false)) + let settledUp = workChatFollowLatch(away, .userScrollEnd(isAtEnd: false)) + XCTAssertFalse(settledUp.following) + XCTAssertFalse(settledUp.inUserSession) - func testShortTranscriptRendersFromTheTop() { - XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: true), .topLeading) - XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: false), .bottomLeading) + let settledAtEnd = workChatFollowLatch(away, .userScrollEnd(isAtEnd: true)) + XCTAssertTrue(settledAtEnd.following) + XCTAssertFalse(settledAtEnd.inUserSession) } - func testPrependCorrectionBailsOutWhenTheProbeDescribesAnotherRow() { - let anchor = WorkChatPrependAnchor( - rowId: "message-42", - rowY: 100, - offsetY: 500, - remainingAttempts: workChatPrependAnchorAttempts - ) - - // The probe is measuring some other row, so it says nothing about the - // anchored one. Falling through with a zero row shift would reduce the - // correction to the reader's own scroll delta and apply it a second time. - XCTAssertEqual( - workChatPrependCorrection( - anchor: anchor, - probed: WorkChatPrependProbeSample(rowId: "message-99", y: 340), - currentOffsetY: 740, - mayWriteScrollOffset: true - ), - .retry - ) - XCTAssertEqual( - workChatPrependCorrection( - anchor: anchor, - probed: nil, - currentOffsetY: 740, - mayWriteScrollOffset: true - ), - .retry + /// Ending a session that never started — which is what a stray settle + /// timer or a synthesized end would be — must not hand follow back. + func testUserScrollEndOutsideASessionCannotGrantFollow() { + let state = workChatFollowLatch( + WorkChatFollowState(following: false, inUserSession: false), + .userScrollEnd(isAtEnd: true) ) + XCTAssertFalse(state.following) } - func testPrependCorrectionIsolatesTheInsertionFromTheReadersOwnScrolling() { - let anchor = WorkChatPrependAnchor( - rowId: "message-42", - rowY: 100, - offsetY: 500, - remainingAttempts: workChatPrependAnchorAttempts - ) - // 900pt inserted above while the reader scrolled 240pt: the row moves - // 900 - 240 and the offset moves 240, so the sum is the insertion. - XCTAssertEqual( - workChatPrependCorrection( - anchor: anchor, - probed: WorkChatPrependProbeSample(rowId: "message-42", y: 100 + 900 - 240), - currentOffsetY: 500 + 240, - mayWriteScrollOffset: true - ), - .apply(900) - ) - // A pure scroll with no prepend sums to zero and correctly restores nothing. - XCTAssertEqual( - workChatPrependCorrection( - anchor: anchor, - probed: WorkChatPrependProbeSample(rowId: "message-42", y: 100 - 240), - currentOffsetY: 500 + 240, - mayWriteScrollOffset: true - ), - .retry - ) + func testSendingAndJumpingAlwaysFollow() { + let reading = WorkChatFollowState(following: false, inUserSession: false) + XCTAssertTrue(workChatFollowLatch(reading, .sendMessage).following) + XCTAssertTrue(workChatFollowLatch(reading, .jumpToLatest).following) + XCTAssertFalse(workChatFollowLatch(reading, .jumpToLatest).inUserSession) } - func testPrependCorrectionWaitsOutTheReaderWithoutSpendingAnAttempt() { - let anchor = WorkChatPrependAnchor( - rowId: "message-42", - rowY: 100, - offsetY: 500, - remainingAttempts: 1 - ) - XCTAssertEqual( - workChatPrependCorrection( - anchor: anchor, - probed: WorkChatPrependProbeSample(rowId: "message-42", y: 1_000), - currentOffsetY: 500, - mayWriteScrollOffset: false - ), - .wait - ) - } + /// A card collapsing can leave the reader sitting on the end without any + /// gesture. That re-follows; a card collapsing while they are mid-fling + /// does not, because the fling still owns the offset. + func testDisclosureSettleOnlyReclaimsFollowOutsideAUserSession() { + let reading = WorkChatFollowState(following: false, inUserSession: false) + XCTAssertTrue(workChatFollowLatch(reading, .disclosureSettled(isAtEnd: true)).following) + XCTAssertFalse(workChatFollowLatch(reading, .disclosureSettled(isAtEnd: false)).following) - func testOverlappingPrependsKeepTheAnchorThatAccumulatesBoth() { - // Second page lands while the first correction is still open. Re-arming on - // the new first row would measure only the second insertion and leave the - // first one uncorrected. - XCTAssertEqual( - workChatPrependArmDecision( - previousFirstId: "message-20", - nextFirstId: "message-10", - previousVisibleCount: 40, - nextVisibleCount: 60, - existingAnchorRowId: "message-42", - anchorRowStillVisible: true, - previousFirstRowStillVisible: true, - probeRowId: "message-42", - probeRowY: 220 - ), - .extendExistingAnchorWindow - ) - // Unless the anchored row is gone, in which case there is nothing left to - // measure against. - XCTAssertEqual( - workChatPrependArmDecision( - previousFirstId: "message-20", - nextFirstId: "message-10", - previousVisibleCount: 40, - nextVisibleCount: 60, - existingAnchorRowId: "message-42", - anchorRowStillVisible: false, - previousFirstRowStillVisible: true, - probeRowId: "message-42", - probeRowY: 220 - ), - .retireAnchor - ) + let flinging = WorkChatFollowState(following: false, inUserSession: true) + XCTAssertFalse(workChatFollowLatch(flinging, .disclosureSettled(isAtEnd: true)).following) } - func testPrependAnchorOnlyArmsOnAGenuineInsertionAbove() { - func decision( - previousFirstId: String? = "message-20", - nextFirstId: String? = "message-10", - previousVisibleCount: Int = 40, - nextVisibleCount: Int = 60, - previousFirstRowStillVisible: Bool = true, - probeRowId: String? = "message-20", - probeRowY: CGFloat? = 180 - ) -> WorkChatPrependArmDecision { - workChatPrependArmDecision( - previousFirstId: previousFirstId, - nextFirstId: nextFirstId, - previousVisibleCount: previousVisibleCount, - nextVisibleCount: nextVisibleCount, - existingAnchorRowId: nil, - anchorRowStillVisible: false, - previousFirstRowStillVisible: previousFirstRowStillVisible, - probeRowId: probeRowId, - probeRowY: probeRowY - ) - } - - XCTAssertEqual(decision(), .arm(rowId: "message-20", rowY: 180)) - // Appended, not prepended: the list grew but still leads with the same row. - XCTAssertEqual(decision(nextFirstId: "message-20"), .ignore) - // Rows replaced rather than inserted. - XCTAssertEqual(decision(nextVisibleCount: 40), .ignore) - // The leading row is gone, so this is not a prepend. - XCTAssertEqual(decision(previousFirstRowStillVisible: false), .ignore) - // The probe was measuring a different row, so there is no "before" position. - XCTAssertEqual(decision(probeRowId: "message-77"), .ignore) - XCTAssertEqual(decision(probeRowY: nil), .ignore) + func testResetOpensAtTheTailAndClearsAnyUserSession() { + let stuck = WorkChatFollowState(following: false, inUserSession: true) + let state = workChatFollowLatch(stuck, .reset) + XCTAssertTrue(state.following) + XCTAssertFalse(state.inUserSession) } func testMobileChatHistoryTriggerAndRenderCapStayBounded() { @@ -17373,19 +17019,6 @@ final class ADETests: XCTestCase { hasHostHistory: true )) - XCTAssertTrue(workChatShouldInstallPrependProbe( - distanceFromTop: 0, - hasPrependAnchor: false - )) - XCTAssertFalse(workChatShouldInstallPrependProbe( - distanceFromTop: workChatOlderHistoryTriggerDistance + 1, - hasPrependAnchor: false - )) - XCTAssertTrue(workChatShouldInstallPrependProbe( - distanceFromTop: workChatOlderHistoryTriggerDistance + 1, - hasPrependAnchor: true - )) - XCTAssertEqual( workTimelineVisibleCountAfterHistoryPrepend( currentVisibleCount: workTimelinePageSize, @@ -20623,15 +20256,9 @@ final class ADETests: XCTestCase { XCTAssertEqual(cards.first?.metadata, ["Automatic recovery"]) } - func testCodexRecoveryRemainsAvailableInSubagentTranscriptWhenHostSupportsIt() { - XCTAssertTrue(workChatCodexRecoveryAvailable( - hostSupportsRecovery: true, - viewingSubagent: true - )) - XCTAssertFalse(workChatCodexRecoveryAvailable( - hostSupportsRecovery: false, - viewingSubagent: true - )) + func testCodexRecoveryFollowsTheHostCapability() { + XCTAssertTrue(workChatCodexRecoveryAvailable(hostSupportsRecovery: true)) + XCTAssertFalse(workChatCodexRecoveryAvailable(hostSupportsRecovery: false)) } func testMcpConnectorIdentitySurvivesDecodedAndFallbackToolCards() throws { diff --git a/apps/ios/ADETests/WorkAssistantRenderingTests.swift b/apps/ios/ADETests/WorkAssistantRenderingTests.swift index 8d0063e041..69d6f5722e 100644 --- a/apps/ios/ADETests/WorkAssistantRenderingTests.swift +++ b/apps/ios/ADETests/WorkAssistantRenderingTests.swift @@ -328,12 +328,6 @@ final class WorkChatLanePrPolicyTests: XCTestCase { XCTAssertTrue(policy.rendersPrBadge) } - func testSubagentTranscriptRendersNoBadgeButStillResolves() { - let policy = WorkChatLanePrPolicy(showsLaneActions: true, viewingSubagent: true) - XCTAssertTrue(policy.resolvesLanePr) - XCTAssertFalse(policy.rendersPrBadge) - } - /// The composer badge input itself: even when a lane PR is somehow in state, /// a lane-action-free chat passes `nil` to `WorkChatSessionView`. func testCtoConfigurationPassesNoPrBadgeEvenWithAResolvedTag() { diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift index 57e07a0b64..94136e0a59 100644 --- a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -132,13 +132,18 @@ final class WorkChatScrollBenchUITests: XCTestCase { sleep(32) } - // (g) Open the keyboard at the tail, then again while scrolled up. + // (g) The keyboard, in both states the transcript can be in: scrolled up + // (the reader's row must not move) and following (the tail must stay glued). func testCaseG_keyboard() { let app = launch() sleep(6) let composer = app.textViews.firstMatch.exists ? app.textViews.firstMatch : app.textFields.firstMatch + + // (g1) Scrolled up, so the transcript is not following. + drag(app, fromY: 0.25, toY: 0.75, duration: 0.5) + sleep(2) if composer.waitForExistence(timeout: 4) { composer.tap() } else { @@ -146,14 +151,25 @@ final class WorkChatScrollBenchUITests: XCTestCase { .coordinate(withNormalizedOffset: CGVector(dx: 0.4, dy: 0.88)) .tap() } - sleep(4) - app.windows.firstMatch - .coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) - .tap() + sleep(5) + + // Interactive dismiss: dragging the transcript down puts the keyboard away. + drag(app, fromY: 0.35, toY: 0.9, duration: 0.6) sleep(3) - // Now scrolled up, reopen the keyboard. - drag(app, fromY: 0.25, toY: 0.75, duration: 0.5) - sleep(2) + + // (g2) Back to the tail, where the transcript follows again, then reopen. + // Through the pill rather than by dragging: the point of this half is the + // keyboard, and a drag that stops short leaves the transcript not + // following and tests the same thing (g1) already did. + let pill = app.buttons.matching(NSPredicate(format: "label CONTAINS[c] 'latest'")).firstMatch + if pill.waitForExistence(timeout: 3) { + pill.tap() + } else { + app.windows.firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.88, dy: 0.76)) + .tap() + } + sleep(4) if composer.exists { composer.tap() } diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 263465df58..0d3b5c47d7 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -863,48 +863,79 @@ represented rows rather than testing set membership — two sends of "continue" share one dedupe key, and one matching transcript row must retire exactly one of them (`workUnrepresentedLocalEchoMessages`). -**Prepended history does not move the reader.** Older pages insert above the -viewport, so the `LazyVStack` grows upward while `contentOffset` stays put. The -correction is measured on the row that led the list before the insert, via a -single geometry probe that rides that row (`WorkChatPrependProbePreferenceKey`), -and is applied through `ScrollPosition` in a non-animated transaction. -Deliberately not total content height: a reply streaming into the tail grows the -content at the same time, and a reader scrolled back through history is exactly -when that happens, so a total-height correction would add the tail's growth and -overshoot. Bottom-follow and the jump-to-latest pill are untouched. - -Three rules keep that correction from becoming a teleport. A probe sample that -describes a *different* row than the armed anchor is no measurement at all, so -it waits rather than falling through with a zero row shift — that would reduce -the correction to the reader's own scroll delta and apply it twice. Overlapping -prepends keep the *first* anchor rather than re-arming on the new leading row: -its row was pushed down by both insertions, so the displacement measured on it -already accumulates them. And a correction is a scroll write, so like every -other one it defers to the reader for the whole interaction — finger-down -through the end of the fling (`workChatMayWriteScrollOffset`, fed by -`onScrollPhaseChange`, not by the drag gesture, which ends at finger-up). - -**A chat opens where it was left, not at a random offset.** The transcript opens -at the tail through `defaultScrollAnchor(.bottom, for: .initialOffset)` — -scoped to the initial offset because the `.sizeChanges` anchor is the -total-height correction the paragraph above exists to avoid. The force-pin -remains as belt-and-braces, but it now stays armed until the content size has -been quiet for 600ms rather than firing on a fixed retry ladder, because -hydration routinely lands after that ladder ends. It stands down early only for -a deliberate scroll (the 2pt stickiness deadband — finger jitter and keyboard -`.interacting` do not count). A transcript shorter than the viewport renders from -the top, desktop-style; a one-entry chat skips the pin entirely. - -**Follow survives the keyboard the same way a terminal does.** Opening the -composer or the system keyboard shrinks the transcript window. A reader who was -glued to the live tail stays glued: the content-size observer re-pins to -`chat-end` after that pass (`workChatLayoutScrollAdjustment`), and the -keyboard's `.interacting` phase is not treated as the reader taking over — -consulting `distanceFromBottom` there is the same predicate flip the terminal -refuses to use on a layout resize. A reader who had scrolled up keeps that -place; the pre-keyboard offset is restored and clamped so a shorter window -cannot overscroll into blank. The same following re-pin runs when a finishing -turn collapses cards and the tape shrinks under the viewport. +**The transcript is a `UICollectionView`, not a `ScrollView`.** +`WorkChatTranscriptCollectionView` hosts a compositional list layout with a +diffable data source keyed by the timeline's own row ids, and every existing +SwiftUI row — bubbles, tool cards, subagent cards, the earlier-messages header, +the bottom gutter — renders unchanged inside a `UIHostingConfiguration` cell. +The reason is one property SwiftUI does not offer: UIKit compensates its own +content offset when a cell self-sizes away from its estimate, so a row +re-measuring above the viewport does not move the row the reader is looking at. +A `LazyVStack` has no such writer, and that missing writer was 88% of the +transcript's measured displacement. `WorkChatTranscriptHeightCache` keys a +measured height by row id + width + content revision, so a row scrolled back +into view is restored to the height the layout already believes it has instead +of re-measuring; the width changing (rotation) drops the cache wholesale. + +Row identity is the collection view's identity. A row whose content changed +carries a new *revision* and is reconfigured — never deleted and re-inserted — +because an insert would take the measured height and the reader's anchor with +it. `workChatTranscriptRowRevision` computes that revision, and the timeline +presentation's own signature is built from the same function, so "did the list +change" and "is this cell's measured height still valid" cannot drift apart. + +**One latch decides every scroll write.** `workChatFollowLatch` is a pure +function over `{following, inUserSession}` and a handful of events — +`reset`, `userScrollBegin`, `userScrollEnd`, `scroll`, `disclosureSettled`, +`sendMessage`, `jumpToLatest` — and is unit-tested on its own. A scroll frame +may only break follow *inside* a user session, which spans finger-down through +the end of momentum (with a 160 ms settle for a drag that reported none), so a +pin, a jump animation, and UIKit's own layout compensation cannot feed back +into the decision that authorized them. Exactly one function, +`performScrollWrite`, moves the viewport, and it refuses any write under 0.5 pt +— which is what makes the corrections below idempotent rather than a fight. + +**Content inserted above the reader keeps the reader's row in place.** While +not following, the transcript holds an anchor: the topmost visible row and how +far its top sat from the viewport's top edge. It is re-sampled only from the +reader's own scrolling — never from a frame UIKit produced while compensating a +re-measure, which is the very displacement the anchor exists to undo. After a +snapshot apply, and again on every layout pass, the anchor is restored by +setting the offset so that row lands where it was. The restore is absolute +rather than incremental, so when UIKit has already compensated correctly it +writes nothing. The pass is caught by a `UICollectionView` subclass that +reports its own `layoutSubviews`: a cell self-sizing re-lays the list without +ever changing the controller's view bounds, and that is exactly the pass that +can move a row above the reader. + +**A chat opens at the tail in one frame.** The first non-empty snapshot is +applied, laid out, and pinned to the bottom in the same pass — there is no +retry ladder and no quiescence timer. Hydration that lands afterwards is an +ordinary content change while following, and each one re-pins once. A +transcript shorter than the viewport renders from the top, which the collection +view does by construction. + +**Follow survives the keyboard the same way a terminal does.** The keyboard, +the composer growing, and a card collapsing all arrive as a bounds change on +the transcript. Following stays glued to the tail; a reader who had scrolled up +has their anchored row put back, which survives a re-measure that a saved +offset would not. A pin is the app moving the viewport, so it waits out the +reader's whole interaction even when the latch says they are still on the tail +— writing an offset under a live finger is what killed flings. + +**Revealing buffered history is scroll-back.** The near-top trigger requires +that the transcript is *not* following. Without that, the first geometry frames +— which report the top, and a content height that trivially "fits", because the +opening pin has not landed and the transcript has one row in it — spent the +whole buffered page before anything was on screen. + +The eight bench cases in `ADEUITests/WorkChatScrollBenchUITests.swift` are the +acceptance test, reduced from the `com.ade.ios.scrollbench` trace. The trace's +`viewport row= y=` line is the one that matters: `contentOffset` stopped +being a usable proxy for "did anything move" the moment UIKit started +compensating its own self-sizing, and it is emitted after the layout pass, not +from the `didScroll` UIKit raises mid-pass from a state no frame is composited +from. **Assistant messages render whole.** There is no line or character budget, no head/tail anchor to pick, no budget floor to preserve, and no "Show more" step — From e264959e1bf425c674f9451e5d39b95e1d92c08d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:16:53 -0400 Subject: [PATCH 14/26] fix(ios): say what "Send now" changes about a staged message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The description named the agent and described the schedule — "Claude picks it up after the current tool step" — which reads as a fact about the run rather than as what the button does to this message. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift index 29dbf99a64..201a1515d5 100644 --- a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift @@ -129,7 +129,7 @@ struct WorkQueuedSteerDetailSheet: View { systemImage: "arrow.turn.down.right", tint: ADEColor.accent, title: "Send now", - description: "\(capability.agentLabel) picks it up after the current tool step.", + description: "Send it now, ahead of the current step.", unavailableReason: sendNowUnavailableReason, identifier: "Work.Chat.StagedStrip.DetailSheet.SendNow" ) { From db45efaa300947403942cd71f56a99feeed81a03 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:49:10 -0400 Subject: [PATCH 15/26] fix(ios): stop the transcript anchor restore fighting the reader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The restore ran on every layout pass whenever the transcript was not following, with no test for whether the anchored row had actually moved. At the top of the thread that closed a loop: the reader overscrolls to -18.3, the restore writes the anchor's offset of -9, the write lays out, the pass reads the same stale sample and writes again. Case d logged 446 such writes, all while the reader's finger was down, and it pinned the rubber band at -9 instead of letting UIKit settle it to the edge — under a live finger, which the follow pin two branches above refuses to do for exactly this reason. workChatShouldRestoreAnchor is the decision, pure and unit-tested. Three refusals: the reader owns the offset for the whole interaction, finger-down through the end of momentum; overscroll is UIKit's to settle; and a row that has not moved needs no restore. The anchor now carries the row's content minY as of the sample or the last restore, so "this row moved" can be told apart from "this sample is old" — an absolute restore cannot tell them apart on its own. A successful restore advances the anchor, and the write is guarded against re-entering itself. Bench case A was also measuring nothing: `press(forDuration: 0.9, thenDragTo:)` holds the touch stationary first, and a stationary touch on a transcript row is a long press that the row's context menu claims, so the movement never reached the scroll view. It is now a short press dragged at a low velocity — the gesture the case meant — and registers 229 user frames where it previously registered none. Eight cases, ungestured viewport movement over 2pt with no write to account for it: 0pt. anchor-relayout writes: 0 (446 before). Only follow-pin and jump-to-latest writes remain, all with userPhase=false. Co-Authored-By: Claude Opus 5 --- .../ADE/Views/Work/WorkChatFollowLatch.swift | 38 ++++++++++ .../WorkChatTranscriptCollectionView.swift | 76 +++++++++++++++---- apps/ios/ADETests/ADETests.swift | 55 ++++++++++++++ .../WorkChatScrollBenchUITests.swift | 26 ++++++- 4 files changed, 178 insertions(+), 17 deletions(-) diff --git a/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift index c492bd68e1..322f34ea28 100644 --- a/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift +++ b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift @@ -93,3 +93,41 @@ func workChatFollowLatch( } return next } + +/// How far the reader's anchored row has to have actually moved before the +/// transcript puts it back. +/// +/// Not a deadband on the correction — the correction is exact. It is the +/// threshold below which "restore" stops being a correction and becomes a +/// fight: a write of a point or two re-enters layout, which re-reads the same +/// stale sample, which writes again. +let workChatAnchorRestoreTolerance: CGFloat = 2 + +/// Whether the transcript may put the reader's anchored row back right now. +/// +/// Three refusals, each of which was a live loop before it was written down: +/// +/// - The reader owns the offset for the whole interaction. Writing under a +/// live finger or a running fling is the same rule the follow pin already +/// obeys; the anchor is re-sampled when the interaction ends. +/// - Overscroll is UIKit's to settle. Restoring into the bounce pins the +/// rubber band at the anchor's offset instead of letting it return to the +/// edge, and every settle frame re-triggers the restore that caused it. +/// - A row that has not moved needs no restore. The restore is absolute, so +/// applying it to a sample that is merely stale — rather than to a row that +/// actually shifted — writes a displacement that was never there. +func workChatShouldRestoreAnchor( + anchorRowMinY: CGFloat, + currentRowMinY: CGFloat, + isDragging: Bool, + isDecelerating: Bool, + contentOffsetY: CGFloat, + minContentOffsetY: CGFloat, + maxContentOffsetY: CGFloat +) -> Bool { + guard !isDragging, !isDecelerating else { return false } + guard contentOffsetY >= minContentOffsetY - 0.5, + contentOffsetY <= maxContentOffsetY + 0.5 + else { return false } + return abs(currentRowMinY - anchorRowMinY) > workChatAnchorRestoreTolerance +} diff --git a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift index 58ed76c082..2da0bbdebb 100644 --- a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift @@ -245,6 +245,8 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele private var settleWorkItem: DispatchWorkItem? /// A scroll write inside a layout pass can re-enter the pass. private var isHandlingLayoutPass = false + /// A restore is a scroll write, and a scroll write lays out. + private var isRestoringAnchor = false /// Gap between rows, matching the transcript's former `LazyVStack` spacing. private let rowSpacing: CGFloat = 14 @@ -389,8 +391,9 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele // Content inserted or resized above the reader. Put the anchored row // back where it was; UIKit already compensated whatever it measured // itself, and this covers the rest. - restoreAnchor(anchor) - liveAnchor = captureAnchor() + if !restoreAnchorIfMoved(anchor, reason: "anchor-restore") { + liveAnchor = captureAnchor() + } } lastContentHeight = collectionView.contentSize.height } @@ -399,6 +402,10 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele struct Anchor { let rowId: String + /// The row's top in content coordinates, as of the sample — or of the last + /// restore. Comparing against it is what separates "this row moved" from + /// "this sample is old", which an absolute restore cannot tell apart. + let rowMinY: CGFloat /// Where the row's top sat relative to the viewport's top edge. let offsetFromViewportTop: CGFloat } @@ -411,19 +418,46 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele else { return nil } return Anchor( rowId: orderedRowIds[indexPath.item], + rowMinY: attributes.frame.minY, offsetFromViewportTop: attributes.frame.minY - collectionView.contentOffset.y ) } + /// Put the reader's row back, if it moved and if the offset is ours to write. + /// + /// On success the anchor advances to the row's new content position, so the + /// next pass compares against what this restore settled on rather than + /// re-deciding from a sample the restore already answered. @discardableResult - private func restoreAnchor(_ anchor: Anchor, reason: String = "anchor-restore") -> Bool { + private func restoreAnchorIfMoved(_ anchor: Anchor, reason: String) -> Bool { + guard !isRestoringAnchor else { return false } guard let index = orderedRowIds.firstIndex(of: anchor.rowId), let attributes = collectionView.layoutAttributesForItem( at: IndexPath(item: index, section: 0) ) else { return false } - let target = attributes.frame.minY - anchor.offsetFromViewportTop - performScrollWrite(.setOffset(target), reason: reason) + let currentRowMinY = attributes.frame.minY + guard workChatShouldRestoreAnchor( + anchorRowMinY: anchor.rowMinY, + currentRowMinY: currentRowMinY, + isDragging: collectionView.isDragging, + isDecelerating: collectionView.isDecelerating, + contentOffsetY: collectionView.contentOffset.y, + minContentOffsetY: minContentOffsetY, + maxContentOffsetY: maxContentOffsetY + ) else { return false } + + isRestoringAnchor = true + defer { isRestoringAnchor = false } + performScrollWrite( + .setOffset(currentRowMinY - anchor.offsetFromViewportTop), + reason: reason + ) + liveAnchor = Anchor( + rowId: anchor.rowId, + rowMinY: currentRowMinY, + offsetFromViewportTop: anchor.offsetFromViewportTop + ) return true } @@ -436,14 +470,22 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele } /// The only function in the transcript that moves the viewport. - private func performScrollWrite(_ write: ScrollWrite, reason: String) { - let before = collectionView.contentOffset.y - let minOffset = -collectionView.contentInset.top - let maxOffset = max( - minOffset, + private var minContentOffsetY: CGFloat { + -collectionView.contentInset.top + } + + private var maxContentOffsetY: CGFloat { + max( + minContentOffsetY, collectionView.contentSize.height - collectionView.bounds.height + collectionView.contentInset.bottom ) + } + + private func performScrollWrite(_ write: ScrollWrite, reason: String) { + let before = collectionView.contentOffset.y + let minOffset = minContentOffsetY + let maxOffset = maxContentOffsetY let target: CGFloat var animated = false @@ -612,9 +654,11 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele // stays glued to the tail; everyone else keeps their row. if follow.following { followPinNeeded = true - } else if let liveAnchor, restoreAnchor(liveAnchor, reason: "viewport-resize") { + } else if let liveAnchor { // Anchored on the row, which survives a re-measure the saved offset - // would not. + // would not. A resize that did not move the row needs no write: the + // offset it already has is the reader's place. + restoreAnchorIfMoved(liveAnchor, reason: "viewport-resize") } else { performScrollWrite(.setOffset(stableOffsetY), reason: "viewport-resize") } @@ -635,10 +679,10 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele } else if !follow.following || follow.inUserSession, let liveAnchor { // A cell measured itself away from its estimate after the update that // asked for it — sometimes without changing the total content height at - // all, when rows above the reader redistribute. The restore is absolute - // rather than incremental, so when UIKit has already compensated - // correctly this writes nothing. - restoreAnchor(liveAnchor, reason: "anchor-relayout") + // all, when rows above the reader redistribute. Gated on the anchored + // row having actually moved, so a pass where UIKit already compensated + // correctly writes nothing at all. + restoreAnchorIfMoved(liveAnchor, reason: "anchor-relayout") } traceViewportAnchor() publishGeometry() diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 7606f12ec0..263267bcdf 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16867,6 +16867,61 @@ final class ADETests: XCTestCase { XCTAssertFalse(workChatFollowLatch(flinging, .disclosureSettled(isAtEnd: true)).following) } + // MARK: - Work chat transcript anchor restore + + private func shouldRestore( + anchorRowMinY: CGFloat = 1_000, + currentRowMinY: CGFloat = 1_200, + isDragging: Bool = false, + isDecelerating: Bool = false, + contentOffsetY: CGFloat = 800, + minContentOffsetY: CGFloat = -9, + maxContentOffsetY: CGFloat = 5_000 + ) -> Bool { + workChatShouldRestoreAnchor( + anchorRowMinY: anchorRowMinY, + currentRowMinY: currentRowMinY, + isDragging: isDragging, + isDecelerating: isDecelerating, + contentOffsetY: contentOffsetY, + minContentOffsetY: minContentOffsetY, + maxContentOffsetY: maxContentOffsetY + ) + } + + func testAnchorRestoresWhenTheRowMovedAndTheOffsetIsOursToWrite() { + XCTAssertTrue(shouldRestore()) + XCTAssertTrue(shouldRestore(currentRowMinY: 800)) + } + + /// The same rule the follow pin obeys: the reader owns the offset for the + /// whole interaction, finger-down through the end of momentum. + func testAnchorRestoreDefersToTheReadersInteraction() { + XCTAssertFalse(shouldRestore(isDragging: true)) + XCTAssertFalse(shouldRestore(isDecelerating: true)) + } + + /// Restoring into the rubber band pins it at the anchor's offset instead of + /// letting it return to the edge, and every settle frame re-triggers the + /// restore that held it there. + func testAnchorRestoreLeavesOverscrollToUIKit() { + XCTAssertFalse(shouldRestore(contentOffsetY: -18.3)) + XCTAssertFalse(shouldRestore(contentOffsetY: 5_100)) + // The edges themselves are at rest, not overscroll. + XCTAssertTrue(shouldRestore(contentOffsetY: -9)) + XCTAssertTrue(shouldRestore(contentOffsetY: 5_000)) + } + + /// A restore is absolute, so applying it to a sample that is merely stale — + /// rather than to a row that actually shifted — writes a displacement that + /// was never there, and that write lays out and asks again. + func testAnchorRestoreSkipsARowThatDidNotMove() { + XCTAssertFalse(shouldRestore(currentRowMinY: 1_000)) + XCTAssertFalse(shouldRestore(currentRowMinY: 1_002)) + XCTAssertFalse(shouldRestore(currentRowMinY: 998)) + XCTAssertTrue(shouldRestore(currentRowMinY: 1_003)) + } + func testResetOpensAtTheTailAndClearsAnyUserSession() { let stuck = WorkChatFollowState(following: false, inUserSession: true) let state = workChatFollowLatch(stuck, .reset) diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift index 94136e0a59..33184c6ef3 100644 --- a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -58,12 +58,36 @@ final class WorkChatScrollBenchUITests: XCTestCase { start.press(forDuration: duration, thenDragTo: end) } + /// A slow *drag*, not a long hold that then moves. + /// + /// `press(forDuration: 0.9, thenDragTo:)` holds the touch stationary first, + /// and a stationary touch on a transcript row is a long press: the row's + /// context menu claims it and the movement never reaches the scroll view. + /// Pressing briefly and moving at a low velocity is the gesture this case + /// meant all along. + private func slowDrag( + _ app: XCUIApplication, + fromY: CGFloat, + toY: CGFloat, + velocity: CGFloat + ) { + let window = app.windows.firstMatch + let start = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: fromY)) + let end = window.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: toY)) + start.press( + forDuration: 0.05, + thenDragTo: end, + withVelocity: XCUIGestureVelocity(rawValue: velocity), + thenHoldForDuration: 0.1 + ) + } + // (a) Open at the tail, then scroll up slowly through three screens. func testCaseA_slowScrollUp() { let app = launch() sleep(6) for _ in 0..<3 { - drag(app, fromY: 0.25, toY: 0.72, duration: 0.9) + slowDrag(app, fromY: 0.25, toY: 0.72, velocity: 320) sleep(2) } sleep(3) From 63d4849cf2f7856c3812dd1f60e8f047bc843959 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 17:57:35 -0400 Subject: [PATCH 16/26] test(ios): case f of the scroll bench drags instead of long-pressing The half-second hold before the drag registered as a long press on a transcript row, so the reader never left follow mode and the case never measured streaming while scrolled up. Co-Authored-By: Claude Fable 5.1 --- apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift index 33184c6ef3..53d7f30211 100644 --- a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -150,7 +150,7 @@ final class WorkChatScrollBenchUITests: XCTestCase { let app = launch(extra: ["-adeBenchStream", "160", "-adeBenchStreamIntervalMs", "140"]) sleep(5) for _ in 0..<2 { - drag(app, fromY: 0.25, toY: 0.75, duration: 0.5) + slowDrag(app, fromY: 0.25, toY: 0.75, velocity: 320) usleep(500_000) } sleep(32) From 1f1327c9ffff0fe0b4053a62c0d9bf0691543c77 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 19:25:03 -0400 Subject: [PATCH 17/26] fix(chat): preserve mobile retries and tool result state --- .../src/services/sync/syncHostService.test.ts | 352 ++++++++++++++++++ .../src/services/sync/syncHostService.ts | 88 +++-- .../desktop/src/shared/chatMobileSlim.test.ts | 60 ++- apps/desktop/src/shared/chatMobileSlim.ts | 144 +++++-- apps/ios/ADE.xcodeproj/project.pbxproj | 11 + .../ADE/Services/ChatToolResultCache.swift | 56 +++ apps/ios/ADE/Services/SyncService.swift | 57 ++- .../Work/WorkChatComposerAndInputViews.swift | 5 +- .../Views/Work/WorkChatRichCardViews.swift | 118 ++---- .../Work/WorkErrorAndMessageHelpers.swift | 22 +- apps/ios/ADE/Views/Work/WorkModels.swift | 49 ++- .../WorkNavigationAndTranscriptHelpers.swift | 3 +- .../Work/WorkQueuedSteerDetailSheet.swift | 8 +- .../Work/WorkRemoteToolResultAffordance.swift | 187 ++++++++++ .../ADE/Views/Work/WorkTranscriptParser.swift | 23 +- apps/ios/ADETests/ADETests.swift | 22 ++ .../WorkChatToolResultCacheTests.swift | 48 +++ .../WorkChatScrollBenchUITests.swift | 5 - 18 files changed, 1048 insertions(+), 210 deletions(-) create mode 100644 apps/ios/ADE/Services/ChatToolResultCache.swift create mode 100644 apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift create mode 100644 apps/ios/ADETests/WorkChatToolResultCacheTests.swift diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index dff1dd9a95..513471b2da 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -29,6 +29,7 @@ import { SYNC_INVALIDATION_BATCH_MAX_ENVELOPE_BYTES, SYNC_INVALIDATION_TABLE_MAX_BYTES, SYNC_INVALIDATION_ONLY_V1_CAPABILITY, + SYNC_MOBILE_CHAT_SLIM_CAPABILITY, SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../../desktop/src/shared/types"; import { @@ -11586,6 +11587,357 @@ describe("chat_subscribe snapshots", () => { cleanup(); } }); + + it("retries a slim progress event after a backpressured live send", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "chat-slim-retry.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "chat-slim-retry", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const chatEventEmitter: { current?: (event: AgentChatEventEnvelope) => void } = {}; + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + pollIntervalMs: 60_000, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-chat-slim-retry", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => (id === "chat-slim-retry" ? session : null), + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn((callback: (event: AgentChatEventEnvelope) => void) => { + chatEventEmitter.current = callback; + return () => {}; + }), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId: "chat-slim-retry", + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + let bufferedAmountSpy: { mockRestore(): void } | null = null; + const emitChatEvent = (event: AgentChatEventEnvelope): void => { + const callback = chatEventEmitter.current; + if (!callback) throw new Error("chat event subscription was not installed"); + callback(event); + }; + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-slim-retry", { + capabilities: [SYNC_MOBILE_CHAT_SLIM_CAPABILITY], + }); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-slim-retry-initial", + payload: { sessionId: "chat-slim-retry" }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-slim-retry-initial"); + + let bufferedAmountReads = 0; + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockImplementation(() => { + bufferedAmountReads += 1; + return bufferedAmountReads === 1 ? 0 : 4 * 1024 * 1024; + }); + emitChatEvent({ + sessionId: "chat-slim-retry", + timestamp: "2026-04-23T10:00:03.000Z", + sequence: 1, + event: { + type: "subagent_progress", + taskId: "agent-1", + agentId: "agent-1", + summary: "working", + }, + }); + await waitForValue( + () => bufferedAmountReads >= 2 ? true : undefined, + "backpressured slim chat event send", + ); + bufferedAmountSpy.mockRestore(); + bufferedAmountSpy = null; + expect(peer.envelopes.filter((envelope) => envelope.type === "chat_event")).toHaveLength(0); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-slim-retry-resume", + payload: { sessionId: "chat-slim-retry", sinceSeq: 0 }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-slim-retry-resume"); + const retried = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "chat_event"), + "retried slim chat event", + ); + expect(retried.payload).toMatchObject({ + sequence: 1, + event: { type: "subagent_progress", summary: "working" }, + }); + + bufferedAmountReads = 0; + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockImplementation(() => { + bufferedAmountReads += 1; + return bufferedAmountReads === 1 ? 0 : 4 * 1024 * 1024; + }); + emitChatEvent({ + sessionId: "chat-slim-retry", + timestamp: "2026-04-23T10:00:04.000Z", + sequence: 2, + event: { type: "text", text: "retry this text" }, + }); + await waitForValue( + () => bufferedAmountReads >= 2 ? true : undefined, + "backpressured slim non-progress send", + ); + bufferedAmountSpy.mockRestore(); + bufferedAmountSpy = null; + expect(peer.envelopes.filter((envelope) => + envelope.type === "chat_event" + && (envelope.payload as { event?: { type?: string } }).event?.type === "text" + )).toHaveLength(0); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-slim-retry-text-resume", + payload: { sessionId: "chat-slim-retry", sinceSeq: 1 }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-slim-retry-text-resume"); + const retriedText = await waitForValue( + () => peer?.envelopes.find((envelope) => + envelope.type === "chat_event" + && (envelope.payload as { event?: { type?: string } }).event?.type === "text" + ), + "retried slim non-progress event", + ); + expect(retriedText.payload).toMatchObject({ + sequence: 2, + event: { type: "text", text: "retry this text" }, + }); + expect(peer.envelopes.filter((envelope) => + envelope.type === "chat_event" + && (envelope.payload as { event?: { type?: string } }).event?.type === "text" + )).toHaveLength(1); + + bufferedAmountReads = 0; + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockImplementation(() => { + bufferedAmountReads += 1; + return bufferedAmountReads === 1 ? 0 : 4 * 1024 * 1024; + }); + emitChatEvent({ + sessionId: "chat-slim-retry", + timestamp: "2026-04-23T10:00:05.000Z", + sequence: 3, + event: { + type: "subagent_progress", + taskId: "agent-2", + agentId: "agent-2", + summary: "stale progress", + }, + }); + await waitForValue( + () => bufferedAmountReads >= 2 ? true : undefined, + "backpressured second slim progress send", + ); + bufferedAmountSpy.mockRestore(); + bufferedAmountSpy = null; + + bufferedAmountReads = 0; + bufferedAmountSpy = vi + .spyOn(WebSocket.prototype, "bufferedAmount", "get") + .mockImplementation(() => { + bufferedAmountReads += 1; + return bufferedAmountReads === 1 ? 0 : 4 * 1024 * 1024; + }); + emitChatEvent({ + sessionId: "chat-slim-retry", + timestamp: "2026-04-23T10:00:06.000Z", + sequence: 4, + event: { + type: "subagent_result", + taskId: "agent-2", + agentId: "agent-2", + status: "completed", + summary: "done", + }, + }); + await waitForValue( + () => bufferedAmountReads >= 2 ? true : undefined, + "backpressured slim terminal send", + ); + bufferedAmountSpy.mockRestore(); + bufferedAmountSpy = null; + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-slim-retry-terminal-resume", + payload: { sessionId: "chat-slim-retry", sinceSeq: 2 }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-slim-retry-terminal-resume"); + await waitForValue( + () => peer?.envelopes.find((envelope) => + envelope.type === "chat_event" + && (envelope.payload as { event?: { type?: string; agentId?: string } }).event?.type === "subagent_result" + && (envelope.payload as { event?: { agentId?: string } }).event?.agentId === "agent-2" + ), + "retried slim terminal event", + ); + expect(peer.envelopes.filter((envelope) => + envelope.type === "chat_event" + && (envelope.payload as { event?: { type?: string; agentId?: string } }).event?.type === "subagent_progress" + && (envelope.payload as { event?: { agentId?: string } }).event?.agentId === "agent-2" + )).toHaveLength(0); + } finally { + bufferedAmountSpy?.mockRestore(); + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("serves a mobile tool-result head slice and the scoped full-result fetch", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const sessionId = "chat-tool-result"; + const itemId = "tool-result-item"; + const transcriptPath = path.join(projectRoot, "transcripts", "chat-tool-result.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + const fullResult = "x".repeat(8_000); + const event: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-09-20T10:00:00.000Z", + sequence: 1, + event: { + type: "tool_result", + tool: "Bash", + result: fullResult, + itemId, + status: "completed", + }, + }; + fs.writeFileSync(transcriptPath, `${JSON.stringify(event)}\n`, "utf8"); + const session = { + id: sessionId, + laneId: "lane-1", + transcriptPath, + status: "exited", + runtimeState: "exited", + lastOutputPreview: "", + }; + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-chat-tool-result", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => (id === sessionId ? session : null), + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId, + events: [event], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "inactive" }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-tool-result", { + capabilities: [SYNC_MOBILE_CHAT_SLIM_CAPABILITY], + }); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-tool-result-subscribe", + payload: { sessionId }, + })); + const snapshot = await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-tool-result-subscribe"); + const snapshotEvent = (snapshot.payload as { events: AgentChatEventEnvelope[] }).events[0]!; + expect(snapshotEvent.event).toMatchObject({ + type: "tool_result", + resultTruncatedForMobile: true, + resultOriginalBytes: fullResult.length, + }); + expect((snapshotEvent.event as { result: string }).result).not.toBe(fullResult); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_tool_result", + requestId: "chat-tool-result-fetch", + payload: { sessionId, itemId }, + })); + const response = await waitForEnvelope(peer.envelopes, "chat_tool_result", "chat-tool-result-fetch"); + expect(response.payload).toMatchObject({ + sessionId, + itemId, + found: true, + result: fullResult, + status: "completed", + tool: "Bash", + }); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); }); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 979bf8aaaa..a3c560648d 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -123,6 +123,7 @@ import { createSubagentProgressCoalescer, foldSubagentProgressForSnapshot, MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS, + type CoalescedChatEvent, type SubagentProgressCoalescer, } from "../../../../desktop/src/shared/chatMobileSlim"; import { readTranscriptHistoryPage } from "../../../../desktop/src/main/services/chat/chatTranscriptHistoryPager"; @@ -5711,17 +5712,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { */ const compactedSyncEnvelopes = new WeakMap(); const compactedMobileSyncEnvelopes = new WeakMap(); - function compactChatEventEnvelopeOnce( - event: AgentChatEventEnvelope, - slim: boolean, - ): AgentChatEventEnvelope { - const cache = slim ? compactedMobileSyncEnvelopes : compactedSyncEnvelopes; - const cached = cache.get(event); + function compactChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope { + const cached = compactedSyncEnvelopes.get(event); + if (cached) return cached; + const compacted = compactChatEventEnvelopeForSync(event); + compactedSyncEnvelopes.set(event, compacted); + return compacted; + } + + function compactMobileChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope { + const cached = compactedMobileSyncEnvelopes.get(event); if (cached) return cached; - const compacted = slim - ? compactChatEventEnvelopeForMobileSync(event) - : compactChatEventEnvelopeForSync(event); - cache.set(event, compacted); + const compacted = compactChatEventEnvelopeForMobileSync(event); + compactedMobileSyncEnvelopes.set(event, compacted); return compacted; } @@ -5748,13 +5751,48 @@ export function createSyncHostService(args: SyncHostServiceArgs) { event: AgentChatEventEnvelope, seq: number | null, ): boolean { - const syncEvent = compactChatEventEnvelopeOnce(event, peerWantsSlimChat(peer)); + const syncEvent = peerWantsSlimChat(peer) + ? compactMobileChatEventEnvelopeOnce(event) + : compactChatEventEnvelopeOnce(event); return send(peer.ws, "chat_event", { ...syncEvent, ...(seq == null ? {} : { seq }), } satisfies SyncChatEventPayload); } + function markDeliveredCoalescedChatEvent(peer: PeerState, entry: CoalescedChatEvent): void { + markChatEventSent(peer, entry.event); + for (const superseded of entry.superseded ?? []) { + markChatEventSent(peer, superseded); + } + } + + function deliverCoalescedChatEvents( + peer: PeerState, + coalescer: SubagentProgressCoalescer, + entries: readonly CoalescedChatEvent[], + ): boolean { + // A terminal event can be queued after its progress was rejected. If the + // transcript pump presents that same progress again before the terminal + // retry drains, the batch contains both; the terminal's superseded list + // is the authoritative answer, so never resurrect the stale progress. + const supersededKeys = new Set( + entries.flatMap((entry) => + (entry.superseded ?? []).map((event) => chatEventDeliveryKey(event)), + ), + ); + const deliverable = entries.filter((entry) => !supersededKeys.has(chatEventDeliveryKey(entry.event))); + for (let index = 0; index < deliverable.length; index += 1) { + const entry = deliverable[index]!; + if (!deliverChatEvent(peer, entry.event, entry.seq)) { + coalescer.requeue(deliverable.slice(index)); + return false; + } + markDeliveredCoalescedChatEvent(peer, entry); + } + return true; + } + function sendChatEvent(peer: PeerState, event: AgentChatEventEnvelope, seq: number): "sent" | "already-sent" | "failed" { if (chatEventAlreadySent(peer, event)) return "already-sent"; if (!peerWantsSlimChat(peer)) { @@ -5762,16 +5800,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (sent) markChatEventSent(peer, event); return sent ? "sent" : "failed"; } - // Coalescing decides what actually goes out. Mark the offered event - // delivered either way: an event the coalescer dropped as a mirror or - // superseded by a newer one for the same agent has been accounted for, and - // leaving it unmarked would let the transcript pump offer it again on the - // next tick — re-entering the coalescer forever. - const outbound = chatEventCoalescerFor(peer, event.sessionId).admit(event, seq, Date.now()); + // Coalescing owns a progress event after admission. A dropped mirror or a + // progress waiting in the one-second window can be marked immediately; + // anything selected for the wire is marked only after its send succeeds. + const coalescer = chatEventCoalescerFor(peer, event.sessionId); + const outbound = coalescer.admit(event, seq, Date.now()); + if (!deliverCoalescedChatEvents(peer, coalescer, outbound)) return "failed"; + // This also accounts for a mirror or a progress event that was folded + // into a pending state instead of being emitted in this call. markChatEventSent(peer, event); - for (const entry of outbound) { - if (!deliverChatEvent(peer, entry.event, entry.seq)) return "failed"; - } return "sent"; } @@ -5784,9 +5821,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (peer.subagentProgressCoalescers.size === 0) return; const nowMs = Date.now(); for (const coalescer of peer.subagentProgressCoalescers.values()) { - for (const entry of coalescer.flushDue(nowMs)) { - if (!deliverChatEvent(peer, entry.event, entry.seq)) return; - } + const due = coalescer.flushDue(nowMs); + if (!deliverCoalescedChatEvents(peer, coalescer, due)) return; } } @@ -8749,9 +8785,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // A resume is a catch-up burst, not a live stream: nothing the // coalescer is holding should wait a further second behind it. if (peerWantsSlimChat(peer)) { - for (const entry of chatEventCoalescerFor(peer, sessionId).flushAll(Date.now())) { - if (!deliverChatEvent(peer, entry.event, entry.seq)) break; - } + const coalescer = chatEventCoalescerFor(peer, sessionId); + const pending = coalescer.flushAll(Date.now()); + deliverCoalescedChatEvents(peer, coalescer, pending); } args.logger.debug("sync_host.chat_subscribe_resumed", { sessionId, diff --git a/apps/desktop/src/shared/chatMobileSlim.test.ts b/apps/desktop/src/shared/chatMobileSlim.test.ts index 34413a9f81..36e9839d64 100644 --- a/apps/desktop/src/shared/chatMobileSlim.test.ts +++ b/apps/desktop/src/shared/chatMobileSlim.test.ts @@ -136,16 +136,22 @@ describe("createSubagentProgressCoalescer", () => { it("passes every non-progress event straight through, in order", () => { const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); const text = envelope({ type: "text", text: "hi" }, 1); - expect(coalescer.admit(text, 1, 0)).toEqual([{ event: text, seq: 1 }]); + expect(coalescer.admit(text, 1, 0)).toEqual([{ event: text, seq: 1, sourceSeq: 1 }]); }); it("drops pending progress when the agent's result supersedes it", () => { const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); - coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100); + const pending = envelope(underscoreProgress("a", "2"), 2); + coalescer.admit(pending, 2, 100); expect(coalescer.pendingCount).toBe(1); const result = envelope({ type: "subagent_result", taskId: "a", agentId: "a", status: "completed", summary: "done" }, 3); - expect(coalescer.admit(result, 3, 200)).toEqual([{ event: result, seq: 3 }]); + expect(coalescer.admit(result, 3, 200)).toEqual([{ + event: result, + seq: 3, + sourceSeq: 3, + superseded: [pending], + }]); expect(coalescer.pendingCount).toBe(0); expect(coalescer.flushDue(5_000)).toEqual([]); }); @@ -191,6 +197,54 @@ describe("createSubagentProgressCoalescer", () => { expect(coalescer.pendingCount).toBe(0); expect(coalescer.nextDueAtMs()).toBeNull(); }); + + it("requeues progress when the transport rejects a flush", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + coalescer.admit(envelope(underscoreProgress("a", "1"), 1), 1, 0); + coalescer.admit(envelope(underscoreProgress("a", "2"), 2), 2, 100); + const due = coalescer.flushDue(1_000); + expect(due).toHaveLength(1); + coalescer.requeue(due); + const retry = coalescer.flushDue(1_000); + expect(retry).toHaveLength(1); + expect((retry[0]!.event.event as { summary: string }).summary).toBe("2"); + expect(retry[0]!.seq).toBeNull(); + expect(retry[0]!.sourceSeq).toBe(2); + }); + + it("requeues non-progress events when the transport rejects them", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + const text = envelope({ type: "text", text: "retry me" }, 1); + const outbound = coalescer.admit(text, 1, 0); + coalescer.requeue(outbound); + expect(coalescer.flushAll(1)).toEqual(outbound); + }); + + it("carries a failed progress event as superseded when its result succeeds", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + const progress = envelope(underscoreProgress("a", "stale"), 1); + coalescer.requeue(coalescer.admit(progress, 1, 0)); + const result = envelope({ type: "subagent_result", taskId: "a", agentId: "a", status: "completed", summary: "done" }, 2); + expect(coalescer.admit(result, 2, 1)).toEqual([{ + event: result, + seq: 2, + sourceSeq: 2, + superseded: [progress], + }]); + }); + + it("carries an overwritten failed progress event into the replacement", () => { + const coalescer = createSubagentProgressCoalescer({ intervalMs: 1_000 }); + const failed = envelope(underscoreProgress("a", "failed"), 1); + coalescer.requeue(coalescer.admit(failed, 1, 0)); + const replacement = envelope(underscoreProgress("a", "replacement"), 2); + expect(coalescer.admit(replacement, 2, 1)).toEqual([{ + event: replacement, + seq: 2, + sourceSeq: 2, + superseded: [failed], + }]); + }); }); describe("compactToolResultForMobile", () => { diff --git a/apps/desktop/src/shared/chatMobileSlim.ts b/apps/desktop/src/shared/chatMobileSlim.ts index 41b44ac27e..90e644b6a1 100644 --- a/apps/desktop/src/shared/chatMobileSlim.ts +++ b/apps/desktop/src/shared/chatMobileSlim.ts @@ -114,7 +114,7 @@ const stringifyResult = (value: unknown): { text: string; measurable: boolean } */ export function compactToolResultForMobile( event: Extract, -): AgentChatEvent { +): Extract { const serialized = stringifyResult(event.result); const totalBytes = serialized.measurable ? utf8Bytes(serialized.text) : 0; // An already-capped result whose original size is known keeps that number: @@ -164,6 +164,29 @@ const trimmed = (value: unknown): string | null => { return text.length > 0 ? text : null; }; +type SubagentProgressEvent = Extract< + AgentChatEvent, + { type: "subagent_progress" | "subagent.progress" } +>; + +type SubagentCompletionEvent = Extract< + AgentChatEvent, + { type: "subagent_result" | "subagent.completed" } +>; + +function asSubagentProgressEvent(value: unknown): SubagentProgressEvent | null { + if (!value || typeof value !== "object") return null; + const type = (value as { type?: unknown }).type; + if (type !== "subagent_progress" && type !== "subagent.progress") return null; + return value as SubagentProgressEvent; +} + +function subagentCompletionAgentKey(event: SubagentCompletionEvent): string | null { + return event.type === "subagent_result" + ? trimmed(event.agentId) ?? trimmed(event.taskId) + : trimmed(event.agentId); +} + /** * Identify a subagent PROGRESS event and the agent it belongs to. * @@ -172,16 +195,15 @@ const trimmed = (value: unknown): string | null => { * lose a subagent, not a redraw. */ export function subagentProgressIdentity(event: unknown): SubagentProgressIdentity | null { - if (!event || typeof event !== "object") return null; - const record = event as Record; - const type = typeof record.type === "string" ? record.type : ""; - if (type !== "subagent_progress" && type !== "subagent.progress") return null; + const progress = asSubagentProgressEvent(event); + if (!progress) return null; // `agentId` is the stable identity on both families; the underscore family // falls back to `taskId`, which is what it keys its own snapshot map on // (`subagentAgentKey` in agentChatService). - const agentKey = trimmed(record.agentId) ?? trimmed(record.taskId); + const agentKey = trimmed(progress.agentId) + ?? (progress.type === "subagent_progress" ? trimmed(progress.taskId) : null); if (!agentKey) return null; - return { agentKey, family: type === "subagent.progress" ? "dot" : "underscore" }; + return { agentKey, family: progress.type === "subagent.progress" ? "dot" : "underscore" }; } /** @@ -260,6 +282,10 @@ export const MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS = 1_000; export type CoalescedChatEvent = { event: AgentChatEventEnvelope; seq: number | null; + /** The host replay sequence, retained so a failed send can be retried. */ + sourceSeq: number; + /** Progress entries intentionally superseded by this terminal event. */ + superseded?: readonly AgentChatEventEnvelope[]; }; export type SubagentProgressCoalescer = { @@ -272,6 +298,8 @@ export type SubagentProgressCoalescer = { flushDue(nowMs: number): CoalescedChatEvent[]; /** Emit everything pending — unsubscribe, disconnect, end of turn. */ flushAll(nowMs: number): CoalescedChatEvent[]; + /** Put progress back after the transport rejected an outbound event. */ + requeue(entries: readonly CoalescedChatEvent[]): void; /** Earliest ms at which `flushDue` could produce anything, or null. */ nextDueAtMs(): number | null; readonly pendingCount: number; @@ -281,7 +309,13 @@ export function createSubagentProgressCoalescer(options: { intervalMs?: number; } = {}): SubagentProgressCoalescer { const intervalMs = Math.max(0, Math.floor(options.intervalMs ?? MOBILE_SUBAGENT_PROGRESS_INTERVAL_MS)); - const pending = new Map(); + const pending = new Map(); + const pendingOutbound: CoalescedChatEvent[] = []; const lastSentAtMs = new Map(); const dueAtMs = new Map(); let lastEmittedSeq = 0; @@ -290,7 +324,7 @@ export function createSubagentProgressCoalescer(options: { const emit = (entry: { event: AgentChatEventEnvelope; seq: number }): CoalescedChatEvent => { if (entry.seq > lastEmittedSeq) { lastEmittedSeq = entry.seq; - return { event: entry.event, seq: entry.seq }; + return { event: entry.event, seq: entry.seq, sourceSeq: entry.seq }; } // The window closed after a newer event for a DIFFERENT agent already went // out, so this agent's latest state now carries a seq below the client's @@ -303,7 +337,7 @@ export function createSubagentProgressCoalescer(options: { // with no seq (its drop rule is `if let seq`), its resume watermark stays // on the newer event, and a reconnect that replays this one again is a // no-op because progress never creates a row. - return { event: entry.event, seq: null }; + return { event: entry.event, seq: null, sourceSeq: entry.seq }; }; const flushAgent = (agentKey: string, nowMs: number): CoalescedChatEvent[] => { @@ -312,11 +346,15 @@ export function createSubagentProgressCoalescer(options: { pending.delete(agentKey); dueAtMs.delete(agentKey); lastSentAtMs.set(agentKey, nowMs); - return [emit(entry)]; + const emitted = emit(entry); + return entry.superseded && entry.superseded.length > 0 + ? [{ ...emitted, superseded: entry.superseded }] + : [emitted]; }; return { admit(event, seq, nowMs) { + const queued = pendingOutbound.splice(0); const identity = subagentProgressIdentity(event?.event); if (!identity) { // A non-progress event does not flush pending progress. It cannot: @@ -327,33 +365,45 @@ export function createSubagentProgressCoalescer(options: { // // The one case that does flush is a subagent ending: its result is the // last word on that card, so the progress behind it has no reader. - const type = typeof (event?.event as { type?: unknown })?.type === "string" - ? (event.event as { type: string }).type - : ""; - const ending = type === "subagent_result" || type === "subagent.completed"; - const before: CoalescedChatEvent[] = []; + const endingEvent = event.event as AgentChatEvent; + const ending = endingEvent.type === "subagent_result" || endingEvent.type === "subagent.completed"; + const superseded: AgentChatEventEnvelope[] = []; if (ending) { - const record = event.event as unknown as Record; - const agentKey = trimmed(record.agentId) ?? trimmed(record.taskId); + const agentKey = subagentCompletionAgentKey(endingEvent as SubagentCompletionEvent); // Drop rather than emit: the result that follows in this same call // supersedes it, and emitting first would put a lower seq ahead of // a higher one for no visible gain. - if (agentKey && pending.delete(agentKey)) { - dueAtMs.delete(agentKey); - lastSentAtMs.delete(agentKey); + const pendingEntry = agentKey ? pending.get(agentKey) : undefined; + if (pendingEntry) { + superseded.push(...(pendingEntry.superseded ?? []), pendingEntry.event); + pending.delete(agentKey!); + dueAtMs.delete(agentKey!); + lastSentAtMs.delete(agentKey!); } lastProgressIdentity = null; } else { lastProgressIdentity = null; } if (seq > lastEmittedSeq) lastEmittedSeq = seq; - return [...before, { event, seq }]; + // A transcript pump can present the same source event again after its + // live send was rejected. Keep the queued copy as the single retry; + // otherwise a non-progress event would be sent twice. + const alreadyQueued = queued.some((entry) => + entry.event.sessionId === event.sessionId && entry.sourceSeq === seq, + ); + const outbound: CoalescedChatEvent = { + event, + seq, + sourceSeq: seq, + ...(superseded.length > 0 ? { superseded } : {}), + }; + return alreadyQueued ? queued : [...queued, outbound]; } if (isMirroredSubagentProgress(lastProgressIdentity, identity)) { // The underscore original was just delivered (or just coalesced) for // this agent. The twin carries nothing new. - return []; + return queued; } lastProgressIdentity = identity; @@ -361,22 +411,31 @@ export function createSubagentProgressCoalescer(options: { if (existing && existing.family === "underscore" && identity.family === "dot") { // Same reason as the snapshot fold: never downgrade a pending // underscore event to its thinner twin. - return []; + return queued; } - pending.set(identity.agentKey, { event, seq, family: identity.family }); + const sameSource = existing?.seq === seq && existing.event.sessionId === event.sessionId; + const superseded = existing + ? [...(existing.superseded ?? []), ...(sameSource ? [] : [existing.event])] + : undefined; + pending.set(identity.agentKey, { + event, + seq, + family: identity.family, + ...(superseded && superseded.length > 0 ? { superseded } : {}), + }); const lastSent = lastSentAtMs.get(identity.agentKey); if (lastSent == null || nowMs - lastSent >= intervalMs) { - return flushAgent(identity.agentKey, nowMs); + return [...queued, ...flushAgent(identity.agentKey, nowMs)]; } if (!dueAtMs.has(identity.agentKey)) { dueAtMs.set(identity.agentKey, lastSent + intervalMs); } - return []; + return queued; }, flushDue(nowMs) { - const out: CoalescedChatEvent[] = []; + const out = pendingOutbound.splice(0); for (const [agentKey, due] of [...dueAtMs]) { if (nowMs < due) continue; out.push(...flushAgent(agentKey, nowMs)); @@ -385,7 +444,7 @@ export function createSubagentProgressCoalescer(options: { }, flushAll(nowMs) { - const out: CoalescedChatEvent[] = []; + const out = pendingOutbound.splice(0); for (const agentKey of [...pending.keys()]) { out.push(...flushAgent(agentKey, nowMs)); } @@ -393,7 +452,32 @@ export function createSubagentProgressCoalescer(options: { return out; }, + requeue(entries) { + const retryOutbound: CoalescedChatEvent[] = []; + for (const entry of entries) { + const identity = subagentProgressIdentity(entry.event.event); + if (!identity) { + retryOutbound.push(entry); + continue; + } + pending.set(identity.agentKey, { + event: entry.event, + seq: entry.sourceSeq, + family: identity.family, + ...(entry.superseded && entry.superseded.length > 0 + ? { superseded: entry.superseded } + : {}), + }); + dueAtMs.set(identity.agentKey, 0); + lastSentAtMs.delete(identity.agentKey); + } + if (retryOutbound.length > 0) { + pendingOutbound.unshift(...retryOutbound); + } + }, + nextDueAtMs() { + if (pendingOutbound.length > 0) return 0; let earliest: number | null = null; for (const due of dueAtMs.values()) { if (earliest == null || due < earliest) earliest = due; @@ -402,7 +486,7 @@ export function createSubagentProgressCoalescer(options: { }, get pendingCount() { - return pending.size; + return pending.size + pendingOutbound.length; }, }; } diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 5806ade2f2..e7efec623f 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -85,6 +85,7 @@ 1A0D93D06509796A389A4CAA /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; }; 26C8992F8D661707E9360503 /* Database.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51942BAC0965C7D0CCE6E8B8 /* Database.swift */; }; 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66B5024B0A05F3D9754101F1 /* SyncService.swift */; }; + F10000000000000000000101 /* ChatToolResultCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000102 /* ChatToolResultCache.swift */; }; B70000000000000000000002 /* SyncRecoveryPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000001 /* SyncRecoveryPolicy.swift */; }; B70000000000000000000004 /* SyncRecoveryPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */; }; B7000000000000000000002F /* PairedHostCredentialStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7000000000000000000001F /* PairedHostCredentialStateTests.swift */; }; @@ -116,6 +117,7 @@ E1000000000000000000002B /* WorkChatSessionView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002B /* WorkChatSessionView+Actions.swift */; }; E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */; }; E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002D /* WorkChatRichCardViews.swift */; }; + F10000000000000000000103 /* WorkRemoteToolResultAffordance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000104 /* WorkRemoteToolResultAffordance.swift */; }; E10000000000000000000055 /* WorkChatPrViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000055 /* WorkChatPrViews.swift */; }; E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000054 /* WorkPlanComposerViews.swift */; }; E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000050 /* WorkChatAttachmentTray.swift */; }; @@ -208,6 +210,7 @@ D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3000000000000000000000A /* WorkPromptStashTests.swift */; }; DCF01D0000000000000002 /* WorkComposerFoldStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */; }; D31000000000000000000F0D /* WorkToolsContractTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000F0D /* WorkToolsContractTests.swift */; }; + F10000000000000000000105 /* WorkChatToolResultCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10000000000000000000106 /* WorkChatToolResultCacheTests.swift */; }; D31000000000000000000F20 /* WorkProofRowModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D30000000000000000000F20 /* WorkProofRowModelTests.swift */; }; FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */; }; FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */; }; @@ -451,6 +454,7 @@ D1000000000000000000002B /* WorkChatSessionView+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Actions.swift"; path = "ADE/Views/Work/WorkChatSessionView+Actions.swift"; sourceTree = ""; }; D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatHeaderAndMessageViews.swift; path = ADE/Views/Work/WorkChatHeaderAndMessageViews.swift; sourceTree = ""; }; D1000000000000000000002D /* WorkChatRichCardViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatRichCardViews.swift; path = ADE/Views/Work/WorkChatRichCardViews.swift; sourceTree = ""; }; + F10000000000000000000104 /* WorkRemoteToolResultAffordance.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkRemoteToolResultAffordance.swift; path = ADE/Views/Work/WorkRemoteToolResultAffordance.swift; sourceTree = ""; }; D10000000000000000000055 /* WorkChatPrViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatPrViews.swift; path = ADE/Views/Work/WorkChatPrViews.swift; sourceTree = ""; }; D10000000000000000000054 /* WorkPlanComposerViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPlanComposerViews.swift; path = ADE/Views/Work/WorkPlanComposerViews.swift; sourceTree = ""; }; D10000000000000000000050 /* WorkChatAttachmentTray.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentTray.swift; path = ADE/Views/Work/WorkChatAttachmentTray.swift; sourceTree = ""; }; @@ -553,6 +557,7 @@ D3000000000000000000000A /* WorkPromptStashTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPromptStashTests.swift; path = ADETests/WorkPromptStashTests.swift; sourceTree = ""; }; DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkComposerFoldStateTests.swift; path = ADETests/WorkComposerFoldStateTests.swift; sourceTree = ""; }; D30000000000000000000F0D /* WorkToolsContractTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkToolsContractTests.swift; path = ADETests/WorkToolsContractTests.swift; sourceTree = ""; }; + F10000000000000000000106 /* WorkChatToolResultCacheTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatToolResultCacheTests.swift; path = ADETests/WorkChatToolResultCacheTests.swift; sourceTree = ""; }; D30000000000000000000F20 /* WorkProofRowModelTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkProofRowModelTests.swift; path = ADETests/WorkProofRowModelTests.swift; sourceTree = ""; }; FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkSessionCanonicalStateTests.swift; path = ADETests/WorkSessionCanonicalStateTests.swift; sourceTree = ""; }; 27A125DE2C17BA32F9291513 /* ADE.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ADE.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -571,6 +576,7 @@ 51942BAC0965C7D0CCE6E8B8 /* Database.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Database.swift; path = ADE/Services/Database.swift; sourceTree = ""; }; 5EE4D463D21266B62B422D11 /* PRsTabView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PRsTabView.swift; path = ADE/Views/PRsTabView.swift; sourceTree = ""; }; 66B5024B0A05F3D9754101F1 /* SyncService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncService.swift; path = ADE/Services/SyncService.swift; sourceTree = ""; }; + F10000000000000000000102 /* ChatToolResultCache.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ChatToolResultCache.swift; path = ADE/Services/ChatToolResultCache.swift; sourceTree = ""; }; B70000000000000000000001 /* SyncRecoveryPolicy.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicy.swift; path = ADE/Services/SyncRecoveryPolicy.swift; sourceTree = ""; }; B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SyncRecoveryPolicyTests.swift; path = ADETests/SyncRecoveryPolicyTests.swift; sourceTree = ""; }; B7000000000000000000001F /* PairedHostCredentialStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PairedHostCredentialStateTests.swift; path = ADETests/PairedHostCredentialStateTests.swift; sourceTree = ""; }; @@ -1002,6 +1008,7 @@ D10000000000000000000F01 /* WorkLaneOrder.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + F10000000000000000000104 /* WorkRemoteToolResultAffordance.swift */, D10000000000000000000055 /* WorkChatPrViews.swift */, D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, @@ -1282,6 +1289,7 @@ D3000000000000000000000A /* WorkPromptStashTests.swift */, DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */, D30000000000000000000F0D /* WorkToolsContractTests.swift */, + F10000000000000000000106 /* WorkChatToolResultCacheTests.swift */, D30000000000000000000F20 /* WorkProofRowModelTests.swift */, FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */, FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */, @@ -1633,6 +1641,7 @@ 26C8992F8D661707E9360503 /* Database.swift in Sources */, 0375D32BA5870617FA1758C6 /* KeychainService.swift in Sources */, 28CFE3D489EA1B208D231519 /* SyncService.swift in Sources */, + F10000000000000000000101 /* ChatToolResultCache.swift in Sources */, C5B000000000000000000001 /* MobileTrustResetPolicy.swift in Sources */, C5B00000000000000000001F /* PairedHostCredentialState.swift in Sources */, C5B000000000000000000002 /* SSHBootstrapModels.swift in Sources */, @@ -1797,6 +1806,7 @@ E10000000000000000000F01 /* WorkLaneOrder.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, + F10000000000000000000103 /* WorkRemoteToolResultAffordance.swift in Sources */, E10000000000000000000055 /* WorkChatPrViews.swift in Sources */, E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */, E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, @@ -1880,6 +1890,7 @@ D31000000000000000000019 /* WorkPromptStashTests.swift in Sources */, DCF01D0000000000000002 /* WorkComposerFoldStateTests.swift in Sources */, D31000000000000000000F0D /* WorkToolsContractTests.swift in Sources */, + F10000000000000000000105 /* WorkChatToolResultCacheTests.swift in Sources */, D31000000000000000000F20 /* WorkProofRowModelTests.swift in Sources */, FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */, FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */, diff --git a/apps/ios/ADE/Services/ChatToolResultCache.swift b/apps/ios/ADE/Services/ChatToolResultCache.swift new file mode 100644 index 0000000000..273d27f657 --- /dev/null +++ b/apps/ios/ADE/Services/ChatToolResultCache.swift @@ -0,0 +1,56 @@ +import Foundation + +/// Bounded, versioned cache for tool results fetched from the host. +/// +/// The item id is not a sufficient identity: a retry can reuse an item id for +/// a newer result. The transcript sequence is therefore part of both the +/// cache and in-flight request keys whenever the wire provides one. +@MainActor +final class WorkChatToolResultCache { + private let limit: Int + private var values: [String: String] = [:] + private var order: [String] = [] + private var inFlight: [String: Task] = [:] + + init(limit: Int = 32) { + self.limit = max(1, limit) + } + + func cachedFullToolResult( + sessionId: String, + itemId: String, + eventSequence: Int? + ) -> String? { + values[key(sessionId: sessionId, itemId: itemId, eventSequence: eventSequence)] + } + + func fullToolResult( + sessionId: String, + itemId: String, + eventSequence: Int?, + fetch: @escaping () async throws -> String + ) async throws -> String { + let cacheKey = key(sessionId: sessionId, itemId: itemId, eventSequence: eventSequence) + if let cached = values[cacheKey] { return cached } + if let task = inFlight[cacheKey] { return try await task.value } + + let task = Task { + try await fetch() + } + inFlight[cacheKey] = task + defer { inFlight[cacheKey] = nil } + + let value = try await task.value + values[cacheKey] = value + order.append(cacheKey) + while order.count > limit { + let evicted = order.removeFirst() + values[evicted] = nil + } + return value + } + + private func key(sessionId: String, itemId: String, eventSequence: Int?) -> String { + "\(sessionId)|\(itemId)|\(eventSequence.map(String.init) ?? "latest")" + } +} diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 4aca63f543..ffabbb2ee3 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13803,26 +13803,22 @@ final class SyncService: ObservableObject { return page } - /// Full tool results already fetched on this device, keyed - /// `sessionId|itemId`. - /// - /// A tool result is immutable once written, so a hit is always correct and - /// re-expanding a row never pays for the round trip twice. Lives here rather - /// than in the row so it survives the LazyVStack recycling the row, and so - /// the inline card and the turn-activity sheet share one copy. Bounded: a - /// long thread must not accumulate every result the user ever opened. - private var chatToolResultCache: [String: String] = [:] - private var chatToolResultCacheOrder: [String] = [] - private var chatToolResultInFlight: [String: Task] = [:] - private static let chatToolResultCacheLimit = 32 - - private func chatToolResultCacheKey(sessionId: String, itemId: String) -> String { - "\(sessionId)|\(itemId)" - } + /// Full tool results already fetched on this device. The bounded cache is + /// shared by the inline card and the turn-activity sheet, and keys include + /// the transcript sequence so a retried item id cannot reuse an older result. + private let chatToolResultCache = WorkChatToolResultCache() /// The cached full result, if this row has already been expanded once. - func cachedFullToolResult(sessionId: String, itemId: String) -> String? { - chatToolResultCache[chatToolResultCacheKey(sessionId: sessionId, itemId: itemId)] + func cachedFullToolResult( + sessionId: String, + itemId: String, + eventSequence: Int? = nil + ) -> String? { + chatToolResultCache.cachedFullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence + ) } /// Fetch (or return the cached) full text of one truncated tool result. @@ -13830,11 +13826,16 @@ final class SyncService: ObservableObject { /// Concurrent expands of the same row share one request: a double tap, or an /// inline card and the turn-activity sheet showing the same result, must not /// become two reads of the same transcript. - func fullToolResult(sessionId: String, itemId: String) async throws -> String { - let key = chatToolResultCacheKey(sessionId: sessionId, itemId: itemId) - if let cached = chatToolResultCache[key] { return cached } - if let inFlight = chatToolResultInFlight[key] { return try await inFlight.value } - let task = Task { [weak self] in + func fullToolResult( + sessionId: String, + itemId: String, + eventSequence: Int? = nil + ) async throws -> String { + try await chatToolResultCache.fullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence + ) { [weak self] in guard let self else { throw CancellationError() } let response = try await self.fetchChatToolResult(sessionId: sessionId, itemId: itemId) if response.unavailable == true { @@ -13853,16 +13854,6 @@ final class SyncService: ObservableObject { } return prettyPrintedRemoteJSONValue(result) } - chatToolResultInFlight[key] = task - defer { chatToolResultInFlight[key] = nil } - let text = try await task.value - chatToolResultCache[key] = text - chatToolResultCacheOrder.append(key) - while chatToolResultCacheOrder.count > Self.chatToolResultCacheLimit { - let evicted = chatToolResultCacheOrder.removeFirst() - chatToolResultCache[evicted] = nil - } - return text } /// Fetch one tool result in full, for a row the slim mobile wire delivered diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 340413ac6a..1383c666b5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -960,10 +960,7 @@ struct WorkQueuedSteerRow: View { /// Says what happens next, not when it was typed: a queued message's /// timestamp is always "a moment ago" and carried no information. private var dispositionText: String { - guard turnActive else { return "after turn" } - // A provider that takes a message mid-turn is not parking this one until - // the turn ends — Claude picks it up at the next tool step. - return capability.modes.contains(.inline) ? "sends at next step" : "sends when turn ends" + workQueuedSteerDisposition(capability: capability, turnActive: turnActive).shortText } // Icon-only tap target with an accessibility label so the row's actions stay diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 16d8a960cc..36c14e50d7 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -89,45 +89,7 @@ struct WorkToolCardView: View, Equatable { let onOpenPr: (Int) -> Void @Environment(\.openURL) private var openURL - @EnvironmentObject private var syncService: SyncService @State private var resultExpanded = false - /// The full result, once fetched for a row the slim mobile wire truncated. - /// Seeded from the service cache on appear so a recycled row does not lose - /// what the user already opened. - @State private var fetchedFullResult: String? - @State private var fetchingFullResult = false - @State private var fullResultError: String? - - /// The result this card should render: the fetched full text when there is - /// one, otherwise whatever arrived on the wire. - private var effectiveResultText: String? { - fetchedFullResult ?? toolCard.resultText - } - - /// True while the card is showing a head slice and the rest is one tap away. - private var hasUnfetchedRemoteResult: Bool { - fetchedFullResult == nil && toolCard.remoteResultBytes != nil && toolCard.sessionId != nil - } - - private func loadFullResult() { - guard let sessionId = toolCard.sessionId, !fetchingFullResult else { return } - fetchingFullResult = true - fullResultError = nil - Task { @MainActor in - defer { fetchingFullResult = false } - do { - fetchedFullResult = try await syncService.fullToolResult( - sessionId: sessionId, - itemId: toolCard.id - ) - resultExpanded = true - } catch { - // The slice stays on screen; only the affordance changes. Losing the - // preview to an error message would hide what the user already had. - fullResultError = (error as NSError).localizedDescription - } - } - } var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -208,16 +170,28 @@ struct WorkToolCardView: View, Equatable { if let argsText = toolCard.argsText, !argsText.isEmpty { WorkStructuredOutputBlock(title: "Arguments", text: argsText) } - if let resultText = effectiveResultText, !resultText.isEmpty { - let result = workToolResultBlockText(resultText, expanded: resultExpanded) - // The block displays a slice; Copy and the viewer get the whole - // result. - WorkStructuredOutputBlock(title: "Result", text: result.displayed, copyText: result.copy) - toolResultAffordances( - resultText: result.copy, - displayedText: result.displayed, - didTruncate: result.didTruncate - ) + if let resultText = toolCard.resultText, !resultText.isEmpty { + if let remoteResultBytes = toolCard.remoteResultBytes, + let sessionId = toolCard.sessionId { + WorkRemoteToolResultAffordance( + toolName: toolDisplayName(toolCard.toolName), + itemId: toolCard.id, + sessionId: sessionId, + resultText: resultText, + remoteResultBytes: remoteResultBytes, + eventSequence: toolCard.resultSequence + ) + } else { + let result = workToolResultBlockText(resultText, expanded: resultExpanded) + // The block displays a slice; Copy and the viewer get the whole + // result. + WorkStructuredOutputBlock(title: "Result", text: result.displayed, copyText: result.copy) + toolResultAffordances( + resultText: result.copy, + displayedText: result.displayed, + didTruncate: result.didTruncate + ) + } } } } @@ -261,12 +235,6 @@ struct WorkToolCardView: View, Equatable { displayedText: String, didTruncate: Bool ) -> some View { - if hasUnfetchedRemoteResult { - // This machine sent one screen of the result and kept the rest. The row - // says how much more there is, fetches it on tap, and shows the fetch - // running rather than freezing on the slice. - remoteResultAffordance - } else { let affordance = workTruncatedOutputAffordance( isTruncated: didTruncate, hasExpandedInPlace: resultExpanded, @@ -318,48 +286,6 @@ struct WorkToolCardView: View, Equatable { Spacer(minLength: 0) } - } - } - - /// "Show all (N)" for a result whose bytes live on the machine, not here. - @ViewBuilder - private var remoteResultAffordance: some View { - HStack(spacing: 12) { - if fetchingFullResult { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Loading full result…") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - } - .frame(minHeight: 44) - .accessibilityLabel("Loading full tool result") - } else { - Button(action: loadFullResult) { - Text("Show all (\(workToolResultRemoteByteLabel(toolCard.remoteResultBytes ?? 0)))") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .frame(minHeight: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Show full tool result") - } - - if let fullResultError { - Text(fullResultError) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(2) - } - - Spacer(minLength: 0) - } - .onAppear { - guard fetchedFullResult == nil, let sessionId = toolCard.sessionId else { return } - fetchedFullResult = syncService.cachedFullToolResult(sessionId: sessionId, itemId: toolCard.id) - } } /// Single-line compact row used once the tool completes — matches the diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index b7bfbbd9d3..bbf531ed62 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -876,7 +876,15 @@ func makeWorkChatTranscript(from entries: [AgentChatEventEnvelope]) -> [WorkChat isLegacySubagentCompletedFrame: entry.isLegacySubagentCompletedFrame, stopSource: entry.stopSource, stopReason: entry.stopReason, - toolResultFullBytes: entry.toolResultFullBytes + toolResultFullBytes: entry.toolResultFullBytes, + commandLifecycleStatus: { + guard case .commandLifecycle(_, let status, _, _, _) = entry.event else { return nil } + return status + }(), + commandLifecycleSteerId: { + guard case .commandLifecycle(_, _, _, let steerId, _) = entry.event else { return nil } + return steerId + }() ) } .sorted(by: workChatEnvelopeOrderedBefore) @@ -1355,12 +1363,17 @@ private func duplicateWorkTextEnvelopeCount(_ transcript: [WorkChatEnvelope]) -> } /// Drop stale queued `user_message` rows once the same steerId has graduated -/// to delivered/inline/failed or been resolved by a steer system notice. +/// to delivered/inline/failed, been resolved by a steer system notice, or has +/// a non-queued Claude command lifecycle frame. func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvelope] { guard !transcript.isEmpty else { return transcript } var resolvedSteerIds = Set() var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { + if let steerId = envelope.commandLifecycleSteerId, + envelope.commandLifecycleStatus != "queued" { + resolvedSteerIds.insert(steerId) + } switch envelope.event { case .userMessage(let text, _, _, let steerId, let deliveryState, _): if let steerId, deliveryState == "queued" { @@ -2181,6 +2194,11 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin var resolved = Set() var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { + if let steerId = envelope.commandLifecycleSteerId, + envelope.commandLifecycleStatus != "queued" { + queue.removeValue(forKey: steerId) + resolved.insert(steerId) + } switch envelope.event { case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, _): if let steerId, deliveryState == "queued", !resolved.contains(steerId) { diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 74fb0a7466..94b69565ab 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -88,6 +88,9 @@ struct WorkToolCardModel: Identifiable, Equatable { /// Session this card came from. Needed only to fetch a truncated result, so /// it is optional: a card built without one simply cannot offer the fetch. let sessionId: String? + /// Transcript sequence for the result. Retry events may reuse `itemId`, so + /// the sequence is part of the remote-result cache identity. + let resultSequence: Int? init( id: String, @@ -100,7 +103,8 @@ struct WorkToolCardModel: Identifiable, Equatable { webSearchActions: [CodexWebSearchAction]? = nil, webSearchResults: [CodexWebSearchResult]? = nil, remoteResultBytes: Int? = nil, - sessionId: String? = nil + sessionId: String? = nil, + resultSequence: Int? = nil ) { self.id = id self.toolName = toolName @@ -113,6 +117,7 @@ struct WorkToolCardModel: Identifiable, Equatable { self.webSearchResults = webSearchResults self.remoteResultBytes = remoteResultBytes self.sessionId = sessionId + self.resultSequence = resultSequence } } @@ -631,6 +636,36 @@ struct WorkActiveSendCapability: Equatable { } } +struct WorkQueuedSteerDisposition: Equatable { + let shortText: String + let detailText: String +} + +/// Shared wording for the staged-message strip and its detail sheet. Keeping +/// the capability decision here prevents the two surfaces from drifting when +/// a provider gains or loses inline steering. +func workQueuedSteerDisposition( + capability: WorkActiveSendCapability, + turnActive: Bool +) -> WorkQueuedSteerDisposition { + guard turnActive else { + return WorkQueuedSteerDisposition( + shortText: "after turn", + detailText: "It sends as soon as \(capability.agentLabel) is ready." + ) + } + if capability.modes.contains(.inline) { + return WorkQueuedSteerDisposition( + shortText: "sends at next step", + detailText: "\(capability.agentLabel) picks it up after the current tool step." + ) + } + return WorkQueuedSteerDisposition( + shortText: "sends when turn ends", + detailText: "\(capability.agentLabel) can't take a message mid-turn, so it sends when this turn ends." + ) +} + /// Work-board columns, as they are written on screen. /// /// Hand mirror of `WORK_BOARD_COLUMN_LABEL` in @@ -1693,6 +1728,12 @@ struct WorkChatEnvelope: Identifiable, Equatable { /// the slim mobile wire sent, nil when the result is complete. Drives the /// Result block's on-demand fetch. let toolResultFullBytes: Int? + /// Raw Claude queue lifecycle metadata. `WorkChatEvent` intentionally maps + /// non-terminal lifecycle frames to no visible card, but pending-steer + /// derivation still needs the status to clear a staged row when the host + /// reports `started`/`completed` without a delivered user-message frame. + let commandLifecycleStatus: String? + let commandLifecycleSteerId: String? init( sessionId: String, @@ -1709,7 +1750,9 @@ struct WorkChatEnvelope: Identifiable, Equatable { isLegacySubagentCompletedFrame: Bool = false, stopSource: String? = nil, stopReason: String? = nil, - toolResultFullBytes: Int? = nil + toolResultFullBytes: Int? = nil, + commandLifecycleStatus: String? = nil, + commandLifecycleSteerId: String? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -1726,6 +1769,8 @@ struct WorkChatEnvelope: Identifiable, Equatable { self.stopSource = stopSource self.stopReason = stopReason self.toolResultFullBytes = toolResultFullBytes + self.commandLifecycleStatus = commandLifecycleStatus + self.commandLifecycleSteerId = commandLifecycleSteerId } } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 0ca5840ebd..e89f22974f 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -897,7 +897,8 @@ func buildWorkToolCards( // block can offer the fetch instead of pretending the slice is all // there was. remoteResultBytes: envelope.toolResultFullBytes, - sessionId: envelope.sessionId + sessionId: envelope.sessionId, + resultSequence: envelope.sequence ) case .webSearch(let query, let action, let actions, let results, let status, let itemId, _): // Web searches are tool calls — render them as tool cards so they're diff --git a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift index 201a1515d5..206731a290 100644 --- a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift @@ -115,13 +115,7 @@ struct WorkQueuedSteerDetailSheet: View { /// strip's one-line disposition had no room to say so. private var stateDetail: String { let staged = "Staged \(relativeTimestamp(steer.timestamp))." - guard turnActive else { - return "\(staged) It sends as soon as \(capability.agentLabel) is ready." - } - if capability.modes.contains(.inline) { - return "\(staged) \(capability.agentLabel) picks it up after the current tool step." - } - return "\(staged) \(capability.agentLabel) can't take a message mid-turn, so it sends when this turn ends." + return "\(staged) \(workQueuedSteerDisposition(capability: capability, turnActive: turnActive).detailText)" } private var sendNowRow: some View { diff --git a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift new file mode 100644 index 0000000000..ac5e013aa8 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -0,0 +1,187 @@ +import SwiftUI + +/// Result block for a mobile tool result whose tail remains on the host. +/// +/// Keeping the fetch state with this focused view prevents a recycled tool row +/// from carrying a previous result's loading/error state, while the transcript +/// sequence keeps a retried item id from hydrating the old full result. +struct WorkRemoteToolResultAffordance: View { + let toolName: String + let itemId: String + let sessionId: String + let resultText: String + let remoteResultBytes: Int + let eventSequence: Int? + + @EnvironmentObject private var syncService: SyncService + @State private var resultExpanded = false + @State private var fetchedFullResult: String? + @State private var fetchingFullResult = false + @State private var fullResultError: String? + @State private var fetchGeneration = 0 + + private var effectiveResultText: String { + fetchedFullResult ?? resultText + } + + private var resultIdentity: String { + "\(sessionId)|\(itemId)|\(eventSequence.map(String.init) ?? "latest")" + } + + var body: some View { + let result = workToolResultBlockText(effectiveResultText, expanded: resultExpanded) + VStack(alignment: .leading, spacing: 8) { + WorkStructuredOutputBlock(title: "Result", text: result.displayed, copyText: result.copy) + affordances( + resultText: result.copy, + displayedText: result.displayed, + didTruncate: result.didTruncate + ) + } + .onAppear(perform: hydrateCachedResult) + .onChange(of: resultIdentity) { _, _ in + fetchGeneration += 1 + fetchedFullResult = nil + fetchingFullResult = false + fullResultError = nil + resultExpanded = false + hydrateCachedResult() + } + } + + @ViewBuilder + private func affordances( + resultText: String, + displayedText: String, + didTruncate: Bool + ) -> some View { + if fetchedFullResult == nil { + remoteAffordance + } else { + let affordance = workTruncatedOutputAffordance( + isTruncated: didTruncate, + hasExpandedInPlace: resultExpanded, + isClipped: workOutputBoxOverflows( + displayedText, + lineCapacity: workStructuredOutputBoxLineCapacity, + columnCapacity: workStructuredOutputBoxColumnCapacity + ) + ) + HStack(spacing: 12) { + switch affordance { + case .none: + EmptyView() + case .showMore: + Button { + resultExpanded = true + } label: { + Text("Show all (\(workToolResultByteLabel(resultText)))") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Show full tool result") + case .openFullOutput: + WorkOpenFullOutputButton( + title: toolName, + subtitle: "Result", + text: resultText, + label: "Open full output", + prominent: true + ) + } + + if resultExpanded { + Button { + resultExpanded = false + } label: { + Text("Collapse") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Collapse tool result") + } + Spacer(minLength: 0) + } + } + } + + @ViewBuilder + private var remoteAffordance: some View { + HStack(spacing: 12) { + if fetchingFullResult { + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Loading full result…") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + .frame(minHeight: 44) + .accessibilityLabel("Loading full tool result") + } else { + Button(action: loadFullResult) { + Text("Show all (\(workToolResultRemoteByteLabel(remoteResultBytes)))") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .frame(minHeight: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Show full tool result") + } + + if let fullResultError { + Text(fullResultError) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + Spacer(minLength: 0) + } + } + + private func loadFullResult() { + guard !fetchingFullResult else { return } + fetchGeneration += 1 + let requestGeneration = fetchGeneration + fetchingFullResult = true + fullResultError = nil + Task { @MainActor in + defer { + if fetchGeneration == requestGeneration { + fetchingFullResult = false + } + } + do { + let fullResult = try await syncService.fullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence + ) + guard fetchGeneration == requestGeneration else { return } + fetchedFullResult = fullResult + resultExpanded = true + } catch { + guard fetchGeneration == requestGeneration else { return } + // The slice stays on screen; only the affordance changes. Losing the + // preview to an error message would hide what the user already had. + fullResultError = (error as NSError).localizedDescription + } + } + } + + private func hydrateCachedResult() { + guard fetchedFullResult == nil else { return } + fetchedFullResult = syncService.cachedFullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence + ) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index f6673b508d..57820d9dfc 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -1091,6 +1091,23 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { turnId: turnId ) ) + case "command_lifecycle": + let status = optionalString(eventDict["status"]) + let steerId = optionalString(eventDict["steerId"] ?? eventDict["steer_id"]) + if status == "cancelled" || status == "discarded" { + let verb = status == "discarded" ? "discarded" : "cancelled" + event = .systemNotice( + kind: "command_lifecycle", + message: optionalString(eventDict["preview"]).map { "Queued message \(verb): \($0)" } ?? "Queued message \(verb)", + detail: nil, + turnId: turnId, + steerId: steerId + ) + } else { + // The lifecycle still matters to the pending-steer fold, but it is + // not a visible timeline card for queued/started/completed states. + event = .unknown(type: type) + } case "model_handoff": // Must produce the exact same shape as the live path in // `WorkEventMapping` — a replayed transcript and a streamed event have @@ -1122,7 +1139,11 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { apiErrorStatus: apiErrorStatus, isLegacySubagentCompletedFrame: type == "subagent.completed", stopSource: stopSource, - stopReason: stopReason + stopReason: stopReason, + commandLifecycleStatus: type == "command_lifecycle" ? optionalString(eventDict["status"]) : nil, + commandLifecycleSteerId: type == "command_lifecycle" + ? optionalString(eventDict["steerId"] ?? eventDict["steer_id"]) + : nil ) } .sorted(by: workChatEnvelopeOrderedBefore) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 263267bcdf..e99e6278db 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -20579,6 +20579,28 @@ final class ADETests: XCTestCase { XCTAssertTrue(steers.isEmpty) } + func testMakeWorkChatTranscriptClearsQueuedSteerWhenCommandLifecycleStarts() throws { + let json = """ + { + "sessionId": "chat-1", + "capturedAt": "2026-09-20T00:00:00.000Z", + "events": [ + {"sessionId":"chat-1","timestamp":"2026-09-20T00:00:01.000Z","sequence":1,"event":{"type":"user_message","text":"start the queued request","turnId":"turn-1","steerId":"steer-1","deliveryState":"queued"}}, + {"sessionId":"chat-1","timestamp":"2026-09-20T00:00:02.000Z","sequence":2,"event":{"type":"command_lifecycle","commandUuid":"command-1","status":"started","steerId":"steer-1","turnId":"turn-1"}} + ], + "truncated": false + } + """ + + let snapshot = try JSONDecoder().decode(AgentChatEventHistorySnapshot.self, from: Data(json.utf8)) + let transcript = makeWorkChatTranscript(from: snapshot.events) + + XCTAssertEqual(transcript.last?.commandLifecycleStatus, "started") + XCTAssertEqual(transcript.last?.commandLifecycleSteerId, "steer-1") + XCTAssertTrue(derivePendingWorkSteers(from: transcript).isEmpty) + XCTAssertEqual(pruneResolvedQueuedSteerEnvelopes(transcript).count, 1) + } + /// The staged strip is now the "you queued this" surface and nothing else. An /// atomically dispatched send writes a user message with an immediate /// delivery state (or none at all) and must never produce a strip entry — diff --git a/apps/ios/ADETests/WorkChatToolResultCacheTests.swift b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift new file mode 100644 index 0000000000..716d85506b --- /dev/null +++ b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import ADE + +@MainActor +final class WorkChatToolResultCacheTests: XCTestCase { + func testSameItemUsesTheTranscriptSequenceAsTheCacheVersion() async throws { + let cache = WorkChatToolResultCache(limit: 4) + var fetchCount = 0 + + let first = try await cache.fullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 10 + ) { + fetchCount += 1 + return "old result" + } + let sameVersion = try await cache.fullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 10 + ) { + fetchCount += 1 + return "should not be fetched" + } + let retry = try await cache.fullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 11 + ) { + fetchCount += 1 + return "new result" + } + + XCTAssertEqual(first, "old result") + XCTAssertEqual(sameVersion, "old result") + XCTAssertEqual(retry, "new result") + XCTAssertEqual(fetchCount, 2) + XCTAssertEqual( + cache.cachedFullToolResult(sessionId: "session-1", itemId: "item-1", eventSequence: 10), + "old result" + ) + XCTAssertEqual( + cache.cachedFullToolResult(sessionId: "session-1", itemId: "item-1", eventSequence: 11), + "new result" + ) + } +} diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift index 53d7f30211..817564e342 100644 --- a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -41,11 +41,6 @@ final class WorkChatScrollBenchUITests: XCTestCase { return app } - /// Transcript area of the window: below the header, above the composer. - private func transcript(_ app: XCUIApplication) -> XCUIElement { - app.scrollViews.firstMatch.exists ? app.scrollViews.firstMatch : app.windows.firstMatch - } - private func drag( _ app: XCUIApplication, fromY: CGFloat, From 68735f044a4f145b1129b2719df9cae371f4452f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 20:46:51 -0400 Subject: [PATCH 18/26] fix(ios): retain staged uploads across navigation --- .../desktop/src/shared/chatMobileSlim.test.ts | 1 - apps/desktop/src/shared/chatMobileSlim.ts | 11 - .../ADE/Views/Work/WorkChatScrollBench.swift | 14 +- .../ADE/Views/Work/WorkChatSessionView.swift | 6 +- .../Work/WorkComposerAttachmentStaging.swift | 66 +++++- .../WorkComposerDraftAttachmentTests.swift | 212 +++++++++++++++++- 6 files changed, 276 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/shared/chatMobileSlim.test.ts b/apps/desktop/src/shared/chatMobileSlim.test.ts index 36e9839d64..cce3afea10 100644 --- a/apps/desktop/src/shared/chatMobileSlim.test.ts +++ b/apps/desktop/src/shared/chatMobileSlim.test.ts @@ -195,7 +195,6 @@ describe("createSubagentProgressCoalescer", () => { expect(coalescer.pendingCount).toBe(1); expect(coalescer.flushAll(20)).toHaveLength(1); expect(coalescer.pendingCount).toBe(0); - expect(coalescer.nextDueAtMs()).toBeNull(); }); it("requeues progress when the transport rejects a flush", () => { diff --git a/apps/desktop/src/shared/chatMobileSlim.ts b/apps/desktop/src/shared/chatMobileSlim.ts index 90e644b6a1..09a8c3ccfa 100644 --- a/apps/desktop/src/shared/chatMobileSlim.ts +++ b/apps/desktop/src/shared/chatMobileSlim.ts @@ -300,8 +300,6 @@ export type SubagentProgressCoalescer = { flushAll(nowMs: number): CoalescedChatEvent[]; /** Put progress back after the transport rejected an outbound event. */ requeue(entries: readonly CoalescedChatEvent[]): void; - /** Earliest ms at which `flushDue` could produce anything, or null. */ - nextDueAtMs(): number | null; readonly pendingCount: number; }; @@ -476,15 +474,6 @@ export function createSubagentProgressCoalescer(options: { } }, - nextDueAtMs() { - if (pendingOutbound.length > 0) return 0; - let earliest: number | null = null; - for (const due of dueAtMs.values()) { - if (earliest == null || due < earliest) earliest = due; - } - return earliest; - }, - get pendingCount() { return pending.size + pendingOutbound.length; }, diff --git a/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift index 95c78e4947..efa6581074 100644 --- a/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift +++ b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift @@ -101,18 +101,6 @@ enum WorkChatScrollTrace { #endif } - /// A write site that decided not to write. Counting the refusals is how a - /// "nothing happened when I asked" complaint gets separated from a jump. - @MainActor - static func writeSuppressed(reason: String, site: String, cause: String) { - #if DEBUG - guard enabled else { return } - logger.log( - "suppressed reason=\(reason, privacy: .public) site=\(site, privacy: .public) cause=\(cause, privacy: .public)" - ) - #endif - } - /// One per scroll geometry frame. The offset delta between consecutive /// samples is the ground truth for "the reader moved"; a large delta with no /// `write` line in front of it is SwiftUI's own re-measurement. @@ -412,7 +400,7 @@ struct WorkChatScrollBenchScreen: View { let started = ProcessInfo.processInfo.systemUptime var envelopes = WorkChatScrollBenchLoader.load(path: path, limit: options.limit) guard !envelopes.isEmpty else { - WorkChatScrollTrace.note("bench loaded 0 envelopes from \(path)") + WorkChatScrollTrace.note("bench loaded 0 envelopes") return } if options.streamCount > 0, envelopes.count > options.streamCount { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index a0625c084e..6ed457dad0 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -3076,7 +3076,11 @@ private struct WorkChatComposerDraftInput: View { // The visible attachments belong to the chat we just left. Flush them // under that key and clear, exactly as `bind` does for the text. persistDraftAttachments(for: previousKey) - WorkComposerAttachmentUploads.shared.release(inputAttachments.map(\.id)) + // Keep upload entries alive across navigation. Cancelling this local + // Task does not abort the host's chunked command, so releasing here + // would let the restored draft start a duplicate upload under the same + // bytes. The persisted id lets the next composer reuse the entry; the + // bounded tracker drops old entries when it reaches its cap. inputAttachments = [] } guard !key.isEmpty, inputAttachments.isEmpty, diff --git a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift index acdc402892..b8721c4e9e 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift @@ -258,8 +258,10 @@ func workChatPersistComposerAttachments( /// array being snapshotted and cleared by the send, which a per-element flag /// would not. /// -/// Bounded: finished entries are dropped by `release`, and the map is trimmed to -/// `maxEntries` oldest-first so an abandoned composer cannot grow it. +/// Settled entries are trimmed to `maxEntries` oldest-first. Active entries may +/// temporarily take the map over that cap: cancelling one is not a reliable +/// host abort, and evicting it would let a restored draft duplicate its upload. +/// They become eligible for pruning as soon as their host task settles. @MainActor final class WorkComposerAttachmentUploads { static let shared = WorkComposerAttachmentUploads() @@ -268,6 +270,8 @@ final class WorkComposerAttachmentUploads { var task: Task var ref: AgentChatFileRef? var failure: String? + var abandoned: Bool + var settled: Bool var startedAt: Date } @@ -290,14 +294,17 @@ final class WorkComposerAttachmentUploads { func isUploading(_ id: UUID) -> Bool { guard let entry = entries[id] else { return false } - return entry.ref == nil && entry.failure == nil + return !entry.settled } /// Ref for a restored attachment that was uploaded in a previous session, so /// a send after a restore does not re-upload the same bytes. func adopt(id: UUID, ref: AgentChatFileRef) { guard entries[id] == nil else { return } - insert(id, Entry(task: Task { ref }, ref: ref, failure: nil, startedAt: Date())) + insert( + id, + Entry(task: Task { ref }, ref: ref, failure: nil, abandoned: false, settled: true, startedAt: Date()) + ) } /// Starts the upload for one ready attachment. No-op when one is already @@ -336,10 +343,15 @@ final class WorkComposerAttachmentUploads { let task = Task { @MainActor () throws -> AgentChatFileRef in try await upload() } - insert(id, Entry(task: task, ref: nil, failure: nil, startedAt: Date())) + insert( + id, + Entry(task: task, ref: nil, failure: nil, abandoned: false, settled: false, startedAt: Date()) + ) Task { @MainActor in do { let ref = try await task.value + entries[id]?.abandoned = false + entries[id]?.settled = true entries[id]?.ref = ref } catch is CancellationError { entries[id] = nil @@ -347,6 +359,8 @@ final class WorkComposerAttachmentUploads { } catch { // A failed upload is not fatal: the send path retries inline, and the // draft falls back to the on-disk byte cache. + entries[id]?.abandoned = false + entries[id]?.settled = true entries[id]?.failure = error.localizedDescription } } @@ -366,6 +380,7 @@ final class WorkComposerAttachmentUploads { ) async -> WorkComposerAttachmentUploadResolution { guard let entry = entries[id] else { return .stageInline } if let ref = entry.ref { return .ref(ref) } + if entry.abandoned { return .abandoned } if entry.failure != nil { return .stageInline } let task = entry.task let settled = await workAwaitWithDeadline(timeoutNanoseconds: timeoutNanoseconds) { @@ -380,6 +395,7 @@ final class WorkComposerAttachmentUploads { return .stageInline case .none: entries[id]?.failure = workChatAttachmentUploadTimedOutMessage + entries[id]?.abandoned = true return .abandoned } } @@ -396,7 +412,8 @@ final class WorkComposerAttachmentUploads { entries[id] = entry order.append(id) while order.count > Self.maxEntries { - let oldest = order.removeFirst() + guard let index = order.firstIndex(where: { entries[$0]?.settled == true }) else { break } + let oldest = order.remove(at: index) entries[oldest]?.task.cancel() entries[oldest] = nil } @@ -418,10 +435,45 @@ enum WorkComposerDraftAttachmentCache { static let maxBytesPerKey = 10 * 1024 * 1024 struct StoredFile: Codable, Equatable { + /// The composer's identity, persisted so a navigation restore can reuse an + /// upload that is still in flight. Optional for files written before this + /// field existed; `read` gives those legacy records a fresh identity. + var id: UUID? var name: String var filename: String var mimeType: String var kind: String + + init( + id: UUID? = nil, + name: String, + filename: String, + mimeType: String, + kind: String + ) { + self.id = id + self.name = name + self.filename = filename + self.mimeType = mimeType + self.kind = kind + } + + private enum CodingKeys: String, CodingKey { + case id + case name + case filename + case mimeType + case kind + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + filename = try container.decode(String.self, forKey: .filename) + mimeType = try container.decode(String.self, forKey: .mimeType) + kind = try container.decode(String.self, forKey: .kind) + } } private static var root: URL? { @@ -485,6 +537,7 @@ enum WorkComposerDraftAttachmentCache { } totalBytes += data.count stored.append(StoredFile( + id: attachment.id, name: name, filename: attachment.filename, mimeType: attachment.mimeType, @@ -501,6 +554,7 @@ enum WorkComposerDraftAttachmentCache { data.count <= maxBytesPerKey else { return nil } let kind = WorkChatInputAttachmentKind(rawValue: file.kind) ?? .file return WorkChatInputAttachment( + id: file.id ?? UUID(), image: kind == .image ? UIImage(data: data) : nil, uploadData: data, filename: file.filename, diff --git a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift index 0987d58810..7019c6e12d 100644 --- a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift +++ b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift @@ -172,6 +172,164 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { XCTAssertTrue(WorkComposerDraftAttachmentCache.read(stored, for: draftKey).isEmpty) } + /// A navigation teardown persists bytes while the host upload may still be + /// running. Restoring the same attachment id lets the singleton tracker + /// recognize that work instead of starting a duplicate upload. Old cache + /// records without an id remain readable and receive a new identity. + @MainActor + func testCachedAttachmentKeepsUploadIdentityAcrossRestore() async throws { + let previousDraftKey = key() + let nextDraftKey = key() + defer { + WorkComposerDraftStore.clear(previousDraftKey) + WorkComposerDraftStore.clear(nextDraftKey) + } + let id = UUID() + let attachment = WorkChatInputAttachment( + id: id, + uploadData: Data("bytes".utf8), + filename: "note.txt", + mimeType: "text/plain", + kind: .file, + state: .ready + ) + + let uploads = WorkComposerAttachmentUploads.shared + var uploadCount = 0 + var release: CheckedContinuation? + defer { + release?.resume() + uploads.release([id]) + } + uploads.begin(id: id) { + uploadCount += 1 + await withCheckedContinuation { (continuation: CheckedContinuation) in + release = continuation + } + return AgentChatFileRef(path: "/never", type: "file") + } + workChatPersistComposerAttachments([attachment], for: previousDraftKey) + // Model the session view switching to another chat: the old composer is + // torn down, but its host task is still active and the old draft remains. + WorkComposerDraftStore.save("next chat", for: nextDraftKey) + guard let entry = WorkComposerDraftStore.loadEntry(previousDraftKey), + let stored = entry.localFiles.first else { + return XCTFail("draft did not persist the in-flight attachment") + } + + let resolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(resolution, .abandoned) + let restored = WorkComposerDraftAttachmentCache.read(entry.localFiles, for: previousDraftKey) + XCTAssertEqual(restored.first?.id, id) + + let legacyData = try JSONSerialization.data(withJSONObject: [ + "name": stored.name, + "filename": stored.filename, + "mimeType": stored.mimeType, + "kind": stored.kind, + ]) + let legacy = try JSONDecoder().decode( + WorkComposerDraftAttachmentCache.StoredFile.self, + from: legacyData + ) + XCTAssertNil(legacy.id) + + guard let restoredAttachment = restored.first else { + return XCTFail("cache restore lost the attachment") + } + uploads.begin(id: restoredAttachment.id) { + uploadCount += 1 + return AgentChatFileRef(path: "/duplicate", type: "file") + } + XCTAssertEqual(uploadCount, 1, "restoring an in-flight attachment must not duplicate its upload") + } + + /// Tracker capacity may evict settled refs, but it must never cancel an + /// upload whose host command can continue after the local Task is cancelled. + /// Otherwise a later restore could begin a second upload for the same bytes. + @MainActor + func testActiveUploadSurvivesTrackerCapacityEviction() async { + let firstID = UUID() + let uploads = WorkComposerAttachmentUploads.shared + var uploadCount = 0 + var release: CheckedContinuation? + var settledIDs: [UUID] = [] + defer { + release?.resume() + uploads.release([firstID] + settledIDs) + } + + uploads.begin(id: firstID) { + uploadCount += 1 + await withCheckedContinuation { (continuation: CheckedContinuation) in + release = continuation + } + return AgentChatFileRef(path: "/never", type: "file") + } + let firstResolution = await uploads.resolve(firstID, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(firstResolution, .abandoned) + + // Add forty settled entries after the pending upload. The bounded tracker + // must evict those settled entries first and retain the active first one. + for index in 0..<40 { + let id = UUID() + settledIDs.append(id) + uploads.adopt( + id: id, + ref: AgentChatFileRef(path: "/settled-\(index)", type: "file") + ) + } + + uploads.begin(id: firstID) { + uploadCount += 1 + return AgentChatFileRef(path: "/duplicate", type: "file") + } + XCTAssertEqual(uploadCount, 1, "capacity eviction must not duplicate an active upload") + } + + /// If every tracked upload is still active, the tracker intentionally grows + /// past the settled-entry cap until host work settles. A pending upload must + /// remain addressable rather than being cancelled and duplicated. + @MainActor + func testAllActiveUploadsRemainTrackedBeyondSettledCapacity() async { + let uploads = WorkComposerAttachmentUploads.shared + var activeIDs: [UUID] = [] + var continuations: [UUID: CheckedContinuation] = [:] + var startedCount = 0 + defer { + for continuation in continuations.values { + continuation.resume() + } + uploads.release(activeIDs) + } + + for _ in 0...40 { + let id = UUID() + activeIDs.append(id) + uploads.begin(id: id) { + startedCount += 1 + await withCheckedContinuation { (continuation: CheckedContinuation) in + continuations[id] = continuation + } + return AgentChatFileRef(path: "/never-\(id)", type: "file") + } + } + for _ in 0..<100 where startedCount < activeIDs.count { + await Task.yield() + } + XCTAssertEqual(startedCount, activeIDs.count) + + uploads.begin(id: activeIDs[0]) { + startedCount += 1 + return AgentChatFileRef(path: "/duplicate", type: "file") + } + XCTAssertEqual( + startedCount, + activeIDs.count, + "the oldest active upload must survive capacity pressure" + ) + } + /// The cache directory token has to be the SAME on the next launch. /// /// It used to be `String(format:)` over `draftKey.hashValue`, which Swift @@ -247,16 +405,66 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { func testAStuckStagedUploadIsAbandonedInsteadOfHangingTheSend() async { let id = UUID() let uploads = WorkComposerAttachmentUploads.shared - defer { uploads.release([id]) } + var release: CheckedContinuation? + defer { + release?.resume() + uploads.release([id]) + } uploads.begin(id: id) { // Never lands: the wedged leg this bug was made of. - try await Task.sleep(nanoseconds: 60_000_000_000) + await withCheckedContinuation { (continuation: CheckedContinuation) in + release = continuation + } return AgentChatFileRef(path: "/never", type: "image") } let resolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) XCTAssertEqual(resolution, .abandoned) + // The original task is still running, so a retry must not fall through to + // a second inline upload for the same attachment. + let retryResolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(retryResolution, .abandoned) + } + + @MainActor + func testTimedOutUploadBecomesInlineRetryableAfterTheOriginalFails() async { + let id = UUID() + let uploads = WorkComposerAttachmentUploads.shared + defer { uploads.release([id]) } + var release: CheckedContinuation? + + uploads.begin(id: id) { + await withCheckedContinuation { (continuation: CheckedContinuation) in + release = continuation + } + throw NSError(domain: "ADE", code: 1, userInfo: [NSLocalizedDescriptionKey: "no route"]) + } + for _ in 0..<20 where release == nil { + await Task.yield() + } + XCTAssertNotNil(release) + + let firstResolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(firstResolution, .abandoned) + let retryWhilePending = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual( + retryWhilePending, + .abandoned, + "an upload that is still in flight must not start a duplicate" + ) + + release?.resume() + for _ in 0..<20 where uploads.failure(for: id) != "no route" { + await Task.yield() + } + XCTAssertEqual(uploads.failure(for: id), "no route") + let retryAfterFailure = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual( + retryAfterFailure, + .stageInline, + "once the original upload fails, retry may stage the cached bytes inline" + ) } /// The ordinary path: a send that arrives while the upload is still moving From 571e76d47f237fa3e7ec58c0a3d6d3bb40244ef9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:20:16 -0400 Subject: [PATCH 19/26] fix(ios): each pending send owns its own draft, and cards refresh in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the mobile chat work. Back-to-back sends erased the newer draft. Queued steering lets a second message leave the composer while the first is still unconfirmed, and both sends shared one `pendingSendKey`, so whichever settled first cleared the other's persisted text and attachments. Each send now carries its own token, and only the newest token for a key may release that key's stored payload. Non-message cards stayed stale. `workChatTranscriptRowRevision` hashed payload fields only for `.message`, so a tool card going running → completed, a subagent card gaining a summary, or a pending-input card gaining a resolution kept its revision and the new UICollectionView transcript skipped the reconfigure. The row revision now hashes the whole card model for every non-message payload, which the height cache key already follows. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Models/RemoteModels.swift | 4 +- .../ADE/Views/Work/WorkChatSessionView.swift | 114 ++++++++++----- apps/ios/ADE/Views/Work/WorkModels.swift | 131 ++++++++++++++---- .../WorkAssistantRenderingTests.swift | 122 ++++++++++++++++ .../WorkComposerDraftAttachmentTests.swift | 89 +++++++++++- .../WorkComposerTriggerDetectorTests.swift | 4 +- 6 files changed, 391 insertions(+), 73 deletions(-) diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 65b83fcd57..7223318ad1 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2428,7 +2428,7 @@ struct AgentChatContextUsage: Codable, Equatable { } } -struct CodexWebSearchAction: Codable, Equatable { +struct CodexWebSearchAction: Codable, Hashable { var type: String var status: String? var query: String? @@ -4459,7 +4459,7 @@ struct SyncFileBlob: Codable, Equatable { var totalSize: Int? = nil } -struct ComputerUseArtifactSummary: Codable, Identifiable, Equatable { +struct ComputerUseArtifactSummary: Codable, Identifiable, Hashable { var id: String var artifactKind: String var backendStyle: String diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 6ed457dad0..65b21e3375 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -2251,24 +2251,31 @@ func workChatTranscriptRowRevision(_ entry: WorkTimelineRenderEntry) -> Int { hasher.combine(timelineEntry.id) hasher.combine(timelineEntry.timestamp) hasher.combine(timelineEntry.rank) - if case .message(let message) = timelineEntry.payload { - hasher.combine(message.id) - hasher.combine(message.role) - hasher.combine(message.steerId) - hasher.combine(message.deliveryState) - hasher.combine(message.processed) - hasher.combine(message.unprocessedResolution?.action) - hasher.combine(message.unprocessedResolution?.state) - hasher.combine(message.unprocessedResolution?.resolvedAt) - workTimelineCombineMessageTextSignature(message, into: &hasher) - if let preview = message.assistantPreview { - // A preview is a pure function of the message text, and the text is - // already in this hash. Its shape is enough to separate two previews - // of the same message — no need to hash the rendered text, which is - // O(message) on every refresh. - hasher.combine(preview.totalLineCount) - hasher.combine(preview.usesMonospacedRendering) - } + // Messages take the digest-based fast path below; every other card kind + // hashes its whole model, so an in-place update (a tool card gaining its + // result, a subagent card gaining a summary, a pending-input card gaining + // a resolution) reconfigures the cell and re-measures its height instead + // of leaving a stale card on screen. + guard case .message(let message) = timelineEntry.payload else { + hasher.combine(timelineEntry.payload) + return hasher.finalize() + } + hasher.combine(message.id) + hasher.combine(message.role) + hasher.combine(message.steerId) + hasher.combine(message.deliveryState) + hasher.combine(message.processed) + hasher.combine(message.unprocessedResolution?.action) + hasher.combine(message.unprocessedResolution?.state) + hasher.combine(message.unprocessedResolution?.resolvedAt) + workTimelineCombineMessageTextSignature(message, into: &hasher) + if let preview = message.assistantPreview { + // A preview is a pure function of the message text, and the text is + // already in this hash. Its shape is enough to separate two previews of + // the same message — no need to hash the rendered text, which is + // O(message) on every refresh. + hasher.combine(preview.totalLineCount) + hasher.combine(preview.usesMonospacedRendering) } case .assistantMarkdownBlock(let model): hasher.combine(model.id) @@ -2773,7 +2780,8 @@ private struct WorkChatComposerDraftInput: View { private func performSend(mode: WorkActiveSendMode) { guard sendEnabled else { return } let key = draftPersistenceKey - let originalText = draftState.beginPendingSend() + let pendingSend = draftState.beginPendingSend() + let originalText = pendingSend.text let outgoingAttachments = workChatInputReadyAttachments(inputAttachments) let text = workChatOutgoingText(originalText, attachmentCount: outgoingAttachments.count) let restoredAttachments = inputAttachments @@ -2785,7 +2793,7 @@ private struct WorkChatComposerDraftInput: View { let sent = await onSend(text, outgoingAttachments, mode) // Drops the stored draft only on a confirmed send; a failure keeps it, so // the composer copy below and the stored copy stay the same message. - draftState.finishPendingSend(sent: sent) + draftState.finishPendingSend(pendingSend, sent: sent) if sent { // The refs were consumed by the send; drop the upload tracking so a // later attachment can never reuse a sent message's ref. @@ -3593,6 +3601,22 @@ struct WorkChatComposerDraftRestore: Equatable, Identifiable { } } +/// One send's claim on a composer's persisted draft. +/// +/// Queued steering lets a second message leave the composer while the first is +/// still unconfirmed, so "there is a send in flight for this key" is not enough +/// to decide who may clear the stored text and attachments. The token names the +/// individual send; `finishPendingSend` only releases the store when the token +/// that settled is the newest one for its key. +/// +/// `token` is nil when the composer had no persistence key at send time — that +/// send stored nothing, so it has nothing to release. +struct WorkChatComposerPendingSend: Equatable { + let token: UUID? + let key: String + let text: String +} + final class WorkChatComposerDraftState: ObservableObject { @Published var text = "" { didSet { @@ -3606,10 +3630,20 @@ final class WorkChatComposerDraftState: ObservableObject { /// persist" (the key is unresolved), which is the safe default. private var persistenceKey = "" private var autosaveTask: Task? - /// Key of a send that has left the composer but has not been confirmed by the - /// host yet. While it is set, the stored draft belongs to that send — autosave - /// must not overwrite it with the emptied field. - private var pendingSendKey: String? + /// Sends that have left the composer but have not been confirmed by the host + /// yet, oldest first. While a key appears here the stored draft belongs to a + /// send — autosave must not overwrite it with the emptied field. + /// + /// This is a list rather than one key because queued steering lets the + /// composer accept a second message while the first is still unconfirmed. + /// Both sends would otherwise share one marker, and whichever settled first + /// would clear the other's persisted text and attachments. + private var pendingSends: [WorkChatComposerPendingSend] = [] + + /// True while any send still owns this composer's stored draft. + private var hasPendingSendForCurrentKey: Bool { + !persistenceKey.isEmpty && pendingSends.contains { $0.key == persistenceKey } + } var trimmedText: String { text.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3653,7 +3687,7 @@ final class WorkChatComposerDraftState: ObservableObject { func flushDraft() { autosaveTask?.cancel() autosaveTask = nil - guard !persistenceKey.isEmpty, pendingSendKey != persistenceKey else { return } + guard !persistenceKey.isEmpty, !hasPendingSendForCurrentKey else { return } WorkComposerDraftStore.save(text, for: persistenceKey) } @@ -3662,7 +3696,7 @@ final class WorkChatComposerDraftState: ObservableObject { private func scheduleAutosave() { // Only the key whose send is unconfirmed stands down; another chat's // composer must keep autosaving normally. - guard !persistenceKey.isEmpty, pendingSendKey != persistenceKey else { return } + guard !persistenceKey.isEmpty, !hasPendingSendForCurrentKey else { return } autosaveTask?.cancel() let key = persistenceKey let value = text @@ -3689,36 +3723,46 @@ final class WorkChatComposerDraftState: ObservableObject { /// host accepting and this returning restores an already-sent message as a /// draft, which is what `SyncRequestTimeout.chatSendMessage` already tells /// the user to check the transcript for. - func beginPendingSend() -> String { + func beginPendingSend() -> WorkChatComposerPendingSend { let value = trimmedText isFocused = false autosaveTask?.cancel() autosaveTask = nil + var issued = WorkChatComposerPendingSend(token: nil, key: "", text: value) if !persistenceKey.isEmpty { - pendingSendKey = persistenceKey + issued = WorkChatComposerPendingSend(token: UUID(), key: persistenceKey, text: value) + pendingSends.append(issued) WorkComposerDraftStore.save(value, for: persistenceKey) } text = "" - return value + return issued } /// Releases the stored draft back to the composer — dropping it when the host /// took the message, keeping it when it did not. - func finishPendingSend(sent: Bool) { - guard let key = pendingSendKey else { return } - pendingSendKey = nil - guard sent else { return } + func finishPendingSend(_ send: WorkChatComposerPendingSend, sent: Bool) { + guard let token = send.token, + let index = pendingSends.firstIndex(where: { $0.token == token }) + else { return } + let key = pendingSends[index].key + // Only the newest send for a key owns that key's stored payload. An older + // send settling first must leave the newer one's text and attachments + // alone — clearing them is exactly how a back-to-back send used to lose the + // second message when the first one confirmed. + let ownsStoredDraft = !pendingSends[(index + 1)...].contains { $0.key == key } + pendingSends.remove(at: index) + guard ownsStoredDraft, sent else { return } WorkComposerDraftStore.clear(key) // Anything typed while the send was in flight was held out of the store by // the guard above; write it now that the key is free again. - if key == persistenceKey, !trimmedText.isEmpty { + if key == persistenceKey, !hasPendingSendForCurrentKey, !trimmedText.isEmpty { WorkComposerDraftStore.save(text, for: persistenceKey) } } /// Whether an unconfirmed send owns this key's stored draft. func isSendInFlight(for key: String) -> Bool { - !key.isEmpty && pendingSendKey == key + !key.isEmpty && pendingSends.contains { $0.key == key } } func restoreUnsentText(_ value: String) { diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 94b69565ab..64318eac8c 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -70,7 +70,7 @@ enum WorkToolCardStatus: String, Equatable { case failed } -struct WorkToolCardModel: Identifiable, Equatable { +struct WorkToolCardModel: Identifiable, Hashable { let id: String let toolName: String let status: WorkToolCardStatus @@ -196,7 +196,7 @@ struct WorkPendingApprovalModel: Identifiable, Equatable { var title: String? = nil } -struct WorkPendingQuestionOption: Equatable { +struct WorkPendingQuestionOption: Hashable { let label: String let value: String let description: String? @@ -205,7 +205,7 @@ struct WorkPendingQuestionOption: Equatable { var previewFormat: String? = nil } -struct WorkPendingQuestion: Identifiable, Equatable { +struct WorkPendingQuestion: Identifiable, Hashable { let questionId: String let question: String let options: [WorkPendingQuestionOption] @@ -219,7 +219,7 @@ struct WorkPendingQuestion: Identifiable, Equatable { var id: String { questionId } } -struct WorkPendingQuestionModel: Identifiable, Equatable { +struct WorkPendingQuestionModel: Identifiable, Hashable { let id: String let questions: [WorkPendingQuestion] var title: String? = nil @@ -425,7 +425,7 @@ extension WorkPendingPlanApprovalModel { } } -struct WorkPendingPermissionModel: Identifiable, Equatable { +struct WorkPendingPermissionModel: Identifiable, Hashable { let id: String let tool: String let description: String @@ -436,7 +436,7 @@ struct WorkPendingPermissionModel: Identifiable, Equatable { /// with `request.kind == "plan_approval"` — desktop's `ChatProposedPlanCard` /// equivalent on iOS. Carries the full plan text so the card can render a /// scrollable formatted block, plus the source label (e.g. "claude", "codex"). -struct WorkPendingPlanApprovalModel: Identifiable, Equatable { +struct WorkPendingPlanApprovalModel: Identifiable, Hashable { let id: String let source: String let planText: String @@ -491,7 +491,7 @@ struct WorkModelSelectionChoice: Codable, Equatable { } } -struct WorkPendingModelSelectionModel: Identifiable, Equatable { +struct WorkPendingModelSelectionModel: Identifiable, Hashable { let id: String let role: String let tag: String @@ -509,7 +509,7 @@ struct WorkPendingModelSelectionModel: Identifiable, Equatable { } } -struct WorkUsageSummary: Equatable { +struct WorkUsageSummary: Hashable { var turnCount: Int var inputTokens: Int var outputTokens: Int @@ -820,7 +820,7 @@ struct WorkCompletionArtifactModel: Equatable { let reference: String? } -struct WorkCodexStallContext: Equatable { +struct WorkCodexStallContext: Hashable { let reason: String let detectedAt: String? let turnStartedAt: String? @@ -838,7 +838,7 @@ struct WorkUserMessageResolution: Equatable { let replacementMessageId: String? } -struct WorkCodexRecoveryReceipt: Equatable { +struct WorkCodexRecoveryReceipt: Hashable { let action: String let state: String let automatic: Bool @@ -848,7 +848,7 @@ struct WorkCodexRecoveryReceipt: Equatable { var providerNeutral: Bool = false } -struct WorkCommandCardModel: Identifiable, Equatable { +struct WorkCommandCardModel: Identifiable, Hashable { let id: String let command: String let cwd: String @@ -859,7 +859,7 @@ struct WorkCommandCardModel: Identifiable, Equatable { let durationMs: Int? } -struct WorkFileChangeCardModel: Identifiable, Equatable { +struct WorkFileChangeCardModel: Identifiable, Hashable { let id: String let path: String let diff: String @@ -895,20 +895,20 @@ enum WorkAdeCardIcon: String, Equatable { case file } -struct WorkAdeCardMetric: Equatable { +struct WorkAdeCardMetric: Hashable { let label: String let value: String let tone: WorkAdeCardTone } -struct WorkAdeCardRow: Equatable { +struct WorkAdeCardRow: Hashable { let icon: WorkAdeCardIcon? let text: String let detail: String? let tone: WorkAdeCardTone } -struct WorkAdeCardProgress: Equatable { +struct WorkAdeCardProgress: Hashable { let passed: Int let failed: Int let running: Int @@ -919,7 +919,7 @@ struct WorkAdeCardProgress: Equatable { } } -struct WorkAdeCardAction: Equatable { +struct WorkAdeCardAction: Hashable { let id: String let label: String let isPrimary: Bool @@ -929,7 +929,7 @@ struct WorkAdeCardAction: Equatable { /// Kinds with no URL form (`route`, `files-external`, or anything a newer host /// invents) parse to `nil`, which degrades the card to fallback text without a /// link rather than dropping the payload. -enum WorkAdeCardNavTarget: Equatable { +enum WorkAdeCardNavTarget: Hashable { case session(sessionId: String, laneId: String?) case file(path: String, line: Int?, laneId: String?) case commit(sha: String, laneId: String?) @@ -951,7 +951,7 @@ enum WorkAdeCardNavTarget: Equatable { /// same id merges into this card (see `buildWorkAdeCards`) instead of appending /// a second row, so a long-running card stays one chronological entry as it /// progresses. -struct WorkAdeCardModel: Identifiable, Equatable { +struct WorkAdeCardModel: Identifiable, Hashable { /// Variants this build knows how to render richly. Anything else degrades to /// `fallbackText` + deeplink — that is what makes one wire contract safe to /// ship across desktop auto-update, App Store review, and npm. @@ -1086,6 +1086,81 @@ enum WorkTimelinePayload: Equatable { case pendingModelSelection(WorkPendingModelSelectionModel) } +extension WorkTimelinePayload: Hashable { + /// Content hash for change detection: every field that can change what a card + /// draws, for every card kind. + /// + /// Cards update in place under a stable row id — a tool card goes running → + /// completed and gains a result, a subagent card gains a summary, a + /// pending-input card gains a resolution — so a revision built from ids and + /// timestamps alone leaves the visible cell showing the old content. Hashing + /// the model itself keeps this aligned with payload equality by construction. + /// + /// `.message` is the one case that hashes only its identity here: assistant + /// markdown is long and re-hashed several times a second during a streaming + /// turn, so `workChatTranscriptRowRevision` covers messages through the + /// digest-based fast path instead. Hashing fewer fields than `==` compares is + /// always safe (it can only collide, never falsely separate). + func hash(into hasher: inout Hasher) { + switch self { + case .message(let message): + hasher.combine(0) + hasher.combine(message.id) + case .toolCard(let model): + hasher.combine(1) + hasher.combine(model) + case .commandCard(let model): + hasher.combine(2) + hasher.combine(model) + case .fileChangeCard(let model): + hasher.combine(3) + hasher.combine(model) + case .subagent(let model): + hasher.combine(4) + hasher.combine(model) + case .subagentStoppedGroup(let model): + hasher.combine(5) + hasher.combine(model) + case .toolGroup(let model): + hasher.combine(6) + hasher.combine(model) + case .changedFiles(let model): + hasher.combine(7) + hasher.combine(model) + case .eventCard(let model): + hasher.combine(8) + hasher.combine(model) + case .adeCard(let model): + hasher.combine(9) + hasher.combine(model) + case .usageSummary(let model): + hasher.combine(10) + hasher.combine(model) + case .artifact(let model): + hasher.combine(11) + hasher.combine(model) + case .turnSeparator(let model): + hasher.combine(12) + hasher.combine(model) + case .turnEndMarker(let model): + hasher.combine(13) + hasher.combine(model) + case .pendingQuestion(let model): + hasher.combine(14) + hasher.combine(model) + case .pendingPermission(let model): + hasher.combine(15) + hasher.combine(model) + case .pendingPlanApproval(let model): + hasher.combine(16) + hasher.combine(model) + case .pendingModelSelection(let model): + hasher.combine(17) + hasher.combine(model) + } + } +} + struct WorkAssistantMarkdownBlockRenderModel: Identifiable, Equatable { let id: String let messageId: String @@ -1144,7 +1219,7 @@ struct WorkTimelineRenderEntry: Identifiable, Equatable { /// One member of a `WorkToolGroupModel`. Carries enough context for the /// collapsed mini-row (icon, title, status) and hands the full payload back /// when the group expands into per-entry cards. -enum WorkToolGroupMember: Equatable, Identifiable { +enum WorkToolGroupMember: Hashable, Identifiable { case tool(WorkToolCardModel) case command(WorkCommandCardModel) case fileChange(WorkFileChangeCardModel) @@ -1174,7 +1249,7 @@ enum WorkToolGroupMember: Equatable, Identifiable { } } -struct WorkToolGroupModel: Identifiable, Equatable { +struct WorkToolGroupModel: Identifiable, Hashable { let id: String let members: [WorkToolGroupMember] @@ -1185,7 +1260,7 @@ struct WorkToolGroupModel: Identifiable, Equatable { /// Stats are summed across every event that touched the same path during the /// cluster, so a file edited by both an `Edit` tool and a `file_change` event /// renders as a single row. -struct WorkChangedFileEntry: Identifiable, Equatable { +struct WorkChangedFileEntry: Identifiable, Hashable { let id: String let path: String let kind: String @@ -1195,21 +1270,21 @@ struct WorkChangedFileEntry: Identifiable, Equatable { let status: WorkToolCardStatus } -struct WorkChangedFilesGroupModel: Identifiable, Equatable { +struct WorkChangedFilesGroupModel: Identifiable, Hashable { let id: String let files: [WorkChangedFileEntry] var count: Int { files.count } } -struct WorkTurnSeparator: Equatable { +struct WorkTurnSeparator: Hashable { let time: String let provider: String let modelLabel: String let modelId: String? } -struct WorkTurnEndMarker: Equatable { +struct WorkTurnEndMarker: Hashable { let turnId: String let time: String let workedDurationLabel: String @@ -1264,7 +1339,7 @@ struct WorkTimelineEntry: Identifiable, Equatable { let payload: WorkTimelinePayload } -struct WorkSubagentSnapshot: Identifiable, Equatable { +struct WorkSubagentSnapshot: Identifiable, Hashable { enum Status: Equatable { case running, succeeded, failed, stopped } let taskId: String @@ -1303,7 +1378,7 @@ struct WorkSubagentSnapshot: Identifiable, Equatable { var id: String { taskId } } -struct WorkSubagentTimelineRow: Identifiable, Equatable { +struct WorkSubagentTimelineRow: Identifiable, Hashable { enum Kind: String, Equatable { case spawn case result @@ -1325,7 +1400,7 @@ struct WorkSubagentTimelineRow: Identifiable, Equatable { /// Folded run of 2+ same-cause, same-source subagent result rows (desktop /// parity: `SubagentStoppedGroupEvent`). Carries the original result rows so /// the card can list each agent's title, last activity, and outcome. -struct WorkSubagentStoppedGroupModel: Identifiable, Equatable { +struct WorkSubagentStoppedGroupModel: Identifiable, Hashable { /// Why the run stopped. The two causes read differently and must not be /// merged: an interrupt is something you did, a usage limit is something that /// happened to every agent at once. @@ -1513,7 +1588,7 @@ struct WorkPlanStep: Equatable, Hashable { let status: String } -struct WorkEventCardModel: Identifiable, Equatable { +struct WorkEventCardModel: Identifiable, Hashable { let id: String let kind: String let title: String diff --git a/apps/ios/ADETests/WorkAssistantRenderingTests.swift b/apps/ios/ADETests/WorkAssistantRenderingTests.swift index 69d6f5722e..5e40094f49 100644 --- a/apps/ios/ADETests/WorkAssistantRenderingTests.swift +++ b/apps/ios/ADETests/WorkAssistantRenderingTests.swift @@ -674,4 +674,126 @@ final class WorkChatActiveSendCapabilityTests: XCTestCase { } XCTAssertEqual(kind, "auth") } + + // MARK: - Transcript row revision + + private func toolCardRenderEntry( + status: WorkToolCardStatus, + resultText: String?, + completedAt: String? = nil + ) -> WorkTimelineRenderEntry { + let card = WorkToolCardModel( + id: "tool-item-1", + toolName: "Bash", + status: status, + startedAt: "2026-09-20T00:00:00.000Z", + completedAt: completedAt, + argsText: "npm test", + resultText: resultText + ) + let entry = WorkTimelineEntry( + id: "tool-item-1", + timestamp: "2026-09-20T00:00:00.000Z", + rank: 3, + payload: .toolCard(card) + ) + return WorkTimelineRenderEntry( + id: "tool-item-1", + sourceEntryId: "tool-item-1", + timestamp: "2026-09-20T00:00:00.000Z", + payload: .entry(entry) + ) + } + + /// A tool card updates in place under one row id: `tool_call` creates it + /// running, `tool_result` completes it and attaches the output. The row + /// revision is what tells the transcript to reconfigure that cell and + /// re-measure it, so a revision built from ids and timestamps alone left the + /// running card on screen forever. + func testToolCardStatusAndResultChangeTheRowRevision() { + let running = toolCardRenderEntry(status: .running, resultText: nil) + let completed = toolCardRenderEntry( + status: .completed, + resultText: "1990 tests passed", + completedAt: "2026-09-20T00:00:09.000Z" + ) + + XCTAssertEqual( + workChatTranscriptRowRevision(running), + workChatTranscriptRowRevision(toolCardRenderEntry(status: .running, resultText: nil)), + "an unchanged card must keep its revision, or every refresh re-measures every row" + ) + XCTAssertNotEqual( + workChatTranscriptRowRevision(running), + workChatTranscriptRowRevision(completed) + ) + XCTAssertNotEqual( + workChatTranscriptRowRevision(completed), + workChatTranscriptRowRevision( + toolCardRenderEntry( + status: .completed, + resultText: "1990 tests passed, 1 skipped", + completedAt: "2026-09-20T00:00:09.000Z" + ) + ), + "a result that grows must reconfigure the cell" + ) + } + + /// The same rule for the other two card kinds that mutate under a stable id: + /// a subagent row gaining its summary, and a pending-input card gaining its + /// resolution. + func testSubagentAndPendingCardUpdatesChangeTheRowRevision() { + func renderEntry(_ payload: WorkTimelinePayload, id: String) -> WorkTimelineRenderEntry { + WorkTimelineRenderEntry( + id: id, + sourceEntryId: id, + timestamp: "2026-09-20T00:00:00.000Z", + payload: .entry( + WorkTimelineEntry( + id: id, + timestamp: "2026-09-20T00:00:00.000Z", + rank: 1, + payload: payload + ) + ) + ) + } + + func permission(_ detail: String?) -> WorkTimelinePayload { + .pendingPermission( + WorkPendingPermissionModel( + id: "perm-1", + tool: "Bash", + description: "Run npm test", + detail: detail + ) + ) + } + + XCTAssertNotEqual( + workChatTranscriptRowRevision(renderEntry(permission(nil), id: "perm-1")), + workChatTranscriptRowRevision(renderEntry(permission("in apps/ios"), id: "perm-1")) + ) + + func commandCard(_ status: WorkToolCardStatus, output: String) -> WorkTimelinePayload { + .commandCard( + WorkCommandCardModel( + id: "cmd-1", + command: "npm test", + cwd: "/repo", + output: output, + status: status, + timestamp: "2026-09-20T00:00:00.000Z", + exitCode: nil, + durationMs: nil + ) + ) + } + + XCTAssertNotEqual( + workChatTranscriptRowRevision(renderEntry(commandCard(.running, output: ""), id: "cmd-1")), + workChatTranscriptRowRevision(renderEntry(commandCard(.completed, output: "ok"), id: "cmd-1")) + ) + } } diff --git a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift index 7019c6e12d..32bfce6f89 100644 --- a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift +++ b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift @@ -516,7 +516,8 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { draft.bind(persistenceKey: draftKey) draft.text = "look at this screenshot" - XCTAssertEqual(draft.beginPendingSend(), "look at this screenshot") + let firstSend = draft.beginPendingSend() + XCTAssertEqual(firstSend.text, "look at this screenshot") XCTAssertEqual(draft.text, "") // Still in flight: the stored copy is the only surviving one, so leaving the // chat now must find it. @@ -528,13 +529,13 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { draft.flushDraft() XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") - draft.finishPendingSend(sent: false) + draft.finishPendingSend(firstSend, sent: false) XCTAssertFalse(draft.isSendInFlight(for: draftKey)) XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") draft.text = "look at this screenshot" - _ = draft.beginPendingSend() - draft.finishPendingSend(sent: true) + let retry = draft.beginPendingSend() + draft.finishPendingSend(retry, sent: true) XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "", "a confirmed send consumes the draft") } @@ -549,7 +550,7 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { let draft = WorkChatComposerDraftState() draft.bind(persistenceKey: draftKey) draft.text = "first message" - _ = draft.beginPendingSend() + let firstSend = draft.beginPendingSend() draft.text = "second message" draft.flushDraft() @@ -559,7 +560,7 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { "the unconfirmed send owns the stored draft" ) - draft.finishPendingSend(sent: true) + draft.finishPendingSend(firstSend, sent: true) XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "second message") } @@ -596,6 +597,82 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { ) } + /// Queued steering lets a second message leave the composer while the first + /// is still unconfirmed. Both sends used to share one marker, so the first + /// completion cleared the second one's stored text and attachments and a + /// later failure had nothing to restore. + @MainActor + func testAnOlderSendConfirmingDoesNotClearANewerPendingSendsDraft() { + let draftKey = key() + defer { WorkComposerDraftStore.clear(draftKey) } + + let draft = WorkChatComposerDraftState() + draft.bind(persistenceKey: draftKey) + + draft.text = "first" + let sendA = draft.beginPendingSend() + XCTAssertEqual(sendA.text, "first") + + draft.text = "second" + let sendB = draft.beginPendingSend() + XCTAssertEqual(sendB.text, "second") + XCTAssertNotEqual(sendA.token, sendB.token, "each pending send owns its own token") + + let queued = WorkChatInputAttachment( + uploadData: Data("png-bytes".utf8), + filename: "second.png", + mimeType: "image/png", + kind: .image, + state: .ready + ) + workChatPersistComposerAttachments([queued], for: draftKey) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "second") + + // A completes. It no longer owns the stored draft, so it must leave B's + // text and attachments alone. + draft.finishPendingSend(sendA, sent: true) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "second") + XCTAssertEqual( + WorkComposerDraftStore.loadEntry(draftKey)?.localFiles.first?.filename, + "second.png" + ) + XCTAssertTrue(draft.isSendInFlight(for: draftKey), "B is still unconfirmed") + + // B fails: both halves are still there to restore. + draft.finishPendingSend(sendB, sent: false) + XCTAssertFalse(draft.isSendInFlight(for: draftKey)) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "second") + let entry = WorkComposerDraftStore.loadEntry(draftKey) + XCTAssertEqual(entry?.localFiles.first?.filename, "second.png") + XCTAssertEqual( + WorkComposerDraftAttachmentCache.read(entry?.localFiles ?? [], for: draftKey).first?.uploadData, + Data("png-bytes".utf8) + ) + } + + /// The mirror case: the newest send is the one that owns the store, so when + /// it confirms the draft is consumed even though an older send is still out. + @MainActor + func testTheNewestPendingSendOwnsTheStoredDraft() { + let draftKey = key() + defer { WorkComposerDraftStore.clear(draftKey) } + + let draft = WorkChatComposerDraftState() + draft.bind(persistenceKey: draftKey) + + draft.text = "first" + let sendA = draft.beginPendingSend() + draft.text = "second" + let sendB = draft.beginPendingSend() + + draft.finishPendingSend(sendB, sent: true) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "", "the owning send consumed the draft") + XCTAssertTrue(draft.isSendInFlight(for: draftKey), "A has still not settled") + + draft.finishPendingSend(sendA, sent: true) + XCTAssertFalse(draft.isSendInFlight(for: draftKey)) + } + /// The copy the retry row shows. Short, plain, and one sentence — it is also /// the message the abandoned-upload error carries, so the row and the error /// cannot disagree. diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index 79ed8ba380..0ee20a33ff 100644 --- a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift +++ b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift @@ -875,7 +875,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { draft.text = " Ship this change " draft.isFocused = true - XCTAssertEqual(draft.beginPendingSend(), "Ship this change") + XCTAssertEqual(draft.beginPendingSend().text, "Ship this change") XCTAssertEqual(draft.text, "") XCTAssertFalse(draft.isFocused) @@ -896,7 +896,7 @@ final class WorkComposerTriggerDetectorTests: XCTestCase { XCTAssertEqual(draft.text, "Check whether this already started") XCTAssertTrue(draft.isFocused) - XCTAssertEqual(draft.beginPendingSend(), "Check whether this already started") + XCTAssertEqual(draft.beginPendingSend().text, "Check whether this already started") draft.applyRestore(restore) XCTAssertEqual(draft.text, "", "The same restoration token must not refill a draft after the user sends it.") From 5ceeb698c0f3e501ad6cf601d5d4570320253092 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:31:51 -0400 Subject: [PATCH 20/26] fix(chat): a dropped queued steer no longer splits the assistant message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seven review findings across the mobile chat wire and the iOS transcript. Desktop: the graduated-steer skip in `transcriptEntriesFromEnvelopes` ran after the assistant draft was flushed, so a queued row that is never emitted still ended the message it landed inside — a mid-turn steer read as two assistant entries with nothing between them. The skip now runs first. `compactToolResultForMobile` also stopped advertising a fetch for an unserializable result: `JSON.stringify` throwing made the size unmeasurable, and the row claimed "Show full result" with a 0-byte original for a short fallback the fetch would return verbatim. iOS: only an explicit non-queued `command_lifecycle` status graduates a steer now, in both `pruneResolvedQueuedSteerEnvelopes` and `derivePendingWorkSteers` — a statusless frame is an off-contract host, not evidence the message left the queue. The transcript deduplicates row ids before the diffable snapshot, since a split assistant message mints ids by string concatenation and both the snapshot and the row map trap on a repeat. `jumpToLatest` schedules its animation fallback for every animated jump that is not being dragged, so a transcript resting at a negative offset cannot leave `animatingToLatest` set and suppress follow pinning. And the in-thread subagent rows are no longer buttons: the drill-in they used to open is gone, and the callback only toggled hidden Chat Info state. Co-Authored-By: Claude Opus 5 --- .../chat/chatTranscriptEntries.test.ts | 18 ++++++ .../services/chat/chatTranscriptEntries.ts | 11 ++-- .../desktop/src/shared/chatMobileSlim.test.ts | 13 +++++ apps/desktop/src/shared/chatMobileSlim.ts | 10 +++- .../Views/Work/WorkChatRichCardViews.swift | 58 +++++-------------- .../Work/WorkChatSessionView+Timeline.swift | 4 +- .../ADE/Views/Work/WorkChatSessionView.swift | 3 - .../WorkChatTranscriptCollectionView.swift | 15 ++++- .../Work/WorkErrorAndMessageHelpers.swift | 12 +++- .../Work/WorkSessionDestinationView.swift | 12 ---- apps/ios/ADETests/ADETests.swift | 52 +++++++++++++++++ .../ADEUITests/WorkComposerFoldUITests.swift | 7 ++- 12 files changed, 142 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts index 7c79d8dc9b..8de6291525 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts @@ -175,6 +175,24 @@ describe("transcriptEntriesFromEnvelopes", () => { ]); }); + it("does not split an assistant run around a queued steer it drops", () => { + // Regression: the skip ran AFTER the assistant draft was flushed, so a + // queued row that is never emitted still ended the message it landed + // inside — one mid-turn steer turned one assistant message into two + // entries with no user entry between them. + const entries = transcriptEntriesFromEnvelopes(SESSION, [ + envelope({ type: "text", text: "Checking the pair", messageId: "msg-a", turnId: "turn-1" }), + envelope({ type: "user_message", text: "also run the linter", turnId: "turn-1", steerId: "steer-1", deliveryState: "queued" }), + envelope({ type: "text", text: "ing now.", messageId: "msg-a", turnId: "turn-1" }), + envelope({ type: "user_message", text: "also run the linter", turnId: "turn-1", steerId: "steer-1", deliveryState: "inline" }), + ]); + + expect(entries.map((entry) => [entry.role, entry.text])).toEqual([ + ["assistant", "Checking the pairing now."], + ["user", "also run the linter"], + ]); + }); + it("keeps a still-pending queued steer", () => { const entries = transcriptEntriesFromEnvelopes(SESSION, [ envelope({ type: "user_message", text: "queue me", turnId: "turn-1", steerId: "steer-2", deliveryState: "queued" }), diff --git a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts index 4a7955760f..beefb0d6f0 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.ts @@ -197,13 +197,16 @@ export function transcriptEntriesFromEnvelopes( for (const entry of envelopes) { if (entry.sessionId !== sessionId) continue; if (entry.event.type === "user_message") { - flushAssistantDraft(); - openStreamKey = null; - assistantDraftsByKey.clear(); const steerId = entry.event.steerId?.trim(); // A queued steer whose delivered twin is in the same batch is the same - // message; the delivered row carries it. + // message; the delivered row carries it. Skipped BEFORE the stream state + // is reset: a row that is never emitted must not break the assistant run + // it landed inside, or a mid-turn steer splits one message into two + // entries with no user entry between them. if (entry.event.deliveryState === "queued" && steerId && graduated.has(steerId)) continue; + flushAssistantDraft(); + openStreamKey = null; + assistantDraftsByKey.clear(); const text = entry.event.text.trim(); if (!text.length) continue; const displayText = typeof entry.event.displayText === "string" && entry.event.displayText.trim().length > 0 diff --git a/apps/desktop/src/shared/chatMobileSlim.test.ts b/apps/desktop/src/shared/chatMobileSlim.test.ts index cce3afea10..d8aa1edc26 100644 --- a/apps/desktop/src/shared/chatMobileSlim.test.ts +++ b/apps/desktop/src/shared/chatMobileSlim.test.ts @@ -286,6 +286,19 @@ describe("compactToolResultForMobile", () => { expect(compacted.resultTruncatedForMobile).toBe(true); }); + it("does not offer a fetch for an unserializable result that fits whole", () => { + // Regression: `JSON.stringify` throws on a circular payload, so the size is + // unmeasurable and the early return was skipped. The row then advertised + // "Show full result" with a 0-byte original size for a 15-byte fallback the + // fetch would return verbatim. + const circular: Record = { tool: "Bash" }; + circular.self = circular; + const event = toolResult(circular); + const compacted = compactToolResultForMobile(event); + expect(compacted).toBe(event); + expect((compacted as Extract).resultTruncatedForMobile).toBeUndefined(); + }); + it("never slices mid-codepoint", () => { const compacted = compactToolResultForMobile(toolResult("é".repeat(4_000))) as Extract; expect((compacted.result as string).includes("�")).toBe(false); diff --git a/apps/desktop/src/shared/chatMobileSlim.ts b/apps/desktop/src/shared/chatMobileSlim.ts index 09a8c3ccfa..aa40a9d769 100644 --- a/apps/desktop/src/shared/chatMobileSlim.ts +++ b/apps/desktop/src/shared/chatMobileSlim.ts @@ -128,11 +128,19 @@ export function compactToolResultForMobile( return event; } const preview = sliceUtf8FromStart(serialized.text, MOBILE_TOOL_RESULT_MAX_BYTES); + const previewBytes = utf8Bytes(preview); + if (!serialized.measurable && previewBytes === utf8Bytes(serialized.text)) { + // An unserializable payload degrades to a short `String(value)` fallback, + // and the stored result the fetch would return is the same fallback. The + // row already has everything there is, so it must not offer to fetch more + // and must not report a 0-byte original size. + return event; + } return { ...event, result: preview, resultOriginalBytes: originalBytes, - resultOmittedBytes: Math.max(0, originalBytes - utf8Bytes(preview)), + resultOmittedBytes: Math.max(0, originalBytes - previewBytes), // The row's "Show full result" affordance. Absent on every other wire, so // an older phone sees exactly the event it sees today. resultTruncatedForMobile: true, diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 36c14e50d7..bdb5f4ea0e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -3946,11 +3946,13 @@ private let workSubagentIsoFallbackFormatter: ISO8601DateFormatter = { /// spawn/result/background-chip rows produced by `deriveSubagentTimelineRows` /// (chatSubagents.ts). These rows are hard timeline boundaries anchored where /// the subagent started and ended; the full roster lives in Chat Info. +/// +/// The rows themselves are not tappable. Opening an in-thread subagent's +/// transcript was removed on the phone (see `handleSubagentSelection`), so the +/// card IS the surface — a button that only toggled hidden Chat Info state read +/// as a dead affordance. Stopping a running spawn is the one action left. struct WorkSubagentTimelineRowView: View { let row: WorkSubagentTimelineRow - /// Tapping a real spawn/result row opens the subagent detail/transcript, the - /// same surface the Chat Info roster row opens. Background chips are inert. - let onOpen: (@MainActor (WorkSubagentSnapshot) async -> Void)? var onStop: (@MainActor (WorkSubagentSnapshot) async -> Void)? = nil var body: some View { @@ -3961,9 +3963,9 @@ struct WorkSubagentTimelineRowView: View { // The stop button is a layout sibling inside the row's own HStack, not a // trailing overlay: an overlay takes no space and landed on top of the // status capsule the row already put at the trailing edge. - tappable { WorkSubagentSpawnRow(row: row, stopAction: spawnStopAction) } + WorkSubagentSpawnRow(row: row, stopAction: spawnStopAction) case .result: - tappable { WorkSubagentResultRow(row: row) } + WorkSubagentResultRow(row: row) } } @@ -3971,20 +3973,6 @@ struct WorkSubagentTimelineRowView: View { guard let onStop, workSubagentCanStopTask(row.snapshot) else { return nil } return { Task { await onStop(row.snapshot) } } } - - @ViewBuilder - private func tappable(@ViewBuilder _ content: () -> Content) -> some View { - if let onOpen { - Button { - Task { await onOpen(row.snapshot) } - } label: { - content() - } - .buttonStyle(.plain) - } else { - content() - } - } } /// Folded card for a run of 2+ same-cause, same-source stopped subagents — @@ -3995,9 +3983,6 @@ struct WorkSubagentStoppedGroupCardView: View { let model: WorkSubagentStoppedGroupModel let isExpanded: Bool let onToggle: () -> Void - /// Same opener the result rows use; nil in previews/offline renders leaves the - /// list inert (and hides the per-row open affordance). - let onOpen: (@MainActor (WorkSubagentSnapshot) async -> Void)? private var headline: String { model.headline } @@ -4061,22 +4046,11 @@ struct WorkSubagentStoppedGroupCardView: View { .contentShape(Rectangle()) } + /// Flat rows, not buttons: the phone has no in-thread subagent drill-in to + /// open, and the list already carries each agent's title, last activity and + /// outcome. @ViewBuilder private func stoppedItem(_ row: WorkSubagentTimelineRow) -> some View { - if let onOpen { - Button { - Task { await onOpen(row.snapshot) } - } label: { - stoppedItemLabel(row) - } - .buttonStyle(.plain) - } else { - stoppedItemLabel(row) - } - } - - @ViewBuilder - private func stoppedItemLabel(_ row: WorkSubagentTimelineRow) -> some View { let title = workSubagentMeaningfulName(row.snapshot) let lastActivity = row.snapshot.lastActivity?.trimmingCharacters(in: .whitespacesAndNewlines) let outcome = workSubagentStoppedOutcomeLabel(row.snapshot) @@ -4096,15 +4070,9 @@ struct WorkSubagentStoppedGroupCardView: View { } } Spacer(minLength: 6) - VStack(alignment: .trailing, spacing: 2) { - Text(outcome) - .font(.caption2.weight(.semibold)) - .foregroundStyle(row.snapshot.resultLanded ? ADEColor.success : ADEColor.warning) - Image(systemName: "arrow.up.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - .opacity(onOpen == nil ? 0 : 1) - } + Text(outcome) + .font(.caption2.weight(.semibold)) + .foregroundStyle(row.snapshot.resultLanded ? ADEColor.success : ADEColor.warning) } .padding(.vertical, 5) .contentShape(Rectangle()) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index e06dd98c2b..8e4956edf1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -144,7 +144,6 @@ extension WorkChatSessionView { case .subagent(let row): WorkSubagentTimelineRowView( row: row, - onOpen: onSelectSubagentRow, onStop: onStopSubagentTask.map { stop in { snapshot in await stop(snapshot.taskId) } } @@ -153,8 +152,7 @@ extension WorkChatSessionView { WorkSubagentStoppedGroupCardView( model: model, isExpanded: cardIsExpanded(model.id, entryId: entry.id), - onToggle: { toggleCard(model.id, entryId: entry.id) }, - onOpen: onSelectSubagentRow + onToggle: { toggleCard(model.id, entryId: entry.id) } ) case .toolGroup(let group): timelineToolGroup(group, entryId: entry.id) diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 65b21e3375..f76dbe6b86 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -410,9 +410,6 @@ struct WorkChatSessionView: View { var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] var scheduledWorkSnapshotsRenderSignature: Int = 0 var onOpenChatInfo: (() -> Void)? = nil - /// Tapping a subagent spawn/result timeline row opens the same detail surface - /// the Chat Info roster row opens (full transcript takeover or expanded row). - var onSelectSubagentRow: (@MainActor (WorkSubagentSnapshot) async -> Void)? = nil /// Fork the current Claude thread in this lane (session-quota card). var onForkChatInLane: (@MainActor () async -> Void)? = nil var prBadge: WorkChatPrBadgeModel? = nil diff --git a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift index 2da0bbdebb..dbaec05617 100644 --- a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift @@ -331,8 +331,16 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele // MARK: Snapshot - func apply(rows: [WorkChatTranscriptRow], contentRevision revision: Int) { + func apply(rows incoming: [WorkChatTranscriptRow], contentRevision revision: Int) { guard isViewLoaded else { return } + // Both `Dictionary(uniqueKeysWithValues:)` below and the diffable snapshot + // trap on a repeated id, and one split assistant message mints row ids by + // string concatenation (`-`), so uniqueness is a property + // of the producer rather than a guarantee. A duplicate is a rendering bug + // worth fixing at the source; it is never worth crashing the transcript. + var seenRowIds = Set() + seenRowIds.reserveCapacity(incoming.count) + let rows = incoming.filter { seenRowIds.insert($0.id).inserted } let nextIds = rows.map(\.id) let revisionChanged = revision != contentRevision let changedRowIds: [String] = rows.compactMap { row in @@ -536,10 +544,13 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele animatingToLatest = true } performScrollWrite(.pinToBottom(animated: animated), reason: reason) - if animated, !collectionView.isDragging, collectionView.contentOffset.y >= 0 { + if animated, !collectionView.isDragging { // `setContentOffset(animated:)` does not always raise // `scrollViewDidEndScrollingAnimation` when the target was already // reached; clear the suppression on the next runloop turn in that case. + // Not gated on a non-negative offset: a transcript that fits rests at + // `-contentInset.top`, and skipping the fallback there leaves + // `animatingToLatest` set, which suppresses follow pinning indefinitely. let work = DispatchWorkItem { [weak self] in self?.animatingToLatest = false } settleWorkItem?.cancel() settleWorkItem = work diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index bbf531ed62..05e911acfe 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1370,8 +1370,12 @@ func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [Wor var resolvedSteerIds = Set() var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { + // `status` is required on a `command_lifecycle` frame. An absent one is an + // off-contract host, and treating "unknown" as "no longer queued" would + // drop a steer row the user can still see is waiting. if let steerId = envelope.commandLifecycleSteerId, - envelope.commandLifecycleStatus != "queued" { + let status = envelope.commandLifecycleStatus, + status != "queued" { resolvedSteerIds.insert(steerId) } switch envelope.event { @@ -2194,8 +2198,12 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin var resolved = Set() var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { + // Same rule as `pruneResolvedQueuedSteerEnvelopes`: only an explicit + // non-queued status graduates the steer. A missing status is an + // off-contract host, not evidence the message left the queue. if let steerId = envelope.commandLifecycleSteerId, - envelope.commandLifecycleStatus != "queued" { + let status = envelope.commandLifecycleStatus, + status != "queued" { queue.removeValue(forKey: steerId) resolved.insert(steerId) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index eb67422fb3..c4136ac88c 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -2025,7 +2025,6 @@ struct WorkSessionDestinationView: View { // to the parent chat, so nested transcript state cannot accidentally // fetch from itself. onOpenChatInfo: { Task { await prepareChatInfoPresentation() } }, - onSelectSubagentRow: subagentRowSelectionHandler(), onForkChatInLane: { let modelId = (composerChatSummary?.modelId ?? composerChatSummary?.model ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2079,17 +2078,6 @@ struct WorkSessionDestinationView: View { ) } - /// Explicitly-typed handler so the optional async closure doesn't make the - /// large `WorkChatSessionView(...)` initializer ambiguous at the call site. - /// Explicitly-typed handler so the optional async closure doesn't make the - /// large `WorkChatSessionView(...)` initializer ambiguous at the call site. - private func subagentRowSelectionHandler() -> (@MainActor (WorkSubagentSnapshot) async -> Void)? { - let handler: @MainActor (WorkSubagentSnapshot) async -> Void = { snapshot in - await handleSubagentSelection(snapshot) - } - return handler - } - /// Extracted from the `.onChange` modifier: the destination's modifier chain /// is long enough that an inline closure body pushes the type checker past /// its budget for the whole expression. diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e99e6278db..84874ed566 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -20694,6 +20694,58 @@ final class ADETests: XCTestCase { XCTAssertTrue(derivePendingWorkSteers(from: pruned).isEmpty) } + /// Regression: a `command_lifecycle` frame whose `status` is missing is an + /// off-contract host (the wire type requires it). Comparing `!= "queued"` + /// against a nil status treated "unknown" as "no longer queued" and dropped a + /// steer row the user can still see waiting. + func testPruneResolvedQueuedSteerEnvelopesKeepsQueuedRowWithoutALifecycleStatus() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil), + commandLifecycleStatus: nil, + commandLifecycleSteerId: nil + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 2, + event: .unknown(type: "command_lifecycle"), + commandLifecycleStatus: nil, + commandLifecycleSteerId: "steer-1" + ), + ] + + let pruned = pruneResolvedQueuedSteerEnvelopes(transcript) + XCTAssertEqual(pruned.count, 2, "a statusless lifecycle frame must not resolve the steer") + XCTAssertEqual(derivePendingWorkSteers(from: pruned).count, 1) + } + + /// The contract case still resolves: an explicit non-queued status graduates + /// the row. + func testPruneResolvedQueuedSteerEnvelopesDropsQueuedRowOnStartedLifecycle() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .userMessage(text: "ship it", attachments: nil, turnId: "turn-1", steerId: "steer-1", deliveryState: "queued", processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:02.000Z", + sequence: 2, + event: .unknown(type: "command_lifecycle"), + commandLifecycleStatus: "started", + commandLifecycleSteerId: "steer-1" + ), + ] + + XCTAssertTrue(derivePendingWorkSteers(from: pruneResolvedQueuedSteerEnvelopes(transcript)).isEmpty) + } + func testPreferredWorkTranscriptPreservesQueuedSteerAfterPlainFallbackBackfill() { let fallback = [ WorkChatEnvelope( diff --git a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift index ec1cc94edf..d21988ceed 100644 --- a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift +++ b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift @@ -129,6 +129,11 @@ final class WorkComposerFoldUITests: XCTestCase { ) // The draft survives the fold, and is still the same message. - XCTAssertTrue(field.value as? String != "" , "the fold cleared the draft") + // Not `!= ""`: `field.value` is `Any?`, so a cleared field reads as nil and + // `nil != ""` passes in exactly the failure this asserts against. + XCTAssertTrue( + (field.value as? String)?.hasPrefix("line 1 of a long draft") == true, + "the fold cleared the draft" + ) } } From 19f9f82d0b46c68849615e9ebb303f0851dd3ca5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:50:22 -0400 Subject: [PATCH 21/26] fix(sync): the full tool-result fetch names the generation it wants A retry reuses a tool call's logical item id, so `findStoredToolResult` answered every row with the newest result for that id: expanding an older retry row showed the newer attempt's output, cached under the older row's sequence. The phone already kept the transcript sequence in its cache identity; `chat_tool_result` now carries it too, and the lookup requires an exact match when the client names one. A client that omits it keeps the newest-match behaviour it already has. Also corrects the iOS companion doc: with the in-thread subagent drill-in gone, the transcript's spawn/result rows are flat rows rather than taps that expand a card, and only the Chat Info roster still expands. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.ts | 7 ++++ .../chat/chatToolResultLookup.test.ts | 41 +++++++++++++++++++ .../services/chat/chatToolResultLookup.ts | 15 +++++++ apps/desktop/src/shared/types/sync.ts | 10 +++++ apps/ios/ADE/Services/SyncService.swift | 13 +++++- .../sync-and-multi-device/ios-companion.md | 19 +++++---- 6 files changed, 96 insertions(+), 9 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index a3c560648d..3c4c6d5fb9 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -8575,6 +8575,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const payload = envelope.payload as SyncChatToolResultRequestPayload | null; const sessionId = toOptionalString(payload?.sessionId); const itemId = toOptionalString(payload?.itemId); + // The generation the row was built from. A retry reuses the logical + // item id, so without this the newest result answers every row. + const requestedResultSequence = typeof payload?.resultSequence === "number" + && Number.isFinite(payload.resultSequence) + ? payload.resultSequence + : null; const unavailable = (): SyncChatToolResultResponsePayload => ({ sessionId: sessionId ?? "", itemId: itemId ?? "", @@ -8614,6 +8620,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { transcriptPath, sessionId, itemId, + ...(requestedResultSequence !== null ? { resultSequence: requestedResultSequence } : {}), ...(signal ? { signal } : {}), }), signal, diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts index b88a6609ff..87839ee4f4 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts @@ -50,6 +50,47 @@ describe("chatToolResultRowId", () => { }); describe("findStoredToolResult", () => { + it("answers the exact generation when the row names its sequence", async () => { + // Regression: a retry reuses the logical item id, so the backward scan + // returned the NEWEST result for every row and an older retry row showed + // the newer attempt's output as its own. + write([ + toolResult("item-1", "old"), + { type: "text", text: "retrying" }, + toolResult("item-1", "new"), + ]); + + const older = await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 1, + }); + expect(older?.event.result).toBe("old"); + expect(older?.envelope.sequence).toBe(1); + + const newer = await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 3, + }); + expect(newer?.event.result).toBe("new"); + + // A generation that is not there is "not found", never the nearest other + // attempt's output. + expect(await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 99, + })).toBeNull(); + + // A client that omits the sequence keeps the newest-match behaviour. + const legacy = await findStoredToolResult({ transcriptPath, sessionId: SESSION_ID, itemId: "item-1" }); + expect(legacy?.event.result).toBe("new"); + }); + it("returns the stored result for the requested row", async () => { write([ toolResult("item-1", "first"), diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts index ce94d99df7..77d4b06d61 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts @@ -40,10 +40,21 @@ export async function findStoredToolResult(options: { transcriptPath: string; sessionId: string; itemId: string; + /** + * Exact transcript sequence of the row being expanded. Present, it is part + * of the identity: a retry reuses the item id, so matching the id alone + * answers an older row with a newer retry's result. Absent (an older + * client), the newest match wins, which is the behaviour those clients + * already have. + */ + resultSequence?: number; signal?: AbortSignal; }): Promise { const wantedId = options.itemId.trim(); if (!wantedId) return null; + const wantedSequence = typeof options.resultSequence === "number" && Number.isFinite(options.resultSequence) + ? options.resultSequence + : null; let beforeOffset = await readHistoryFileSize(options.transcriptPath); let scannedBytes = 0; for (let page = 0; page < CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES; page += 1) { @@ -65,6 +76,10 @@ export async function findStoredToolResult(options: { const event = envelope.event; if (!event || event.type !== "tool_result") continue; if (chatToolResultRowId(event) !== wantedId) continue; + // A named generation must match exactly. Answering with the nearest + // other generation would be worse than "not found": the row would show + // another attempt's output as its own. + if (wantedSequence !== null && envelope.sequence !== wantedSequence) continue; return { event, envelope }; } scannedBytes += windowBytes; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index e6a91677cd..b53bd82528 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1680,6 +1680,16 @@ export type SyncChatHistoryResponsePayload = AgentChatEventHistoryPage; export type SyncChatToolResultRequestPayload = SyncChatUnsubscribePayload & { /** `logicalItemId ?? itemId` of the tool_result row the user expanded. */ itemId: string; + /** + * Transcript sequence of the `tool_result` envelope the expanded row was + * built from. A retry reuses the logical item id, so the id alone names a + * row and not a generation: without this the host answers every row with the + * newest result for that id, and an older retry row shows the newer output. + * + * Optional for clients that predate it; those keep the newest-match + * behaviour they already have. + */ + resultSequence?: number; }; export type SyncChatToolResultResponsePayload = { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ffabbb2ee3..ce4392892c 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13837,7 +13837,11 @@ final class SyncService: ObservableObject { eventSequence: eventSequence ) { [weak self] in guard let self else { throw CancellationError() } - let response = try await self.fetchChatToolResult(sessionId: sessionId, itemId: itemId) + let response = try await self.fetchChatToolResult( + sessionId: sessionId, + itemId: itemId, + resultSequence: eventSequence + ) if response.unavailable == true { throw NSError( domain: "ADE", @@ -13866,7 +13870,8 @@ final class SyncService: ObservableObject { /// serves `chat_tool_result` by construction. func fetchChatToolResult( sessionId: String, - itemId: String + itemId: String, + resultSequence: Int? = nil ) async throws -> AgentChatToolResultResponse { let requestId = makeRequestId() var payload = chatSubscriptionPayload( @@ -13875,6 +13880,10 @@ final class SyncService: ObservableObject { includeSinceSeq: false ) payload["itemId"] = itemId + // The row's transcript sequence, so a retried item id cannot be answered + // with a later attempt's output. Omitted when the row has none, which is + // the pre-slim-wire shape the host still accepts. + if let resultSequence { payload["resultSequence"] = resultSequence } let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: false, diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 0d3b5c47d7..c4c37a98c4 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -240,13 +240,18 @@ a second — it bounds a burst rather than thinning a steady stream. ## In-thread subagents have a card, not a transcript -Tapping a subagent that runs INSIDE a main thread — a Claude or Codex native -Task — expands its card and nothing more. The card carries the label, model, -status, latest summary and final result, which is everything a phone can act -on. Opening the transcript replaced the thread the user was reading with a -read-only copy they had to back out of, and kept a one-and-a-half-second -transcript poll running for as long as it was open, for content that is the -parent's own work seen one level down. +A subagent that runs INSIDE a main thread — a Claude or Codex native Task — +has no transcript to open. Its card carries the label, model, status, latest +summary and final result, which is everything a phone can act on. Opening the +transcript replaced the thread the user was reading with a read-only copy they +had to back out of, and kept a one-and-a-half-second transcript poll running +for as long as it was open, for content that is the parent's own work seen one +level down. + +The in-transcript spawn/result rows are therefore flat rows, not buttons. +Stopping a running spawn is the one action left on them. Tapping a roster row +in the Chat Info sheet still expands that agent's details in place, which is +the only surface that expansion was ever visible on. This is only about in-thread subagents. A `--type subagent` chat and a child lane are full chats with their own rows, their own composer and their own lane, From 93fb460e5bc75964ab7dfeaf951beadabe621748 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 21:59:13 -0400 Subject: [PATCH 22/26] fix(ios): a truncated result keeps its fetch, and the height cache stays bounded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four more review findings on the iOS transcript. A `resultTruncatedForMobile` flag with no `resultOriginalBytes` set `toolResultFullBytes` to nil, which reads as "this row already has everything" and hid the Show-all affordance for a head slice. Truncation is now recorded even when the size is not, and the button drops the size from its label rather than disappearing. The transcript height cache keyed on `row.revision &+ contentRevision`, so one revision moving could cancel the other and restore a height measured for different content; the two are separate key fields now. Pruning also filtered on row id alone, which kept every revision a surviving row was ever measured at — a streaming session grew the cache for its whole life. It now keeps only entries a live row can still ask for. And both `command_lifecycle` steer-id reads coerce each key on its own: an explicit `"steerId": null` arrives as NSNull, won the `??`, and hid a snake_case `steer_id` beside it, losing the id whose queued row then never graduated. Co-Authored-By: Claude Opus 5 --- apps/ios/ADE/Models/RemoteModels.swift | 5 +- .../WorkChatTranscriptCollectionView.swift | 26 ++++++++-- .../Work/WorkRemoteToolResultAffordance.swift | 6 ++- .../ADE/Views/Work/WorkTranscriptParser.swift | 8 ++- .../WorkAssistantRenderingTests.swift | 49 ++++++++++++++++++- 5 files changed, 85 insertions(+), 9 deletions(-) diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 7223318ad1..f451757472 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2741,8 +2741,11 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { // Only a host that honoured `mobileChatSlimV1` sets this. A host that did // not sends the whole result and no flag, and the row behaves exactly as // it does today. + // Non-nil means "this row is a head slice" — the size may be unknown (0) + // if a host sent the flag without a count, and losing the fetch + // affordance over a missing number would strand the rest of the result. toolResultFullBytes = rawEvent?.resultTruncatedForMobile == true - ? (rawEvent?.resultOriginalBytes).map { max(0, $0) } + ? max(0, rawEvent?.resultOriginalBytes ?? 0) : nil } } diff --git a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift index dbaec05617..9b7ed19d06 100644 --- a/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift @@ -49,7 +49,11 @@ struct WorkChatTranscriptGeometry: Equatable { struct WorkChatTranscriptHeightKey: Hashable { let rowId: String let width: CGFloat + /// The row's own content revision and the transcript-wide one, kept apart + /// rather than summed: they move independently, and a sum lets one change + /// cancel the other out and restore a height measured for different content. let revision: Int + let contentRevision: Int } /// Measured row heights, keyed by row id + width + content revision. @@ -73,9 +77,19 @@ final class WorkChatTranscriptHeightCache { heights.removeAll(keepingCapacity: true) } - func prune(keeping rowIds: Set) { + /// Drop every entry no live row can ask for again. + /// + /// Filtering on the row id alone was not enough: `revision` and + /// `contentRevision` are part of the key and both move on live state, card + /// expansion, and width changes, so a row that simply stays in the + /// transcript kept one entry per revision it was ever measured at — the + /// cache grew for the whole session. An entry survives only while its row is + /// still present AND still at that revision pair. + func prune(keeping live: [String: Int], contentRevision: Int) { guard heights.count > 4096 else { return } - heights = heights.filter { rowIds.contains($0.key.rowId) } + heights = heights.filter { key, _ in + key.contentRevision == contentRevision && live[key.rowId] == key.revision + } } } @@ -291,7 +305,8 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele cell.cacheKey = WorkChatTranscriptHeightKey( rowId: rowId, width: width, - revision: row.revision &+ self.contentRevision + revision: row.revision, + contentRevision: self.contentRevision ) cell.backgroundConfiguration = .clear() let content = self.rowContent?(row) ?? AnyView(EmptyView()) @@ -359,7 +374,10 @@ final class WorkChatTranscriptController: UIViewController, UICollectionViewDele contentRevision = revision rowsById = Dictionary(uniqueKeysWithValues: rows.map { ($0.id, $0) }) orderedRowIds = nextIds - heightCache.prune(keeping: Set(nextIds)) + heightCache.prune( + keeping: Dictionary(rows.map { ($0.id, $0.revision) }, uniquingKeysWith: { first, _ in first }), + contentRevision: revision + ) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.main]) diff --git a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift index ac5e013aa8..5f408ad241 100644 --- a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -126,7 +126,11 @@ struct WorkRemoteToolResultAffordance: View { .accessibilityLabel("Loading full tool result") } else { Button(action: loadFullResult) { - Text("Show all (\(workToolResultRemoteByteLabel(remoteResultBytes)))") + // A host that flagged truncation without a size gives 0 here; the + // button still has to exist, it just cannot name a number. + Text(remoteResultBytes > 0 + ? "Show all (\(workToolResultRemoteByteLabel(remoteResultBytes)))" + : "Show all") .font(.caption2.weight(.semibold)) .foregroundStyle(ADEColor.accent) .frame(minHeight: 44) diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 57820d9dfc..90d5e0b551 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -1093,7 +1093,11 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { ) case "command_lifecycle": let status = optionalString(eventDict["status"]) - let steerId = optionalString(eventDict["steerId"] ?? eventDict["steer_id"]) + // Per key, never `?? ` on the raw values: an explicit `"steerId": null` + // arrives as `NSNull`, wins the coalesce, and hides a snake_case + // `steer_id` beside it — the steer id is then lost and its queued row + // never graduates. Same rule this file states for `apiErrorStatus`. + let steerId = optionalString(eventDict["steerId"]) ?? optionalString(eventDict["steer_id"]) if status == "cancelled" || status == "discarded" { let verb = status == "discarded" ? "discarded" : "cancelled" event = .systemNotice( @@ -1142,7 +1146,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { stopReason: stopReason, commandLifecycleStatus: type == "command_lifecycle" ? optionalString(eventDict["status"]) : nil, commandLifecycleSteerId: type == "command_lifecycle" - ? optionalString(eventDict["steerId"] ?? eventDict["steer_id"]) + ? optionalString(eventDict["steerId"]) ?? optionalString(eventDict["steer_id"]) : nil ) } diff --git a/apps/ios/ADETests/WorkAssistantRenderingTests.swift b/apps/ios/ADETests/WorkAssistantRenderingTests.swift index 5e40094f49..b5d2088099 100644 --- a/apps/ios/ADETests/WorkAssistantRenderingTests.swift +++ b/apps/ios/ADETests/WorkAssistantRenderingTests.swift @@ -674,8 +674,55 @@ final class WorkChatActiveSendCapabilityTests: XCTestCase { } XCTAssertEqual(kind, "auth") } +} + +/// The transcript's change-detection surface: what makes a row reconfigure +/// and re-measure, plus the envelope fields those rows are built from. +final class WorkChatTranscriptRowRevisionTests: XCTestCase { + /// Regression: `optionalString(a ?? b)` cannot fall through an explicit + /// null. A host that sends `"steerId": null` beside a snake_case + /// `steer_id` had the NSNull win the coalesce, so the steer id was lost and + /// its queued row never graduated. + func testCommandLifecycleReadsSnakeCaseSteerIdPastAnExplicitNull() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-09-16T00:00:01.000Z","sequence":1,"event":{"type":"command_lifecycle","commandUuid":"cmd-1","status":"started","steerId":null,"steer_id":"steer-1","turnId":"turn-1"}} + """ + + let transcript = parseWorkChatTranscript(raw) + XCTAssertEqual(transcript.count, 1) + XCTAssertEqual(transcript[0].commandLifecycleSteerId, "steer-1") + XCTAssertEqual(transcript[0].commandLifecycleStatus, "started") + } - // MARK: - Transcript row revision + /// Regression: a `resultTruncatedForMobile` flag with no `resultOriginalBytes` + /// left `toolResultFullBytes` nil, which reads as "the row already has + /// everything" and hid the fetch affordance for a head slice. + func testTruncatedToolResultKeepsItsFetchAffordanceWithoutAByteCount() throws { + let unsized = """ + {"sessionId":"chat-1","timestamp":"2026-09-16T00:00:01.000Z","sequence":4,"event":{"type":"tool_result","tool":"Bash","itemId":"item-1","status":"completed","result":"head slice","resultTruncatedForMobile":true}} + """ + XCTAssertEqual( + try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(unsized.utf8)).toolResultFullBytes, + 0, + "truncated, size unknown — not nil, which means complete" + ) + + let sized = """ + {"sessionId":"chat-1","timestamp":"2026-09-16T00:00:02.000Z","sequence":5,"event":{"type":"tool_result","tool":"Bash","itemId":"item-2","status":"completed","result":"head slice","resultTruncatedForMobile":true,"resultOriginalBytes":50000}} + """ + XCTAssertEqual( + try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(sized.utf8)).toolResultFullBytes, + 50_000 + ) + + let complete = """ + {"sessionId":"chat-1","timestamp":"2026-09-16T00:00:03.000Z","sequence":6,"event":{"type":"tool_result","tool":"Bash","itemId":"item-3","status":"completed","result":"whole thing"}} + """ + XCTAssertNil( + try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(complete.utf8)).toolResultFullBytes, + "an untruncated row must not offer a fetch" + ) + } private func toolCardRenderEntry( status: WorkToolCardStatus, From 337960576e36f96340a40081790c5e3f073f5289 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:11:09 -0400 Subject: [PATCH 23/26] fix(ios): a retry past the upload deadline can always send MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more review findings. An upload that never settled stayed `.abandoned` forever, so the retry row failed instantly on every tap and the message could not be sent at all. The no-duplicate rule still covers the whole timeout window — the first resolve waits it out before anything is marked abandoned — but past that deadline the original leg is presumed dead: the retry cancels it, records the failure, and stages the bytes inline. A duplicate temp upload is a file the send references once; an unsendable message is not recoverable. And `chat_tool_result` now carries the row's timestamp beside its sequence. Older hosts restarted `eventSequence` at 1 on every rehydration, so one legacy transcript can hold two generations under the same number and an exact sequence match could still pick the wrong one. With both named, both must match; with only the sequence, the newest-first scan keeps taking the newest of a tie. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.ts | 4 ++ .../chat/chatToolResultLookup.test.ts | 52 ++++++++++++++++ .../services/chat/chatToolResultLookup.ts | 15 +++++ apps/desktop/src/shared/types/sync.ts | 10 +++ apps/ios/ADE/Services/SyncService.swift | 14 ++++- .../Views/Work/WorkChatRichCardViews.swift | 6 +- .../Work/WorkComposerAttachmentStaging.swift | 19 +++++- .../Work/WorkRemoteToolResultAffordance.swift | 7 ++- .../WorkComposerDraftAttachmentTests.swift | 62 ++++++++++++++++--- 9 files changed, 174 insertions(+), 15 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 3c4c6d5fb9..cfc7ef9a40 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -8581,6 +8581,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { && Number.isFinite(payload.resultSequence) ? payload.resultSequence : null; + // Legacy transcripts can repeat a sequence across host restarts; the + // timestamp tells those generations apart. + const requestedResultTimestamp = toOptionalString(payload?.resultTimestamp); const unavailable = (): SyncChatToolResultResponsePayload => ({ sessionId: sessionId ?? "", itemId: itemId ?? "", @@ -8621,6 +8624,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { sessionId, itemId, ...(requestedResultSequence !== null ? { resultSequence: requestedResultSequence } : {}), + ...(requestedResultTimestamp ? { resultTimestamp: requestedResultTimestamp } : {}), ...(signal ? { signal } : {}), }), signal, diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts index 87839ee4f4..a32c2843bf 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts @@ -50,6 +50,58 @@ describe("chatToolResultRowId", () => { }); describe("findStoredToolResult", () => { + it("uses the timestamp to separate legacy generations that share a sequence", async () => { + // Older hosts restarted `eventSequence` at 1 on every rehydration, so one + // legacy transcript can hold two generations under the same number. With + // both named, both must match; with only the sequence named, the + // newest-first scan takes the newest of the tie. + const lines = [ + { sequence: 7, timestamp: "2026-09-19T10:00:00.000Z", result: "before restart" }, + { sequence: 7, timestamp: "2026-09-20T10:00:00.000Z", result: "after restart" }, + ].map(({ sequence, timestamp, result }) => JSON.stringify({ + sessionId: SESSION_ID, + timestamp, + sequence, + event: toolResult("item-1", result), + } satisfies AgentChatEventEnvelope)); + fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf8"); + + expect((await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 7, + resultTimestamp: "2026-09-19T10:00:00.000Z", + }))?.event.result).toBe("before restart"); + + expect((await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 7, + resultTimestamp: "2026-09-20T10:00:00.000Z", + }))?.event.result).toBe("after restart"); + + // Sequence alone: the newest of the tie, which is what the scan order + // already gives. + expect((await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 7, + }))?.event.result).toBe("after restart"); + + // A timestamp that belongs to no generation is "not found", never another + // attempt's output. + expect(await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-1", + resultSequence: 7, + resultTimestamp: "2026-01-01T00:00:00.000Z", + })).toBeNull(); + }); + it("answers the exact generation when the row names its sequence", async () => { // Regression: a retry reuses the logical item id, so the backward scan // returned the NEWEST result for every row and an older retry row showed diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts index 77d4b06d61..786ea65d02 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts @@ -48,6 +48,14 @@ export async function findStoredToolResult(options: { * already have. */ resultSequence?: number; + /** + * Timestamp of that same envelope, when the caller has it. `sequence` is not + * unique over a transcript's whole life — older hosts restarted it at 1 on + * every rehydration — so a legacy file can hold two generations under one + * number. With both named, both must match. Sequence alone keeps the + * newest-first scan, so the newest of a tie wins. + */ + resultTimestamp?: string; signal?: AbortSignal; }): Promise { const wantedId = options.itemId.trim(); @@ -55,6 +63,9 @@ export async function findStoredToolResult(options: { const wantedSequence = typeof options.resultSequence === "number" && Number.isFinite(options.resultSequence) ? options.resultSequence : null; + const wantedTimestamp = typeof options.resultTimestamp === "string" && options.resultTimestamp.trim() + ? options.resultTimestamp.trim() + : null; let beforeOffset = await readHistoryFileSize(options.transcriptPath); let scannedBytes = 0; for (let page = 0; page < CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES; page += 1) { @@ -80,6 +91,10 @@ export async function findStoredToolResult(options: { // other generation would be worse than "not found": the row would show // another attempt's output as its own. if (wantedSequence !== null && envelope.sequence !== wantedSequence) continue; + // Legacy transcripts can repeat a sequence across host restarts, so the + // timestamp disambiguates when the row names one. Sequence alone falls + // back to this newest-first scan, where the newest of a tie wins. + if (wantedTimestamp !== null && envelope.timestamp !== wantedTimestamp) continue; return { event, envelope }; } scannedBytes += windowBytes; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index b53bd82528..e24b5e4410 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1690,6 +1690,16 @@ export type SyncChatToolResultRequestPayload = SyncChatUnsubscribePayload & { * behaviour they already have. */ resultSequence?: number; + /** + * Timestamp of that same envelope. + * + * `sequence` alone is not unique across the whole life of a transcript: + * older hosts restarted `eventSequence` at 1 on every rehydration, so a + * legacy file can carry the same number for two different generations. + * Sent together, the pair identifies the row; the host requires both to + * match when both are named. + */ + resultTimestamp?: string; }; export type SyncChatToolResultResponsePayload = { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index ce4392892c..2f6b4dbb12 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13829,7 +13829,8 @@ final class SyncService: ObservableObject { func fullToolResult( sessionId: String, itemId: String, - eventSequence: Int? = nil + eventSequence: Int? = nil, + eventTimestamp: String? = nil ) async throws -> String { try await chatToolResultCache.fullToolResult( sessionId: sessionId, @@ -13840,7 +13841,8 @@ final class SyncService: ObservableObject { let response = try await self.fetchChatToolResult( sessionId: sessionId, itemId: itemId, - resultSequence: eventSequence + resultSequence: eventSequence, + resultTimestamp: eventTimestamp ) if response.unavailable == true { throw NSError( @@ -13871,7 +13873,8 @@ final class SyncService: ObservableObject { func fetchChatToolResult( sessionId: String, itemId: String, - resultSequence: Int? = nil + resultSequence: Int? = nil, + resultTimestamp: String? = nil ) async throws -> AgentChatToolResultResponse { let requestId = makeRequestId() var payload = chatSubscriptionPayload( @@ -13884,6 +13887,11 @@ final class SyncService: ObservableObject { // with a later attempt's output. Omitted when the row has none, which is // the pre-slim-wire shape the host still accepts. if let resultSequence { payload["resultSequence"] = resultSequence } + // Paired with the sequence: a legacy transcript can repeat a sequence + // across host restarts, and the timestamp tells those generations apart. + if let resultTimestamp, !resultTimestamp.isEmpty { + payload["resultTimestamp"] = resultTimestamp + } let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: false, diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index bdb5f4ea0e..658f8aa6d3 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -179,7 +179,11 @@ struct WorkToolCardView: View, Equatable { sessionId: sessionId, resultText: resultText, remoteResultBytes: remoteResultBytes, - eventSequence: toolCard.resultSequence + eventSequence: toolCard.resultSequence, + // The result envelope's own timestamp: `completedAt` is set + // from it on the same pass that stamps `resultSequence`, so + // the pair always names one generation. + eventTimestamp: toolCard.completedAt ) } else { let result = workToolResultBlockText(resultText, expanded: resultExpanded) diff --git a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift index b8721c4e9e..2f4428409d 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift @@ -380,7 +380,24 @@ final class WorkComposerAttachmentUploads { ) async -> WorkComposerAttachmentUploadResolution { guard let entry = entries[id] else { return .stageInline } if let ref = entry.ref { return .ref(ref) } - if entry.abandoned { return .abandoned } + if entry.abandoned { + // The deadline already passed once for this upload, so this call is the + // user's retry and the original leg is presumed dead. Cancel it, record + // the failure, and let the send stage the bytes inline. + // + // The earlier rule — never a second upload while the first is in flight + // — still holds for the whole timeout window: the first `resolve` waits + // it out before anything is marked abandoned. What changed is what + // happens after. A leg that never settles left every later retry + // returning `.abandoned`, so the message could never be sent at all. A + // duplicate host upload is a temp file the send references once; an + // unsendable message is not recoverable. + entry.task.cancel() + entries[id]?.abandoned = false + entries[id]?.settled = true + entries[id]?.failure = workChatAttachmentUploadTimedOutMessage + return .stageInline + } if entry.failure != nil { return .stageInline } let task = entry.task let settled = await workAwaitWithDeadline(timeoutNanoseconds: timeoutNanoseconds) { diff --git a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift index 5f408ad241..b6b114b4e9 100644 --- a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -12,6 +12,10 @@ struct WorkRemoteToolResultAffordance: View { let resultText: String let remoteResultBytes: Int let eventSequence: Int? + /// Timestamp of the same `tool_result` envelope the sequence came from. The + /// pair is what identifies this generation on a legacy transcript, where a + /// sequence can repeat across host restarts. + let eventTimestamp: String? @EnvironmentObject private var syncService: SyncService @State private var resultExpanded = false @@ -166,7 +170,8 @@ struct WorkRemoteToolResultAffordance: View { let fullResult = try await syncService.fullToolResult( sessionId: sessionId, itemId: itemId, - eventSequence: eventSequence + eventSequence: eventSequence, + eventTimestamp: eventTimestamp ) guard fetchGeneration == requestGeneration else { return } fetchedFullResult = fullResult diff --git a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift index 32bfce6f89..a940214c89 100644 --- a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift +++ b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift @@ -420,11 +420,55 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { } let resolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) - XCTAssertEqual(resolution, .abandoned) - // The original task is still running, so a retry must not fall through to - // a second inline upload for the same attachment. - let retryResolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) - XCTAssertEqual(retryResolution, .abandoned) + XCTAssertEqual( + resolution, + .abandoned, + "the send that waited out the deadline reports it, rather than racing a second upload" + ) + } + + /// The other half of that contract: a retry AFTER the deadline has to be able + /// to make progress. + /// + /// `.abandoned` covered the whole timeout window and then never lifted, so a + /// leg that never settled left every later retry failing instantly and the + /// message could not be sent at all. The retry now cancels the wedged task, + /// records the failure, and stages the bytes inline — a duplicate temp upload + /// on the host is a file the send references once, an unsendable message is + /// not recoverable. + @MainActor + func testARetryAfterTheDeadlineCancelsTheWedgedUploadAndStagesInline() async { + let id = UUID() + let uploads = WorkComposerAttachmentUploads.shared + var release: CheckedContinuation? + defer { + release?.resume() + uploads.release([id]) + } + + uploads.begin(id: id) { + await withCheckedContinuation { (continuation: CheckedContinuation) in + release = continuation + } + return AgentChatFileRef(path: "/never", type: "image") + } + + let first = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(first, .abandoned) + + let retry = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(retry, .stageInline, "the retry must be able to upload rather than fail forever") + XCTAssertEqual( + uploads.failure(for: id), + workChatAttachmentUploadTimedOutMessage, + "the wedged leg is recorded as failed, not left pending" + ) + XCTAssertFalse(uploads.isUploading(id), "the wedged upload is no longer treated as in flight") + + // Still inline-eligible on every later attempt; it never falls back to + // `.abandoned`. + let third = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual(third, .stageInline) } @MainActor @@ -447,11 +491,11 @@ final class WorkComposerDraftAttachmentTests: XCTestCase { let firstResolution = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) XCTAssertEqual(firstResolution, .abandoned) - let retryWhilePending = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + let retryAfterTheDeadline = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) XCTAssertEqual( - retryWhilePending, - .abandoned, - "an upload that is still in flight must not start a duplicate" + retryAfterTheDeadline, + .stageInline, + "past the deadline the original leg is presumed dead and the retry may upload" ) release?.resume() From bbdab77b94c798aea392b4a9c630fc7b51d0d6ef Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:39:43 -0400 Subject: [PATCH 24/26] fix(ios): the tool-result cache keys on the same generation the host does The fetch request gained the envelope timestamp beside its sequence, because a legacy transcript can repeat a sequence across host restarts. The cache did not, so two generations shared one entry and the second expansion was handed the first one's output without ever reaching the host. The cache key, the in-flight key, and the row's reset identity now all use the same session/item/sequence/timestamp tuple the host matches on. Co-Authored-By: Claude Opus 5 --- .../ADE/Services/ChatToolResultCache.swift | 38 ++++++++++--- apps/ios/ADE/Services/SyncService.swift | 9 ++- .../Work/WorkRemoteToolResultAffordance.swift | 9 ++- .../WorkChatToolResultCacheTests.swift | 55 +++++++++++++++++++ 4 files changed, 99 insertions(+), 12 deletions(-) diff --git a/apps/ios/ADE/Services/ChatToolResultCache.swift b/apps/ios/ADE/Services/ChatToolResultCache.swift index 273d27f657..715658fe6e 100644 --- a/apps/ios/ADE/Services/ChatToolResultCache.swift +++ b/apps/ios/ADE/Services/ChatToolResultCache.swift @@ -3,8 +3,13 @@ import Foundation /// Bounded, versioned cache for tool results fetched from the host. /// /// The item id is not a sufficient identity: a retry can reuse an item id for -/// a newer result. The transcript sequence is therefore part of both the -/// cache and in-flight request keys whenever the wire provides one. +/// a newer result. The transcript sequence AND its timestamp are therefore part +/// of both the cache and in-flight request keys whenever the wire provides +/// them — the same pair the host matches on. The sequence alone is not enough +/// either: older hosts restarted `eventSequence` at 1 on every rehydration, so +/// one legacy transcript can hold two generations under the same number, and a +/// sequence-only key would hand the second expansion the first one's output +/// without ever contacting the host. @MainActor final class WorkChatToolResultCache { private let limit: Int @@ -19,18 +24,30 @@ final class WorkChatToolResultCache { func cachedFullToolResult( sessionId: String, itemId: String, - eventSequence: Int? + eventSequence: Int?, + eventTimestamp: String? = nil ) -> String? { - values[key(sessionId: sessionId, itemId: itemId, eventSequence: eventSequence)] + values[key( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence, + eventTimestamp: eventTimestamp + )] } func fullToolResult( sessionId: String, itemId: String, eventSequence: Int?, + eventTimestamp: String? = nil, fetch: @escaping () async throws -> String ) async throws -> String { - let cacheKey = key(sessionId: sessionId, itemId: itemId, eventSequence: eventSequence) + let cacheKey = key( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence, + eventTimestamp: eventTimestamp + ) if let cached = values[cacheKey] { return cached } if let task = inFlight[cacheKey] { return try await task.value } @@ -50,7 +67,14 @@ final class WorkChatToolResultCache { return value } - private func key(sessionId: String, itemId: String, eventSequence: Int?) -> String { - "\(sessionId)|\(itemId)|\(eventSequence.map(String.init) ?? "latest")" + private func key( + sessionId: String, + itemId: String, + eventSequence: Int?, + eventTimestamp: String? + ) -> String { + let sequence = eventSequence.map(String.init) ?? "latest" + let timestamp = eventTimestamp.flatMap { $0.isEmpty ? nil : $0 } ?? "-" + return "\(sessionId)|\(itemId)|\(sequence)|\(timestamp)" } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2f6b4dbb12..d877077f4a 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13812,12 +13812,14 @@ final class SyncService: ObservableObject { func cachedFullToolResult( sessionId: String, itemId: String, - eventSequence: Int? = nil + eventSequence: Int? = nil, + eventTimestamp: String? = nil ) -> String? { chatToolResultCache.cachedFullToolResult( sessionId: sessionId, itemId: itemId, - eventSequence: eventSequence + eventSequence: eventSequence, + eventTimestamp: eventTimestamp ) } @@ -13835,7 +13837,8 @@ final class SyncService: ObservableObject { try await chatToolResultCache.fullToolResult( sessionId: sessionId, itemId: itemId, - eventSequence: eventSequence + eventSequence: eventSequence, + eventTimestamp: eventTimestamp ) { [weak self] in guard let self else { throw CancellationError() } let response = try await self.fetchChatToolResult( diff --git a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift index b6b114b4e9..206c3250c8 100644 --- a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -28,8 +28,12 @@ struct WorkRemoteToolResultAffordance: View { fetchedFullResult ?? resultText } + /// Same fields the cache and the host key on, so a row that switches + /// generation resets its fetched state instead of keeping the old output. private var resultIdentity: String { - "\(sessionId)|\(itemId)|\(eventSequence.map(String.init) ?? "latest")" + let sequence = eventSequence.map(String.init) ?? "latest" + let timestamp = eventTimestamp.flatMap { $0.isEmpty ? nil : $0 } ?? "-" + return "\(sessionId)|\(itemId)|\(sequence)|\(timestamp)" } var body: some View { @@ -190,7 +194,8 @@ struct WorkRemoteToolResultAffordance: View { fetchedFullResult = syncService.cachedFullToolResult( sessionId: sessionId, itemId: itemId, - eventSequence: eventSequence + eventSequence: eventSequence, + eventTimestamp: eventTimestamp ) } } diff --git a/apps/ios/ADETests/WorkChatToolResultCacheTests.swift b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift index 716d85506b..17a6d78ad8 100644 --- a/apps/ios/ADETests/WorkChatToolResultCacheTests.swift +++ b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift @@ -45,4 +45,59 @@ final class WorkChatToolResultCacheTests: XCTestCase { "new result" ) } + + /// Regression: the request identity gained the envelope timestamp (a legacy + /// transcript can repeat a sequence across host restarts) but the cache key + /// did not, so the second generation was served the first one's output + /// without ever contacting the host. + func testRepeatedLegacySequencesAreSeparatedByTheirTimestamp() async throws { + let cache = WorkChatToolResultCache(limit: 4) + var fetchCount = 0 + + let before = try await cache.fullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 42, + eventTimestamp: "2026-09-19T10:00:00.000Z" + ) { + fetchCount += 1 + return "before restart" + } + let after = try await cache.fullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 42, + eventTimestamp: "2026-09-20T11:00:00.000Z" + ) { + fetchCount += 1 + return "after restart" + } + + XCTAssertEqual(before, "before restart") + XCTAssertEqual(after, "after restart", "the second generation must not reuse the first's entry") + XCTAssertEqual(fetchCount, 2, "each generation fetches once") + + XCTAssertEqual( + cache.cachedFullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 42, + eventTimestamp: "2026-09-19T10:00:00.000Z" + ), + "before restart" + ) + XCTAssertEqual( + cache.cachedFullToolResult( + sessionId: "session-1", + itemId: "item-1", + eventSequence: 42, + eventTimestamp: "2026-09-20T11:00:00.000Z" + ), + "after restart" + ) + XCTAssertNil( + cache.cachedFullToolResult(sessionId: "session-1", itemId: "item-1", eventSequence: 42), + "a lookup with no timestamp is a different identity, not a wildcard" + ) + } } From c17a5798e3ea6aff271c9e76a0e3f3c8f0b3f9e9 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 22:46:57 -0400 Subject: [PATCH 25/26] feat(sync): a tool-result fetch can say where the row is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findStoredToolResult` scans a bounded window back from the tail so an id that is not in the file costs fixed I/O. The reader can page much further back than that window, though, so a truncated result they can still see read as "no longer in the transcript" — on a 16 MB thread, about half of them. The window stays as it is. Instead the row now carries its location: the pager already computed `envelopeStartOffsets`, so `chat_history` returns them, the phone stamps each row with its own offset, and `chat_tool_result` passes it back as `sourceOffset`. The host reads that one row directly and falls back to the existing scan when the hint misses. The hint is never trusted on its own — the row found there must still match the id, the sequence and the timestamp — so a stale, misaligned or foreign offset costs a scan, never a wrong result. Offsets are zipped onto events only when the lossy event array still has the same length, so a row this build cannot decode drops the page's hints rather than shifting them onto the wrong rows. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.ts | 12 +++ .../chat/chatToolResultLookup.test.ts | 91 ++++++++++++++++++- .../services/chat/chatToolResultLookup.ts | 85 ++++++++++++++++- apps/desktop/src/shared/types/chat.ts | 13 +++ apps/desktop/src/shared/types/sync.ts | 10 ++ apps/ios/ADE/Models/RemoteModels.swift | 30 ++++++ apps/ios/ADE/Services/SyncService.swift | 18 +++- .../Views/Work/WorkChatRichCardViews.swift | 3 +- .../Work/WorkErrorAndMessageHelpers.swift | 3 +- apps/ios/ADE/Views/Work/WorkModels.swift | 16 +++- .../WorkNavigationAndTranscriptHelpers.swift | 3 +- .../Work/WorkRemoteToolResultAffordance.swift | 7 +- 12 files changed, 278 insertions(+), 13 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index cfc7ef9a40..b9ceb80dd8 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -8542,6 +8542,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { sessionId, events: read.envelopes, startOffset: read.startOffset, + // Per-row locations, so a later `chat_tool_result` for a row on + // this page can be answered by an exact read instead of a scan + // that may not reach back this far. + envelopeStartOffsets: read.envelopeStartOffsets, hasMore: read.hasMore, sessionFound: true, }; @@ -8584,6 +8588,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Legacy transcripts can repeat a sequence across host restarts; the // timestamp tells those generations apart. const requestedResultTimestamp = toOptionalString(payload?.resultTimestamp); + // Where the phone saw the row. A hint, never trusted on its own: the + // lookup still verifies the row it finds there is the one asked for. + const requestedSourceOffset = typeof payload?.sourceOffset === "number" + && Number.isFinite(payload.sourceOffset) + && payload.sourceOffset >= 0 + ? payload.sourceOffset + : null; const unavailable = (): SyncChatToolResultResponsePayload => ({ sessionId: sessionId ?? "", itemId: itemId ?? "", @@ -8625,6 +8636,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { itemId, ...(requestedResultSequence !== null ? { resultSequence: requestedResultSequence } : {}), ...(requestedResultTimestamp ? { resultTimestamp: requestedResultTimestamp } : {}), + ...(requestedSourceOffset !== null ? { sourceOffset: requestedSourceOffset } : {}), ...(signal ? { signal } : {}), }), signal, diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts index a32c2843bf..b3b051b5c3 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts @@ -3,7 +3,11 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; -import { chatToolResultRowId, findStoredToolResult } from "./chatToolResultLookup"; +import { + CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES, + chatToolResultRowId, + findStoredToolResult, +} from "./chatToolResultLookup"; const SESSION_ID = "session-tool-result"; @@ -50,6 +54,91 @@ describe("chatToolResultRowId", () => { }); describe("findStoredToolResult", () => { + it("answers from the sourceOffset hint, past the bounded tail scan", async () => { + // The scan only reaches a fixed window back from the tail, but the reader + // can page much further than that. A row they can still see must stay + // fetchable, and the hint is what makes that one exact read. + const target = JSON.stringify({ + sessionId: SESSION_ID, + timestamp: "2026-09-19T10:00:00.000Z", + sequence: 1, + event: toolResult("item-deep", "the deep result"), + } satisfies AgentChatEventEnvelope); + // Pad past CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES so the backward scan provably + // cannot reach the target row. + const padRow = (index: number) => JSON.stringify({ + sessionId: SESSION_ID, + timestamp: `2026-09-20T10:00:${String(index % 60).padStart(2, "0")}.000Z`, + sequence: index + 2, + event: { type: "text", text: "x".repeat(4_096) }, + } satisfies AgentChatEventEnvelope); + const padCount = Math.ceil(CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES / 4_096) + 16; + const pad = Array.from({ length: padCount }, (_, index) => padRow(index)); + fs.writeFileSync(transcriptPath, `${[target, ...pad].join("\n")}\n`, "utf8"); + + // Without the hint the row is out of reach. + expect(await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-deep", + })).toBeNull(); + + // With it, one exact read answers. + const hit = await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-deep", + sourceOffset: 0, + resultSequence: 1, + resultTimestamp: "2026-09-19T10:00:00.000Z", + }); + expect(hit?.event.result).toBe("the deep result"); + expect(hit?.envelope.sequence).toBe(1); + }); + + it("falls back to the scan when the hint misses", async () => { + write([ + toolResult("item-1", "first"), + { type: "text", text: "between" }, + toolResult("item-2", "second"), + ]); + + // An offset pointing at the wrong row: the hinted read finds a row that is + // not the one asked for, so it is discarded and the scan answers. + const hit = await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-2", + sourceOffset: 0, + }); + expect(hit?.event.result).toBe("second"); + + // An offset past the end of the file, and one landing mid-row, are both + // just missed hints. + expect((await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-2", + sourceOffset: 10_000_000, + }))?.event.result).toBe("second"); + expect((await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-2", + sourceOffset: 12, + }))?.event.result).toBe("second"); + + // A hint that lands on the right row but the wrong generation is refused + // by the same identity check the scan uses. + expect(await findStoredToolResult({ + transcriptPath, + sessionId: SESSION_ID, + itemId: "item-2", + sourceOffset: 0, + resultSequence: 99, + })).toBeNull(); + }); + it("uses the timestamp to separate legacy generations that share a sequence", async () => { // Older hosts restarted `eventSequence` at 1 on every rehydration, so one // legacy transcript can hold two generations under the same number. With diff --git a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts index 786ea65d02..c187252f4b 100644 --- a/apps/desktop/src/main/services/chat/chatToolResultLookup.ts +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts @@ -1,6 +1,7 @@ import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; +import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { readTranscriptHistoryPage } from "./chatTranscriptHistoryPager"; -import { readHistoryFileSize } from "../storage/historyCompression"; +import { readHistoryFileRange, readHistoryFileSize } from "../storage/historyCompression"; /** * Find one stored `tool_result` in a transcript by the id its row is keyed on. @@ -21,6 +22,11 @@ import { readHistoryFileSize } from "../storage/historyCompression"; */ export const CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES = 8 * 1024 * 1024; export const CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES = 32; +/** + * Window read forward from a client's `sourceOffset` hint. One row, plus room + * for a long one — the wire caps a stored result well below this. + */ +export const CHAT_TOOL_RESULT_HINT_MAX_BYTES = 2 * 1024 * 1024; export type ChatToolResultLookupHit = { event: Extract; @@ -56,6 +62,18 @@ export async function findStoredToolResult(options: { * newest-first scan, so the newest of a tie wins. */ resultTimestamp?: string; + /** + * Byte offset the client saw this row at, from a `chat_history` page. + * + * The scan below is bounded to a fixed window back from the tail so an id + * that is not in the file costs fixed I/O. A reader can page much further + * back than that window, though, so a row they can see may sit outside it + * and read as "no longer in the transcript". This hint turns that case into + * one exact read. It is never trusted on its own: the row found there must + * still match the id (and the generation, when named), and anything else + * falls through to the scan. + */ + sourceOffset?: number; signal?: AbortSignal; }): Promise { const wantedId = options.itemId.trim(); @@ -66,6 +84,17 @@ export async function findStoredToolResult(options: { const wantedTimestamp = typeof options.resultTimestamp === "string" && options.resultTimestamp.trim() ? options.resultTimestamp.trim() : null; + const hinted = await readHintedToolResult({ + transcriptPath: options.transcriptPath, + sessionId: options.sessionId, + sourceOffset: options.sourceOffset, + wantedId, + wantedSequence, + wantedTimestamp, + ...(options.signal ? { signal: options.signal } : {}), + }); + if (hinted) return hinted; + let beforeOffset = await readHistoryFileSize(options.transcriptPath); let scannedBytes = 0; for (let page = 0; page < CHAT_TOOL_RESULT_LOOKUP_MAX_PAGES; page += 1) { @@ -104,3 +133,57 @@ export async function findStoredToolResult(options: { } return null; } + +/** + * The one row at `sourceOffset`, when the client told us where it saw it. + * + * Reads a single bounded window forward from that byte and takes the first + * complete line, which is the row itself — the offsets the pager hands out + * always land on a line start. Everything about the hit is then verified + * against what was asked for, so a stale or misaligned hint returns null and + * the caller scans as before rather than answering with the wrong row. + */ +async function readHintedToolResult(args: { + transcriptPath: string; + sessionId: string; + sourceOffset: number | undefined; + wantedId: string; + wantedSequence: number | null; + wantedTimestamp: string | null; + signal?: AbortSignal; +}): Promise { + const { sourceOffset } = args; + if (typeof sourceOffset !== "number" || !Number.isFinite(sourceOffset) || sourceOffset < 0) { + return null; + } + if (args.signal?.aborted) return null; + try { + const buffer = await readHistoryFileRange( + args.transcriptPath, + sourceOffset, + CHAT_TOOL_RESULT_HINT_MAX_BYTES, + args.signal, + ); + const text = buffer.toString("utf8"); + const newline = text.indexOf("\n"); + // No terminator inside the window means the row is larger than the hint + // budget; the scan reads it through the pager's own oversized-row path. + if (newline < 0) return null; + const line = text.slice(0, newline).trim(); + if (!line) return null; + const [envelope] = parseAgentChatTranscript(line); + if (!envelope) return null; + // The hint names a byte in THIS session's file, so a row for another + // session means the offset is stale or came from somewhere else. + if (envelope.sessionId !== args.sessionId) return null; + const event = envelope.event; + if (!event || event.type !== "tool_result") return null; + if (chatToolResultRowId(event) !== args.wantedId) return null; + if (args.wantedSequence !== null && envelope.sequence !== args.wantedSequence) return null; + if (args.wantedTimestamp !== null && envelope.timestamp !== args.wantedTimestamp) return null; + return { event, envelope }; + } catch { + // A bad hint is only ever a missed hint. + return null; + } +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 7d0c8c0fc7..3e8d566921 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1972,6 +1972,19 @@ export type AgentChatEventHistoryPage = { events: AgentChatEventEnvelope[]; /** Byte offset in the transcript where this page begins. Pass as the next request's beforeOffset. 0 = head reached. */ startOffset: number; + /** + * Byte offset of each row in `events`, positionally aligned with it. + * + * Lets a client hand a later `chat_tool_result` the exact location of the + * row it wants instead of asking the host to scan for it: a truncated result + * the reader paged back to can sit far outside that lookup's bounded + * tail window, and without a location it reads as "no longer in the + * transcript". Optional so older hosts simply omit it, and a client whose + * event array lost an element to a lossy decode must compare lengths before + * zipping — a mismatched hint is only ever a missed hint, never a wrong row, + * because the host still matches the row's own sequence and timestamp. + */ + envelopeStartOffsets?: number[]; hasMore: boolean; sessionFound: boolean; /** diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index e24b5e4410..7d862366f2 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1700,6 +1700,16 @@ export type SyncChatToolResultRequestPayload = SyncChatUnsubscribePayload & { * match when both are named. */ resultTimestamp?: string; + /** + * Byte offset of the row in the transcript, when the client got it from a + * `chat_history` page. + * + * Purely a hint: the host reads that one row directly (fixed I/O, no scan) + * and still checks it is the row that was asked for. A hint that misses — + * stale, misaligned, or from another file — falls back to the bounded scan, + * so it can never produce a wrong result, only a slower one. + */ + sourceOffset?: number; }; export type SyncChatToolResultResponsePayload = { diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index f451757472..873b6913bb 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2599,6 +2599,15 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { /// `AgentChatEvent.toolResult` already carries seven associated values that /// every construction site in the app and the tests would have to restate. var toolResultFullBytes: Int? + /// Byte offset of this row in the host's transcript, when it arrived on a + /// `chat_history` page that reported one. + /// + /// Not decoded from the event — the page carries the offsets in a parallel + /// array and the client stamps them on. Carried so a later "show full + /// result" can tell the host exactly where to read instead of asking it to + /// scan: that scan is bounded to a window near the tail, and a row the + /// reader paged back to can sit outside it. + var sourceOffset: Int? init( sessionId: String, @@ -2803,9 +2812,30 @@ struct AgentChatEventHistoryPage: Decodable, Equatable { var sessionId: String @ADELossyArray var events: [AgentChatEventEnvelope] var startOffset: Int + /// Byte offset of each row in `events`, positionally aligned with it. Absent + /// on hosts that predate it. + var envelopeStartOffsets: [Int]? var hasMore: Bool var sessionFound: Bool var unavailable: Bool? + + /// The page with each event stamped with its own offset. + /// + /// `events` is a lossy array: a row this build cannot decode is dropped, and + /// that would shift every later offset onto the wrong row. So the two are + /// zipped only when their lengths still agree. A dropped row therefore costs + /// the whole page its hints, which only ever means the host falls back to + /// scanning — never a hint pointing at the wrong row. + func stampingEnvelopeOffsets() -> AgentChatEventHistoryPage { + guard let offsets = envelopeStartOffsets, offsets.count == events.count else { return self } + var stamped = self + stamped.events = zip(events, offsets).map { event, offset in + var copy = event + copy.sourceOffset = offset >= 0 ? offset : nil + return copy + } + return stamped + } } /// One tool result fetched on demand, after the slim mobile wire delivered diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d877077f4a..f09c09ffeb 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -13832,7 +13832,8 @@ final class SyncService: ObservableObject { sessionId: String, itemId: String, eventSequence: Int? = nil, - eventTimestamp: String? = nil + eventTimestamp: String? = nil, + sourceOffset: Int? = nil ) async throws -> String { try await chatToolResultCache.fullToolResult( sessionId: sessionId, @@ -13845,7 +13846,8 @@ final class SyncService: ObservableObject { sessionId: sessionId, itemId: itemId, resultSequence: eventSequence, - resultTimestamp: eventTimestamp + resultTimestamp: eventTimestamp, + sourceOffset: sourceOffset ) if response.unavailable == true { throw NSError( @@ -13877,7 +13879,8 @@ final class SyncService: ObservableObject { sessionId: String, itemId: String, resultSequence: Int? = nil, - resultTimestamp: String? = nil + resultTimestamp: String? = nil, + sourceOffset: Int? = nil ) async throws -> AgentChatToolResultResponse { let requestId = makeRequestId() var payload = chatSubscriptionPayload( @@ -13895,6 +13898,11 @@ final class SyncService: ObservableObject { if let resultTimestamp, !resultTimestamp.isEmpty { payload["resultTimestamp"] = resultTimestamp } + // Where this row was read from, when it came off a history page. The host + // reads that one row directly instead of scanning back from the tail, + // which is what makes a result the reader paged a long way back to + // fetchable at all. + if let sourceOffset, sourceOffset >= 0 { payload["sourceOffset"] = sourceOffset } let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: false, @@ -13931,7 +13939,7 @@ final class SyncService: ObservableObject { ) { self.sendEnvelope(type: "chat_history", requestId: requestId, payload: payload) } - let page = try decode(raw, as: AgentChatEventHistoryPage.self) + let page = try decode(raw, as: AgentChatEventHistoryPage.self).stampingEnvelopeOffsets() recordChatHistoryPageCursor( requestedSessionId: sessionId, beforeOffset: beforeOffset, @@ -13952,7 +13960,7 @@ final class SyncService: ObservableObject { targetProjectId: scope.projectId, targetProjectRootPath: scope.rootPath, as: AgentChatEventHistoryPage.self - ) + ).stampingEnvelopeOffsets() recordChatHistoryPageCursor( requestedSessionId: sessionId, beforeOffset: beforeOffset, diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 658f8aa6d3..562e58bc6c 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -183,7 +183,8 @@ struct WorkToolCardView: View, Equatable { // The result envelope's own timestamp: `completedAt` is set // from it on the same pass that stamps `resultSequence`, so // the pair always names one generation. - eventTimestamp: toolCard.completedAt + eventTimestamp: toolCard.completedAt, + sourceOffset: toolCard.resultSourceOffset ) } else { let result = workToolResultBlockText(resultText, expanded: resultExpanded) diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 05e911acfe..1a4f5b4371 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -884,7 +884,8 @@ func makeWorkChatTranscript(from entries: [AgentChatEventEnvelope]) -> [WorkChat commandLifecycleSteerId: { guard case .commandLifecycle(_, _, _, let steerId, _) = entry.event else { return nil } return steerId - }() + }(), + sourceOffset: entry.sourceOffset ) } .sorted(by: workChatEnvelopeOrderedBefore) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 64318eac8c..76c6ba949c 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -91,6 +91,10 @@ struct WorkToolCardModel: Identifiable, Hashable { /// Transcript sequence for the result. Retry events may reuse `itemId`, so /// the sequence is part of the remote-result cache identity. let resultSequence: Int? + /// Where the result row sits in the host transcript, when the phone read it + /// off a history page. Turns the fetch into an exact read rather than a + /// bounded scan that may not reach back this far. + let resultSourceOffset: Int? init( id: String, @@ -104,7 +108,8 @@ struct WorkToolCardModel: Identifiable, Hashable { webSearchResults: [CodexWebSearchResult]? = nil, remoteResultBytes: Int? = nil, sessionId: String? = nil, - resultSequence: Int? = nil + resultSequence: Int? = nil, + resultSourceOffset: Int? = nil ) { self.id = id self.toolName = toolName @@ -118,6 +123,7 @@ struct WorkToolCardModel: Identifiable, Hashable { self.remoteResultBytes = remoteResultBytes self.sessionId = sessionId self.resultSequence = resultSequence + self.resultSourceOffset = resultSourceOffset } } @@ -1809,6 +1815,10 @@ struct WorkChatEnvelope: Identifiable, Equatable { /// reports `started`/`completed` without a delivered user-message frame. let commandLifecycleStatus: String? let commandLifecycleSteerId: String? + /// Byte offset of this row in the host's transcript, when it came off a + /// `chat_history` page. Lets a "show full result" fetch name the exact + /// location instead of relying on the host's bounded tail scan. + let sourceOffset: Int? init( sessionId: String, @@ -1827,7 +1837,8 @@ struct WorkChatEnvelope: Identifiable, Equatable { stopReason: String? = nil, toolResultFullBytes: Int? = nil, commandLifecycleStatus: String? = nil, - commandLifecycleSteerId: String? = nil + commandLifecycleSteerId: String? = nil, + sourceOffset: Int? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -1846,6 +1857,7 @@ struct WorkChatEnvelope: Identifiable, Equatable { self.toolResultFullBytes = toolResultFullBytes self.commandLifecycleStatus = commandLifecycleStatus self.commandLifecycleSteerId = commandLifecycleSteerId + self.sourceOffset = sourceOffset } } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index e89f22974f..b885b3912f 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -898,7 +898,8 @@ func buildWorkToolCards( // there was. remoteResultBytes: envelope.toolResultFullBytes, sessionId: envelope.sessionId, - resultSequence: envelope.sequence + resultSequence: envelope.sequence, + resultSourceOffset: envelope.sourceOffset ) case .webSearch(let query, let action, let actions, let results, let status, let itemId, _): // Web searches are tool calls — render them as tool cards so they're diff --git a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift index 206c3250c8..c5043f7948 100644 --- a/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -16,6 +16,10 @@ struct WorkRemoteToolResultAffordance: View { /// pair is what identifies this generation on a legacy transcript, where a /// sequence can repeat across host restarts. let eventTimestamp: String? + /// Byte offset of the row in the host transcript, when the phone has one. + /// Deliberately NOT part of `resultIdentity`: it is a location hint, not + /// part of what makes this result this result. + let sourceOffset: Int? @EnvironmentObject private var syncService: SyncService @State private var resultExpanded = false @@ -175,7 +179,8 @@ struct WorkRemoteToolResultAffordance: View { sessionId: sessionId, itemId: itemId, eventSequence: eventSequence, - eventTimestamp: eventTimestamp + eventTimestamp: eventTimestamp, + sourceOffset: sourceOffset ) guard fetchGeneration == requestGeneration else { return } fetchedFullResult = fullResult From 7ba8448680f5f7a747388396e0dcc4f28ffb3d85 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:25:07 -0400 Subject: [PATCH 26/26] fix(sync): a handoff flushes coalesced progress before it exports the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A slim subagent-progress event is marked delivered the moment the coalescer accepts it, because coalescing owns it from then on and this host flushes it when the one-second window closes. That bargain holds only while the pending entry stays in this process. A shared-listener handoff exports the cursor and not the window, so the adopted host resumed after those sequences and never re-read them: the update died with the process and the phone's subagent card kept whatever it last showed. Every pending entry is now flushed onto the peer's wire — still open, the loop skips peers whose socket is not — before the snapshot is built. If a send fails mid-flush, the exported cursor is held back to the last sequence that actually left, so the adopted host re-reads the stranded window instead of skipping it. Co-Authored-By: Claude Opus 5 --- .../src/services/sync/syncHostService.test.ts | 134 ++++++++++++++++++ .../src/services/sync/syncHostService.ts | 49 ++++++- 2 files changed, 182 insertions(+), 1 deletion(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 513471b2da..db1ec43609 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -9065,6 +9065,140 @@ describe("sync host handoff over a shared listener", () => { } }); + it("flushes coalesced subagent progress to the wire before handing off its cursor", async () => { + // A slim progress event is marked delivered the moment the coalescer + // accepts it — this host promised to send it when the one-second window + // closed. A handoff exports the cursor and not the window, so without a + // flush on the way out the adopted host resumes past that sequence and the + // update dies with the process, leaving the phone's subagent card stale. + const { projectRoot, cleanup } = createTempProjectRoot(); + const tokenPath = path.join(projectRoot, "shared-chat-handoff-token"); + const listener = createSharedSyncListener({ bindHost: "127.0.0.1" }); + const transcriptPath = path.join(projectRoot, "chat-handoff.jsonl"); + fs.writeFileSync(transcriptPath, "", "utf8"); + const sessionId = "chat-handoff"; + const session = { + id: sessionId, + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const chatEventEmitter: { current?: (event: AgentChatEventEnvelope) => void } = {}; + const makeArgs = () => ({ + ...createHandoffHostArgs(projectRoot, tokenPath, { + siteId: "site-chat-handoff", + dbVersion: 0, + changes: [], + }), + pollIntervalMs: 60_000, + projectId: "project-1", + sessionService: { + list: () => [session], + get: (id: string) => (id === sessionId ? session : null), + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn((callback: (event: AgentChatEventEnvelope) => void) => { + chatEventEmitter.current = callback; + return () => {}; + }), + getChatEventHistory: vi.fn().mockReturnValue({ + sessionId, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }), + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + sharedListener: listener, + }); + const progress = (sequence: number, summary: string): AgentChatEventEnvelope => ({ + sessionId, + timestamp: `2026-09-20T10:00:0${sequence}.000Z`, + sequence, + event: { + type: "subagent_progress", + taskId: "agent-1", + agentId: "agent-1", + summary, + }, + } as AgentChatEventEnvelope); + const emitChatEvent = (event: AgentChatEventEnvelope): void => { + const callback = chatEventEmitter.current; + if (!callback) throw new Error("chat event subscription was not installed"); + callback(event); + }; + const chatEvents = (peerEnvelopes: ParsedSyncEnvelope[]) => + peerEnvelopes.filter((envelope) => envelope.type === "chat_event"); + + let hostA: ReturnType | null = null; + let hostB: ReturnType | null = null; + let peer: Awaited> | null = null; + try { + hostA = createSyncHostService( + makeArgs() as unknown as Parameters[0], + ); + const port = await hostA.waitUntilListening(); + peer = await connectPeer(port, hostA.getBootstrapToken(), "ios-chat-handoff", { + capabilities: [SYNC_MOBILE_CHAT_SLIM_CAPABILITY], + }); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-handoff-subscribe", + payload: { sessionId }, + })); + await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-handoff-subscribe"); + + // The first progress for an agent goes straight out and opens the + // window; the second is held inside it. + emitChatEvent(progress(1, "first")); + await waitForValue( + () => (chatEvents(peer!.envelopes).length === 1 ? true : undefined), + "first progress on the wire", + ); + emitChatEvent(progress(2, "second")); + expect(chatEvents(peer.envelopes)).toHaveLength(1); + + // Handoff. The held entry has to leave before the cursor does. + await hostA.dispose(); + hostA = null; + + const flushed = await waitForValue( + () => chatEvents(peer!.envelopes)[1], + "held progress flushed by the handoff", + ); + expect(flushed.payload).toMatchObject({ + event: { type: "subagent_progress", agentId: "agent-1", summary: "second" }, + }); + + // And the cursor it exported still covers both, so the adopted host + // does not replay them onto the same socket. + hostB = createSyncHostService( + makeArgs() as unknown as Parameters[0], + ); + expect(await hostB.waitUntilListening()).toBe(port); + await waitForValue( + () => (hostB!.getPeerStates().length === 1 ? true : undefined), + "adopted peer on host B", + ); + expect(chatEvents(peer.envelopes)).toHaveLength(2); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await hostA?.dispose(); + await hostB?.dispose(); + await listener.close(); + cleanup(); + } + }); + it("preserves an invalidation browser's cursor across a same-database handoff", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const tokenPath = path.join(projectRoot, "shared-browser-bootstrap-token"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index b9ceb80dd8..4419b5eea5 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -5831,6 +5831,43 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subagentProgressCoalescers.delete(sessionId); } + /** + * Push every coalesced progress entry still inside its one-second window out + * to this peer, before its cursor is handed to another host. + * + * `sendChatEvent` marks a slim progress event delivered the moment the + * coalescer accepts it — coalescing owns it from then on, and this host will + * flush it when the window closes. That bargain holds only while the pending + * entry stays in this process's memory. A handoff exports the cursor and not + * the window, so the adopted host resumes AFTER those sequences and never + * re-reads them: the update dies here, and the phone's subagent card keeps + * whatever it last showed. + * + * Returns, per session, the highest sequence still honestly on the wire when + * something could not be flushed, so the caller can hold the exported cursor + * back to it rather than claiming the whole window went out. + */ + function flushChatEventCoalescersForHandoff(peer: PeerState): Map { + const cursorCaps = new Map(); + if (peer.subagentProgressCoalescers.size === 0) return cursorCaps; + const nowMs = Date.now(); + for (const [sessionId, coalescer] of peer.subagentProgressCoalescers) { + deliverCoalescedChatEvents(peer, coalescer, coalescer.flushAll(nowMs)); + if (coalescer.pendingCount === 0) continue; + // A send failed mid-flush and the rest was requeued. Read what is left + // without consuming it, so the cursor can stop short of it. + const stranded = coalescer.flushAll(nowMs); + if (stranded.length === 0) continue; + coalescer.requeue(stranded); + const oldest = stranded.reduce( + (lowest, entry) => Math.min(lowest, entry.sourceSeq), + Number.POSITIVE_INFINITY, + ); + if (Number.isFinite(oldest)) cursorCaps.set(sessionId, oldest - 1); + } + return cursorCaps; + } + async function pumpChatEvents(peer: PeerState): Promise { if (disposed || !peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) return; if (isPeerBackpressured(peer)) return; @@ -9397,6 +9434,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } continue; } + // Anything the coalescer is still holding was already marked + // delivered, so it has to reach the wire before the cursor below + // tells the next host those sequences are behind it. + const chatEventCursorCaps = flushChatEventCoalescersForHandoff(peer); const chatSubscriptions = [...peer.subscribedChatSessionIds].flatMap((sessionId) => { const scope = peer.chatSubscriptionBindings.get(sessionId)?.scope ?? "project"; return scope === "foreign-project" ? [] : [{ sessionId, scope }]; @@ -9435,7 +9476,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { chatEventSequences: Object.fromEntries( [...handedOffChatSessionIds].flatMap((sessionId) => { const eventSequence = chatEventSequenceHighWaterBySession.get(sessionId); - return eventSequence == null ? [] : [[sessionId, eventSequence]]; + if (eventSequence == null) return []; + // Held back to the last sequence that actually reached the + // wire when a flush could not complete, so the adopted host + // re-reads the stranded window instead of skipping it. + const cap = chatEventCursorCaps.get(sessionId); + const exported = cap == null ? eventSequence : Math.min(eventSequence, cap); + return exported < 0 ? [] : [[sessionId, exported]]; }), ), rosterSubscribed: peer.rosterSubscribed,