diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 8d2fe0e2df..db1ec43609 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 { @@ -49,6 +50,7 @@ import { buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, compactChatEventEnvelopeForSync, + compactChatEventEnvelopeForMobileSync, createChatEventReplayBuffer, createSyncHostService, createTerminalInputDedupeLedger, @@ -9063,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"); @@ -11585,9 +11721,393 @@ 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(); + } + }); }); +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..4419b5eea5 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,20 @@ 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 CoalescedChatEvent, + 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 +885,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 +1526,7 @@ const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set(); + const compactedMobileSyncEnvelopes = new WeakMap(); function compactChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope { const cached = compactedSyncEnvelopes.get(event); if (cached) return cached; @@ -5688,17 +5720,158 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return compacted; } + function compactMobileChatEventEnvelopeOnce(event: AgentChatEventEnvelope): AgentChatEventEnvelope { + const cached = compactedMobileSyncEnvelopes.get(event); + if (cached) return cached; + const compacted = compactChatEventEnvelopeForMobileSync(event); + compactedMobileSyncEnvelopes.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 = 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"; - 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 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); + 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()) { + const due = coalescer.flushDue(nowMs); + if (!deliverCoalescedChatEvents(peer, coalescer, due)) return; + } + } + + /** Nothing pending may outlive the subscription that produced it. */ + function discardChatEventCoalescer(peer: PeerState, sessionId: string): void { + 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; + 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 +6881,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 +8091,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 @@ -8394,6 +8579,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, }; @@ -8417,6 +8606,116 @@ 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); + // 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; + // 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 ?? "", + 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, + ...(requestedResultSequence !== null ? { resultSequence: requestedResultSequence } : {}), + ...(requestedResultTimestamp ? { resultTimestamp: requestedResultTimestamp } : {}), + ...(requestedSourceOffset !== null ? { sourceOffset: requestedSourceOffset } : {}), + ...(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 +8842,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)) { + const coalescer = chatEventCoalescerFor(peer, sessionId); + const pending = coalescer.flushAll(Date.now()); + deliverCoalescedChatEvents(peer, coalescer, pending); + } args.logger.debug("sync_host.chat_subscribe_resumed", { sessionId, sinceSeq: payload?.sinceSeq, @@ -8591,7 +8897,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 +8908,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 +8937,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 +9023,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.chatTranscriptScanOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); peer.resolvedChatTranscriptPaths.delete(sessionId); + discardChatEventCoalescer(peer, sessionId); } break; } @@ -9100,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 }]; @@ -9138,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, 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..b3b051b5c3 --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.test.ts @@ -0,0 +1,291 @@ +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 { + CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES, + 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("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 + // 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 + // 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"), + { 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..c187252f4b --- /dev/null +++ b/apps/desktop/src/main/services/chat/chatToolResultLookup.ts @@ -0,0 +1,189 @@ +import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types/chat"; +import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; +import { readTranscriptHistoryPage } from "./chatTranscriptHistoryPager"; +import { readHistoryFileRange, 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; +/** + * 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; + 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; + /** + * 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; + /** + * 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; + /** + * 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(); + if (!wantedId) return null; + 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; + 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) { + 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; + // 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; + // 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; + if (!read.hasMore) return null; + if (scannedBytes >= CHAT_TOOL_RESULT_LOOKUP_MAX_BYTES) return null; + beforeOffset = read.startOffset; + } + 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/main/services/chat/chatTranscriptEntries.test.ts b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts index b9dcf0e473..8de6291525 100644 --- a/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts +++ b/apps/desktop/src/main/services/chat/chatTranscriptEntries.test.ts @@ -158,6 +158,62 @@ 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("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" }), + ]); + + 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..beefb0d6f0 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(); @@ -170,6 +197,13 @@ export function transcriptEntriesFromEnvelopes( for (const entry of envelopes) { if (entry.sessionId !== sessionId) continue; if (entry.event.type === "user_message") { + 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. 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(); diff --git a/apps/desktop/src/shared/chatMobileSlim.test.ts b/apps/desktop/src/shared/chatMobileSlim.test.ts new file mode 100644 index 0000000000..d8aa1edc26 --- /dev/null +++ b/apps/desktop/src/shared/chatMobileSlim.test.ts @@ -0,0 +1,327 @@ +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, 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); + 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, + sourceSeq: 3, + superseded: [pending], + }]); + 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); + }); + + 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", () => { + 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("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); + }); +}); + +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..aa40a9d769 --- /dev/null +++ b/apps/desktop/src/shared/chatMobileSlim.ts @@ -0,0 +1,489 @@ +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, +): 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: + // `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); + 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 - 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, + }; +} + +/** 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; +}; + +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. + * + * `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 { + 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(progress.agentId) + ?? (progress.type === "subagent_progress" ? trimmed(progress.taskId) : null); + if (!agentKey) return null; + return { agentKey, family: progress.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; + /** 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 = { + /** + * 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[]; + /** Put progress back after the transport rejected an outbound event. */ + requeue(entries: readonly CoalescedChatEvent[]): void; + 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 pendingOutbound: CoalescedChatEvent[] = []; + 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, 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 + // 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, sourceSeq: entry.seq }; + }; + + 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); + 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: + // 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 endingEvent = event.event as AgentChatEvent; + const ending = endingEvent.type === "subagent_result" || endingEvent.type === "subagent.completed"; + const superseded: AgentChatEventEnvelope[] = []; + if (ending) { + 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. + 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; + // 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 queued; + } + 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 queued; + } + 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 [...queued, ...flushAgent(identity.agentKey, nowMs)]; + } + if (!dueAtMs.has(identity.agentKey)) { + dueAtMs.set(identity.agentKey, lastSent + intervalMs); + } + return queued; + }, + + flushDue(nowMs) { + const out = pendingOutbound.splice(0); + for (const [agentKey, due] of [...dueAtMs]) { + if (nowMs < due) continue; + out.push(...flushAgent(agentKey, nowMs)); + } + return out; + }, + + flushAll(nowMs) { + const out = pendingOutbound.splice(0); + for (const agentKey of [...pending.keys()]) { + out.push(...flushAgent(agentKey, nowMs)); + } + lastProgressIdentity = null; + 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); + } + }, + + get pendingCount() { + return pending.size + pendingOutbound.length; + }, + }; +} diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 6fdc8b07c7..3e8d566921 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; @@ -1964,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 37fb694a27..7d862366f2 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,65 @@ 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; + /** + * 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; + /** + * 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; + /** + * 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 = { + 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 +2353,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 +2436,7 @@ export type SyncEnvelope = | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope | SyncChatHistoryEnvelope + | SyncChatToolResultEnvelope | SyncBrainStatusEnvelope | SyncPrsUpdatedEnvelope | SyncRosterSubscribeEnvelope diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index d4e4bc5964..e7efec623f 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* 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 */; }; @@ -83,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 */; }; @@ -114,11 +117,13 @@ 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 */; }; 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 */; }; @@ -203,7 +208,9 @@ 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 */; }; + 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 */; }; @@ -298,6 +305,9 @@ 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 */; }; + 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 */; }; @@ -333,6 +343,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 */; @@ -437,11 +454,13 @@ 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 = ""; }; 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 = ""; }; @@ -480,6 +499,9 @@ 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 = ""; }; + 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 = ""; }; @@ -533,7 +555,9 @@ 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 = ""; }; + 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; }; @@ -552,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 = ""; }; @@ -656,6 +681,9 @@ 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 = ""; }; + 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 = ""; }; @@ -678,6 +706,13 @@ /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + EB000000000000000000UI11 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; DE00988229EE6003DBF8D8FA /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -959,6 +994,9 @@ D10000000000000000000057 /* WorkExternalSessionAffordances.swift */, 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 */, @@ -970,11 +1008,13 @@ D10000000000000000000F01 /* WorkLaneOrder.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + F10000000000000000000104 /* WorkRemoteToolResultAffordance.swift */, D10000000000000000000055 /* WorkChatPrViews.swift */, D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000AA01 /* WorkComposerAttachmentStaging.swift */, D1000000000000000000AA02 /* WorkChatAttachmentPreviewSheets.swift */, + D1000000000000000000AA03 /* WorkQueuedSteerDetailSheet.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D10000000000000000000F03 /* WorkPromptStash.swift */, D10000000000000000000F04 /* WorkContextUsageViews.swift */, @@ -1184,6 +1224,7 @@ children = ( 27A125DE2C17BA32F9291513 /* ADE.app */, E3A5721EB84321D201716BC3 /* ADETests.xctest */, + DB000000000000000000UI02 /* ADEUITests.xctest */, CCAB2414C359E971B780BF99 /* PreviewHost.app */, AA0000000000000000000002 /* ADEWidgets.appex */, AC0000000000000000000002 /* ADEClip.app */, @@ -1214,6 +1255,8 @@ isa = PBXGroup; children = ( 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */, + DB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift */, + DCF01D0000000000000003 /* WorkComposerFoldUITests.swift */, AC8000000000000000000002 /* ProjectHostRecoveryTests.swift */, AD0000000000000000000A06 /* AccountEmailAuthFlowTests.swift */, B70000000000000000000003 /* SyncRecoveryPolicyTests.swift */, @@ -1244,7 +1287,9 @@ D30000000000000000000009 /* WorkComposerTriggerDetectorTests.swift */, D30000000000000000000F31 /* WorkChipDetectorTests.swift */, D3000000000000000000000A /* WorkPromptStashTests.swift */, + DCF01D0000000000000001 /* WorkComposerFoldStateTests.swift */, D30000000000000000000F0D /* WorkToolsContractTests.swift */, + F10000000000000000000106 /* WorkChatToolResultCacheTests.swift */, D30000000000000000000F20 /* WorkProofRowModelTests.swift */, FA000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift */, FA000000000000000000C2C3 /* WorkAssistantRenderingTests.swift */, @@ -1263,6 +1308,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" */; @@ -1444,11 +1507,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; @@ -1512,6 +1583,15 @@ /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + EB000000000000000000UI10 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + EB000000000000000000UI01 /* WorkChatScrollBenchUITests.swift in Sources */, + DCF01D0000000000000004 /* WorkComposerFoldUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 73ED0BCB9618AA29B41D84DA /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -1561,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 */, @@ -1712,6 +1793,9 @@ E10000000000000000000057 /* WorkExternalSessionAffordances.swift in Sources */, 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 */, @@ -1722,11 +1806,13 @@ 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 */, 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 */, @@ -1802,7 +1888,9 @@ 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 */, + F10000000000000000000105 /* WorkChatToolResultCacheTests.swift in Sources */, D31000000000000000000F20 /* WorkProofRowModelTests.swift in Sources */, FB000000000000000000C2C2 /* WorkSessionCanonicalStateTests.swift in Sources */, FB000000000000000000C2C3 /* WorkAssistantRenderingTests.swift in Sources */, @@ -1851,6 +1939,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; @@ -1872,6 +1966,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 = { @@ -2209,6 +2333,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/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 67e491bd57..873b6913bb 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? @@ -2590,6 +2590,24 @@ 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? + /// 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, @@ -2606,7 +2624,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 +2642,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 +2662,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 +2671,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 +2689,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 +2747,15 @@ 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. + // 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 + ? max(0, rawEvent?.resultOriginalBytes ?? 0) + : nil } } @@ -2777,9 +2812,48 @@ 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 +/// 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 { @@ -4418,7 +4492,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/Services/ChatToolResultCache.swift b/apps/ios/ADE/Services/ChatToolResultCache.swift new file mode 100644 index 0000000000..715658fe6e --- /dev/null +++ b/apps/ios/ADE/Services/ChatToolResultCache.swift @@ -0,0 +1,80 @@ +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 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 + 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?, + eventTimestamp: String? = nil + ) -> String? { + 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, + eventTimestamp: eventTimestamp + ) + 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?, + 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 4aa25ad51e..f09c09ffeb 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,117 @@ final class SyncService: ObservableObject { return page } + /// 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, + eventSequence: Int? = nil, + eventTimestamp: String? = nil + ) -> String? { + chatToolResultCache.cachedFullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence, + eventTimestamp: eventTimestamp + ) + } + + /// 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, + eventSequence: Int? = nil, + eventTimestamp: String? = nil, + sourceOffset: Int? = nil + ) async throws -> String { + try await chatToolResultCache.fullToolResult( + sessionId: sessionId, + itemId: itemId, + eventSequence: eventSequence, + eventTimestamp: eventTimestamp + ) { [weak self] in + guard let self else { throw CancellationError() } + let response = try await self.fetchChatToolResult( + sessionId: sessionId, + itemId: itemId, + resultSequence: eventSequence, + resultTimestamp: eventTimestamp, + sourceOffset: sourceOffset + ) + 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) + } + } + + /// 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, + resultSequence: Int? = nil, + resultTimestamp: String? = nil, + sourceOffset: Int? = nil + ) async throws -> AgentChatToolResultResponse { + let requestId = makeRequestId() + var payload = chatSubscriptionPayload( + sessionId: sessionId, + maxBytes: nil, + 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 } + // 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 + } + // 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, + 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, @@ -13827,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, @@ -13848,7 +13960,7 @@ final class SyncService: ObservableObject { targetProjectId: scope.projectId, targetProjectRootPath: scope.rootPath, as: AgentChatEventHistoryPage.self - ) + ).stampingEnvelopeOffsets() recordChatHistoryPageCursor( requestedSessionId: sessionId, beforeOffset: beforeOffset, @@ -13899,16 +14011,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 +14115,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 +17631,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 +19494,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/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/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 7c9d6368b4..1383c666b5 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -2,6 +2,103 @@ 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 +} + +/// 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. +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. @@ -594,6 +691,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 +713,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 +787,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 +802,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 +855,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 +942,25 @@ 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" + 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/WorkChatFollowLatch.swift b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift new file mode 100644 index 0000000000..322f34ea28 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatFollowLatch.swift @@ -0,0 +1,133 @@ +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 +} + +/// 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/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index c2dfe0640f..562e58bc6c 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -171,15 +171,32 @@ struct WorkToolCardView: View, Equatable { WorkStructuredOutputBlock(title: "Arguments", text: argsText) } if let resultText = toolCard.resultText, !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 remoteResultBytes = toolCard.remoteResultBytes, + let sessionId = toolCard.sessionId { + WorkRemoteToolResultAffordance( + toolName: toolDisplayName(toolCard.toolName), + itemId: toolCard.id, + sessionId: sessionId, + resultText: resultText, + remoteResultBytes: remoteResultBytes, + 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, + sourceOffset: toolCard.resultSourceOffset + ) + } 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 + ) + } } } } @@ -877,6 +894,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" } @@ -2824,8 +2849,6 @@ struct WorkChatInfoDetailsSheet: View { let scheduledWorkPaused: Bool 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 @@ -2853,8 +2876,6 @@ struct WorkChatInfoDetailsSheet: View { scheduledWorkPaused: Bool, nextWakeAt: String?, provider: String?, - selectedTaskId: String?, - probingTaskId: String?, expandedTaskIds: Binding>, sessionModel: String? = nil, onSelect: @escaping @MainActor (WorkSubagentSnapshot) async -> Void, @@ -2868,8 +2889,6 @@ struct WorkChatInfoDetailsSheet: View { self.scheduledWorkPaused = scheduledWorkPaused self.nextWakeAt = nextWakeAt self.provider = provider - self.selectedTaskId = selectedTaskId - self.probingTaskId = probingTaskId self.sessionModel = sessionModel self._expandedTaskIds = expandedTaskIds self.onSelect = onSelect @@ -3014,7 +3033,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 @@ -3070,7 +3089,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" @@ -3189,8 +3208,6 @@ struct WorkChatInfoDetailsSheet: View { private func subagentRow(_ snapshot: WorkSubagentSnapshot) -> some View { WorkChatInfoSubagentRow( snapshot: snapshot, - selected: selectedTaskId == snapshot.taskId, - probing: probingTaskId == snapshot.taskId, expanded: expandedTaskIds.contains(snapshot.taskId), sessionModel: sessionModel, treePrefix: workSubagentTreePrefix(snapshot, in: subagents), @@ -3364,8 +3381,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,12 +3432,9 @@ 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") + Image(systemName: "chevron.right") .font(.system(size: 12, weight: .bold)) .foregroundStyle(ADEColor.textMuted) } @@ -3450,11 +3462,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) @@ -3939,11 +3951,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 { @@ -3954,9 +3968,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) } } @@ -3964,20 +3978,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 — @@ -3988,9 +3988,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 } @@ -4054,22 +4051,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) @@ -4089,15 +4075,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/WorkChatScrollBench.swift b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift new file mode 100644 index 0000000000..efa6581074 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatScrollBench.swift @@ -0,0 +1,520 @@ +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 + } + + /// 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 + } + + /// 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 + 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") + 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..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,260 +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() - 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 - } - 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 - } - - 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 - } - } - - 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 } - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - scrollPosition.scrollTo(y: y) - } - } - } - - @MainActor - func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool) { - bottomStickinessReleasedByUser = false - 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 { return } - latestPinGeneration &+= 1 - let generation = latestPinGeneration - latestPinTask?.cancel() - latestPinTask = Task { @MainActor in - guard generation == latestPinGeneration, isNearBottom, canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled, - generation == latestPinGeneration, - isNearBottom, - canWriteAutomaticScrollOffset else { return } - scrollToLatest(proxy, animated: false) - if generation == latestPinGeneration { - latestPinTask = nil - } - } - } - - @MainActor - func forcePinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { - guard canWriteAutomaticScrollOffset else { return } - 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) - 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) - } - 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 56d9dbc158..8e4956edf1 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 { @@ -147,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) } } @@ -156,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) @@ -239,14 +234,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) - withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo("pending-question-\(question.id)", anchor: .bottom) - } + transcriptScroller.scrollRowIntoView( + id: entry.id, + reason: "question-freeform-focus" + ) } }, fallbackProvider: chatSummaryContext.provider, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 74f0e83dbf..f76dbe6b86 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,11 +409,7 @@ 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). - 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 @@ -895,30 +490,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 @@ -1036,6 +613,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 } @@ -1256,130 +848,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: - return - case .extendExistingAnchorWindow: - scrollMetrics.prependAnchor?.remainingAttempts = workChatPrependAnchorAttempts - case .retireAnchor: - scrollMetrics.prependAnchor = nil - case .arm(let rowId, let 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: - return - case .retry: - anchor.remainingAttempts -= 1 - scrollMetrics.prependAnchor = anchor.remainingAttempts > 0 ? anchor : nil - 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 { return } - - 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 { @@ -1550,85 +1021,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) } } @@ -1671,10 +1228,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." ) } } @@ -1698,7 +1255,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 @@ -1713,6 +1270,7 @@ struct WorkChatSessionView: View { drafts: $steerEditDrafts, busy: actionInFlight, isLive: isLive, + capability: stagedSteerCapability, turnActive: sessionStatus == "active", onCancel: { steerId in await runSessionAction { @@ -1731,7 +1289,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 } } @@ -1741,7 +1299,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 } } @@ -1851,7 +1409,6 @@ struct WorkChatSessionView: View { } }, onOpenModelPicker: !chatSummaryContext.isAvailable - || selectedSubagentTaskId != nil || (isPersonalChat && ( !personalModelCatalogAvailable || !personalSessionUpdatesAvailable )) @@ -1867,7 +1424,7 @@ struct WorkChatSessionView: View { }, onSend: onSend, onSent: { - scrollToLatest(proxy, animated: true) + transcriptScroller.scrollToLatest(animated: true, reason: "composer-sent") } ) } @@ -1876,180 +1433,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) - 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 } - 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 - 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) { - isNearBottom = true - if timelineDragActive { - timelineDragActive = false - } - scrollToLatest(proxy, animated: true) - 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 @@ -2061,7 +1524,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 @@ -2091,7 +1554,7 @@ struct WorkChatSessionView: View { .animation(.easeInOut(duration: 0.18), value: showsComposerBadgeChips) } - composerInset(proxy: proxy) + composerInset .fixedSize(horizontal: false, vertical: true) .background(alignment: .bottom) { WorkChatComposerBackdrop() @@ -2117,27 +1580,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 @@ -2162,6 +1604,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, @@ -2175,7 +1624,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 @@ -2190,7 +1639,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 { @@ -2205,19 +1655,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 } @@ -2233,10 +1670,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") @@ -2396,14 +1838,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) ) - } + ) } } @@ -2643,25 +2082,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 @@ -2806,52 +2226,72 @@ 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) - } - } - 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) + 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) + // 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) + 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() } @@ -3121,18 +2561,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) - ) } } @@ -3192,6 +2620,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 @@ -3274,41 +2708,130 @@ 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 + } } + /// 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 + } + } + } + + 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 { + 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 pendingSend = draftState.beginPendingSend() + let originalText = pendingSend.text + 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(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. + 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 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()) + 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) } - .buttonStyle(.plain) - .accessibilityLabel("Collapse composer") - .accessibilityHint("Lowers the keyboard and shrinks the composer") - .accessibilityIdentifier("Work.Chat.Composer.Collapse") + .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") } } @@ -3316,23 +2839,22 @@ 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 { if !composerCollapsed { @@ -3355,7 +2877,9 @@ private struct WorkChatComposerDraftInput: View { onPasteImages: { images in workChatInputPasteImages(images, into: $inputAttachments) }, - maxLines: 1 + maxLines: 1, + collapsed: composerCollapsed, + onFoldSwipeDown: { applyFold(.collapse) } ) } @@ -3382,7 +2906,9 @@ private struct WorkChatComposerDraftInput: View { onPasteImages: { images in workChatInputPasteImages(images, into: $inputAttachments) }, - maxLines: composerMaxLines + maxLines: composerMaxLines, + collapsed: composerCollapsed, + onFoldSwipeDown: { applyFold(.collapse) } ) if showInterrupt && hasSendableDraftOrAttachment { @@ -3418,6 +2944,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() } @@ -3462,23 +2998,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, @@ -3493,6 +3024,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 @@ -3524,21 +3057,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 @@ -3554,7 +3081,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, @@ -3619,10 +3150,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 { @@ -3635,10 +3163,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) } ) } } @@ -3655,10 +3180,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 { @@ -4076,6 +3598,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 { @@ -4089,6 +3627,20 @@ final class WorkChatComposerDraftState: ObservableObject { /// persist" (the key is unresolved), which is the safe default. private var persistenceKey = "" private var autosaveTask: Task? + /// 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) @@ -4132,14 +3684,16 @@ final class WorkChatComposerDraftState: ObservableObject { func flushDraft() { autosaveTask?.cancel() autosaveTask = nil - guard !persistenceKey.isEmpty else { return } + guard !persistenceKey.isEmpty, !hasPendingSendForCurrentKey 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, !hasPendingSendForCurrentKey else { return } autosaveTask?.cancel() let key = persistenceKey let value = text @@ -4154,27 +3708,58 @@ 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() -> WorkChatComposerPendingSend { let value = trimmedText isFocused = false - 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) + var issued = WorkChatComposerPendingSend(token: nil, key: "", text: value) + if !persistenceKey.isEmpty { + issued = WorkChatComposerPendingSend(token: UUID(), key: persistenceKey, text: value) + pendingSends.append(issued) + WorkComposerDraftStore.save(value, for: persistenceKey) + } + text = "" + 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(_ 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, !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 && pendingSends.contains { $0.key == key } } func restoreUnsentText(_ value: String) { @@ -4209,8 +3794,27 @@ 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 + /// The field's own downward scroll pan asks the composer to fold. + var onFoldSwipeDown: (() -> Void)? = nil @State private var measuredHeight: CGFloat = 24 + /// 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, foldedHeight) : measuredHeight + } + var body: some View { WorkComposerTextView( draftState: draftState, @@ -4220,9 +3824,13 @@ private struct WorkChatComposerTextField: View { measuredHeight: $measuredHeight, acceptsPastedImages: acceptsPastedImages, onPasteImages: onPasteImages, - maxLines: maxLines + maxLines: maxLines, + collapsed: collapsed, + onFoldSwipeDown: onFoldSwipeDown ) .frame(height: measuredHeight) + .frame(height: displayHeight, alignment: .top) + .clipped() .frame(maxWidth: .infinity, alignment: .leading) } } @@ -4236,8 +3844,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( @@ -4256,23 +3866,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/WorkChatTranscriptCollectionView.swift b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift new file mode 100644 index 0000000000..9b7ed19d06 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatTranscriptCollectionView.swift @@ -0,0 +1,820 @@ +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 + /// 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. +/// +/// 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) + } + + /// 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 { key, _ in + key.contentRevision == contentRevision && live[key.rowId] == key.revision + } + } +} + +// 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 + /// 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 + + 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, + contentRevision: 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 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 + 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: Dictionary(rows.map { ($0.id, $0.revision) }, uniquingKeysWith: { first, _ in first }), + contentRevision: revision + ) + + 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. + if !restoreAnchorIfMoved(anchor, reason: "anchor-restore") { + liveAnchor = captureAnchor() + } + } + lastContentHeight = collectionView.contentSize.height + } + + // MARK: Anchoring + + 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 + } + + 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], + 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 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 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 + } + + // 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 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 + 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 { + // `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 + 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 { + // Anchored on the row, which survives a re-measure the saved offset + // 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") + } + } + } + + 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. 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() + } + + // 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/WorkComposerAttachmentStaging.swift b/apps/ios/ADE/Views/Work/WorkComposerAttachmentStaging.swift index 1c1a047931..2f4428409d 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. /// @@ -151,8 +258,10 @@ func workChatAttachmentSignature(_ attachments: [WorkChatInputAttachment]) -> St /// 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() @@ -161,6 +270,8 @@ final class WorkComposerAttachmentUploads { var task: Task var ref: AgentChatFileRef? var failure: String? + var abandoned: Bool + var settled: Bool var startedAt: Date } @@ -183,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 @@ -203,10 +317,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,10 +329,29 @@ final class WorkComposerAttachmentUploads { targetProjectRootPath: projectRootPath ) } - insert(id, Entry(task: task, ref: nil, failure: nil, startedAt: Date())) + } + + /// 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, 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 @@ -225,19 +359,62 @@ 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 } } } - /// 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.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) { + 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 + entries[id]?.abandoned = true + return .abandoned + } } func release(_ ids: [UUID]) { @@ -252,61 +429,14 @@ 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 } } } -/// 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 — @@ -322,10 +452,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? { @@ -389,6 +554,7 @@ enum WorkComposerDraftAttachmentCache { } totalBytes += data.count stored.append(StoredFile( + id: attachment.id, name: name, filename: attachment.filename, mimeType: attachment.mimeType, @@ -405,6 +571,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/ADE/Views/Work/WorkComposerTypedTriggers.swift b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift index 8d937a748b..0318fcdd50 100644 --- a/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift +++ b/apps/ios/ADE/Views/Work/WorkComposerTypedTriggers.swift @@ -1667,6 +1667,16 @@ 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 + /// 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 @@ -1697,6 +1707,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 @@ -1718,6 +1731,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) @@ -1751,6 +1765,7 @@ struct WorkComposerTextView: UIViewRepresentable { } context.coordinator.applyFocusRequest(draftState.isFocused, to: textView) context.coordinator.updateHeight() + context.coordinator.pinFoldedOffset(on: textView) } @MainActor @@ -1767,6 +1782,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 @@ -1779,6 +1798,52 @@ 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 + } + } + + /// 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/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 9ff9e4cd7c..1a4f5b4371 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -875,7 +875,17 @@ func makeWorkChatTranscript(from entries: [AgentChatEventEnvelope]) -> [WorkChat apiErrorStatus: entry.apiErrorStatus, isLegacySubagentCompletedFrame: entry.isLegacySubagentCompletedFrame, stopSource: entry.stopSource, - stopReason: entry.stopReason + stopReason: entry.stopReason, + 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 + }(), + sourceOffset: entry.sourceOffset ) } .sorted(by: workChatEnvelopeOrderedBefore) @@ -982,7 +992,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 +1048,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. @@ -1337,12 +1364,21 @@ 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) { + // `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, + let status = envelope.commandLifecycleStatus, + status != "queued" { + resolvedSteerIds.insert(steerId) + } switch envelope.event { case .userMessage(let text, _, _, let steerId, let deliveryState, _): if let steerId, deliveryState == "queued" { @@ -2163,6 +2199,15 @@ 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, + let status = envelope.commandLifecycleStatus, + status != "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 e27d752971..76c6ba949c 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 @@ -80,6 +80,21 @@ 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? + /// 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, @@ -90,7 +105,11 @@ struct WorkToolCardModel: Identifiable, Equatable { argsText: String?, resultText: String?, webSearchActions: [CodexWebSearchAction]? = nil, - webSearchResults: [CodexWebSearchResult]? = nil + webSearchResults: [CodexWebSearchResult]? = nil, + remoteResultBytes: Int? = nil, + sessionId: String? = nil, + resultSequence: Int? = nil, + resultSourceOffset: Int? = nil ) { self.id = id self.toolName = toolName @@ -101,6 +120,10 @@ struct WorkToolCardModel: Identifiable, Equatable { self.resultText = resultText self.webSearchActions = webSearchActions self.webSearchResults = webSearchResults + self.remoteResultBytes = remoteResultBytes + self.sessionId = sessionId + self.resultSequence = resultSequence + self.resultSourceOffset = resultSourceOffset } } @@ -179,7 +202,7 @@ struct WorkPendingApprovalModel: Identifiable, Equatable { var title: String? = nil } -struct WorkPendingQuestionOption: Equatable { +struct WorkPendingQuestionOption: Hashable { let label: String let value: String let description: String? @@ -188,7 +211,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] @@ -202,7 +225,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 @@ -408,7 +431,7 @@ extension WorkPendingPlanApprovalModel { } } -struct WorkPendingPermissionModel: Identifiable, Equatable { +struct WorkPendingPermissionModel: Identifiable, Hashable { let id: String let tool: String let description: String @@ -419,7 +442,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 @@ -474,7 +497,7 @@ struct WorkModelSelectionChoice: Codable, Equatable { } } -struct WorkPendingModelSelectionModel: Identifiable, Equatable { +struct WorkPendingModelSelectionModel: Identifiable, Hashable { let id: String let role: String let tag: String @@ -492,7 +515,7 @@ struct WorkPendingModelSelectionModel: Identifiable, Equatable { } } -struct WorkUsageSummary: Equatable { +struct WorkUsageSummary: Hashable { var turnCount: Int var inputTokens: Int var outputTokens: Int @@ -619,6 +642,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 @@ -773,7 +826,7 @@ struct WorkCompletionArtifactModel: Equatable { let reference: String? } -struct WorkCodexStallContext: Equatable { +struct WorkCodexStallContext: Hashable { let reason: String let detectedAt: String? let turnStartedAt: String? @@ -791,7 +844,7 @@ struct WorkUserMessageResolution: Equatable { let replacementMessageId: String? } -struct WorkCodexRecoveryReceipt: Equatable { +struct WorkCodexRecoveryReceipt: Hashable { let action: String let state: String let automatic: Bool @@ -801,7 +854,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 @@ -812,7 +865,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 @@ -848,20 +901,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 @@ -872,7 +925,7 @@ struct WorkAdeCardProgress: Equatable { } } -struct WorkAdeCardAction: Equatable { +struct WorkAdeCardAction: Hashable { let id: String let label: String let isPrimary: Bool @@ -882,7 +935,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?) @@ -904,7 +957,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. @@ -1039,6 +1092,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 @@ -1097,7 +1225,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) @@ -1127,7 +1255,7 @@ enum WorkToolGroupMember: Equatable, Identifiable { } } -struct WorkToolGroupModel: Identifiable, Equatable { +struct WorkToolGroupModel: Identifiable, Hashable { let id: String let members: [WorkToolGroupMember] @@ -1138,7 +1266,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 @@ -1148,21 +1276,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 @@ -1217,7 +1345,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 @@ -1256,7 +1384,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 @@ -1278,7 +1406,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. @@ -1466,7 +1594,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 @@ -1677,6 +1805,20 @@ 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? + /// 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? + /// 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, @@ -1692,7 +1834,11 @@ struct WorkChatEnvelope: Identifiable, Equatable { apiErrorStatus: Int? = nil, isLegacySubagentCompletedFrame: Bool = false, stopSource: String? = nil, - stopReason: String? = nil + stopReason: String? = nil, + toolResultFullBytes: Int? = nil, + commandLifecycleStatus: String? = nil, + commandLifecycleSteerId: String? = nil, + sourceOffset: Int? = nil ) { self.sessionId = sessionId self.timestamp = timestamp @@ -1708,6 +1854,10 @@ struct WorkChatEnvelope: Identifiable, Equatable { self.isLegacySubagentCompletedFrame = isLegacySubagentCompletedFrame self.stopSource = stopSource self.stopReason = stopReason + 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 9b157575e1..b885b3912f 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -892,7 +892,14 @@ 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, + 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/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 220baf748d..604b04611e 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -1172,6 +1172,9 @@ 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 /// Xcode scheme editor both use for launch arguments. @@ -1220,16 +1223,78 @@ struct ADEPreviewScreenHost: View { initialArtifactId: "proof-1", onLoadArtifact: { _ in } ) + case .chatScroll: + WorkChatScrollBenchScreen(options: .fromLaunchArguments()) case .tools: WorkToolsSheet( laneId: WorkProofPreviewData.laneId, 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 +1313,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) diff --git a/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift new file mode 100644 index 0000000000..206731a290 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkQueuedSteerDetailSheet.swift @@ -0,0 +1,247 @@ +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))." + return "\(staged) \(workQueuedSteerDisposition(capability: capability, turnActive: turnActive).detailText)" + } + + private var sendNowRow: some View { + optionRow( + systemImage: "arrow.turn.down.right", + tint: ADEColor.accent, + title: "Send now", + description: "Send it now, ahead of the current 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/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift new file mode 100644 index 0000000000..c5043f7948 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkRemoteToolResultAffordance.swift @@ -0,0 +1,206 @@ +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? + /// 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? + /// 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 + @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 + } + + /// 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 { + let sequence = eventSequence.map(String.init) ?? "latest" + let timestamp = eventTimestamp.flatMap { $0.isEmpty ? nil : $0 } ?? "-" + return "\(sessionId)|\(itemId)|\(sequence)|\(timestamp)" + } + + 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) { + // 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) + .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, + eventTimestamp: eventTimestamp, + sourceOffset: sourceOffset + ) + 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, + eventTimestamp: eventTimestamp + ) + } +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index faf3daa7ec..c4136ac88c 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 { @@ -888,14 +884,8 @@ 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 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 @@ -1073,12 +1063,6 @@ struct WorkSessionDestinationView: View { artifactsRenderSignature = workArtifactSummariesRenderSignature(next) } - @MainActor - func setSubagentTranscript(_ next: [WorkChatEnvelope]) { - subagentTranscript = next - subagentTranscriptRenderSignature &+= 1 - } - @MainActor func refreshArtifactContentRenderSignature() { artifactContentRenderSignature = workLoadedArtifactContentRenderSignature(artifactContent) @@ -1097,9 +1081,6 @@ struct WorkSessionDestinationView: View { } var sessionDestinationNavigationTitle: String { - if let subagentView { - return subagentView.name - } if let navigationTitleOverride { return navigationTitleOverride } @@ -1136,14 +1117,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 @@ -1234,24 +1208,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() } }, @@ -1461,8 +1417,6 @@ struct WorkSessionDestinationView: View { scheduledWorkPaused: composerChatSummary?.scheduledWorkPaused == true, nextWakeAt: composerChatSummary?.nextWakeAt, provider: subagentProvider, - selectedTaskId: subagentView?.taskId, - probingTaskId: probingSubagentTaskId, expandedTaskIds: $expandedSubagentDetailIds, sessionModel: composerChatSummary?.model, onSelect: handleSubagentSelection, @@ -1681,9 +1635,6 @@ struct WorkSessionDestinationView: View { .task(id: pollingKey) { await pollIfNeeded() } - .task(id: selectedSubagentPollingKey) { - await pollSelectedSubagentTranscriptIfNeeded() - } .task(id: cursorCloudMirrorWatchKey) { let watchId = sessionId let agentId = composerChatSummary?.cursorCloudAgentId? @@ -1716,8 +1667,7 @@ struct WorkSessionDestinationView: View { refreshChatInfoSnapshots() } .onChange(of: chatInfoPresented) { _, presented in - guard presented else { return } - Task { await refreshRemoteSubagentSnapshots() } + handleChatInfoPresentationChange(presented) } .onDisappear { chatDestinationVisible = false @@ -1880,36 +1830,21 @@ 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 - ) + let prPolicy = WorkChatLanePrPolicy(showsLaneActions: showsLaneActions) let chatPrBadge: WorkChatPrBadgeModel? = prPolicy.rendersPrBadge ? workChatPrBadgeModel( tag: chatDisplayPrTag, @@ -1919,9 +1854,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 @@ -1946,11 +1879,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. @@ -1988,7 +1919,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", @@ -2009,17 +1940,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, @@ -2036,8 +1967,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, @@ -2079,23 +2010,21 @@ 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, // 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() } }, onForkChatInLane: { let modelId = (composerChatSummary?.modelId ?? composerChatSummary?.model ?? "") .trimmingCharacters(in: .whitespacesAndNewlines) @@ -2127,15 +2056,14 @@ struct WorkSessionDestinationView: View { || syncService.canInvokeRemoteAction("personalChats.modelCatalog"), personalSessionUpdatesAvailable: !personalChat || syncService.canInvokeRemoteAction("personalChats.updateSession"), - onRecoverCodexTurn: workChatCodexRecoveryAvailable( - hostSupportsRecovery: supportsRecovery, - viewingSubagent: viewingSubagent - ) ? recoverCodexTurn : nil, - onRunUnprocessedMessage: !viewingSubagent && supportsUnprocessedResolution + onRecoverCodexTurn: workChatCodexRecoveryAvailable(hostSupportsRecovery: supportsRecovery) + ? recoverCodexTurn + : nil, + onRunUnprocessedMessage: supportsUnprocessedResolution ? runUnprocessedMessage : nil, - onEditUnprocessedMessage: !viewingSubagent ? editUnprocessedMessage : nil, - onDismissUnprocessedMessage: !viewingSubagent && supportsUnprocessedResolution + onEditUnprocessedMessage: editUnprocessedMessage, + onDismissUnprocessedMessage: supportsUnprocessedResolution ? dismissUnprocessedMessage : nil, transcriptLoadState: transcriptLoadState, @@ -2150,13 +2078,13 @@ 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) } + /// 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 { @@ -2195,13 +2123,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)" } @@ -2585,9 +2506,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 +3116,7 @@ struct WorkSessionDestinationView: View { ) let canonicalLiveTranscript: [WorkChatEnvelope] if shouldPreferFallbackTranscript { - canonicalLiveTranscript = liveTranscript.filter { envelope in - workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) - } + canonicalLiveTranscript = workChatIdleCanonicalEventTranscript(liveTranscript) } else { canonicalLiveTranscript = liveTranscript } @@ -3327,17 +3244,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 @@ -3390,32 +3296,6 @@ struct WorkSessionDestinationView: View { } } - @MainActor - func clearSubagentView() { - subagentView = nil - setSubagentTranscript([]) - probingSubagentTaskId = nil - } - - @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 } @@ -3442,80 +3322,29 @@ 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. + /// + /// 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 @@ -3527,38 +3356,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 } @@ -3661,138 +3458,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/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index f6673b508d..90d5e0b551 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -1091,6 +1091,27 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { turnId: turnId ) ) + case "command_lifecycle": + let status = optionalString(eventDict["status"]) + // 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( + 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 +1143,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"]) ?? optionalString(eventDict["steer_id"]) + : nil ) } .sorted(by: workChatEnvelopeOrderedBefore) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ddf05d5374..84874ed566 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -16806,426 +16806,127 @@ final class ADETests: XCTestCase { ), 0) } - // MARK: - Work chat transcript scroll policy + // MARK: - Work chat transcript follow latch + + /// 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) + + // 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) + + state = workChatFollowLatch(state, .userScrollBegin) + XCTAssertFalse(state.following) + XCTAssertTrue(state.inUserSession) + } + + 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 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)) - - 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)) + let settledAtEnd = workChatFollowLatch(away, .userScrollEnd(isAtEnd: true)) + XCTAssertTrue(settledAtEnd.following) + XCTAssertFalse(settledAtEnd.inUserSession) } - 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 + /// 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 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 testSendingAndJumpingAlwaysFollow() { + let reading = WorkChatFollowState(following: false, inUserSession: false) + XCTAssertTrue(workChatFollowLatch(reading, .sendMessage).following) + XCTAssertTrue(workChatFollowLatch(reading, .jumpToLatest).following) + XCTAssertFalse(workChatFollowLatch(reading, .jumpToLatest).inUserSession) } - 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) - ) - } + /// 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 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 - ) - ) + let flinging = WorkChatFollowState(following: false, inUserSession: true) + XCTAssertFalse(workChatFollowLatch(flinging, .disclosureSettled(isAtEnd: true)).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 - ) - ) - } + // MARK: - Work chat transcript anchor restore - 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 - ) + 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 testShortTranscriptRendersFromTheTop() { - XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: true), .topLeading) - XCTAssertEqual(workChatTranscriptContentAlignment(contentFitsViewport: false), .bottomLeading) + func testAnchorRestoresWhenTheRowMovedAndTheOffsetIsOursToWrite() { + XCTAssertTrue(shouldRestore()) + XCTAssertTrue(shouldRestore(currentRowMinY: 800)) } - 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 - ) + /// 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)) } - 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 - ) + /// 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)) } - 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 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 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 - ) - } - - 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 +17074,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, @@ -17476,48 +17164,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.") + /// 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) } - XCTAssertEqual(assistantText, "Done") - XCTAssertEqual(itemId, "a-1") + XCTAssertFalse(workResolveSubagentCapability(provider: "unknown-provider").canList) + XCTAssertFalse(workResolveSubagentCapability(provider: nil).canList) } func testWorkChatTranscriptUsesMessageIdToSplitAssistantMessages() { @@ -20657,15 +20311,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 { @@ -20931,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 — @@ -21024,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( @@ -21052,6 +20774,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/apps/ios/ADETests/WorkAssistantRenderingTests.swift b/apps/ios/ADETests/WorkAssistantRenderingTests.swift index 8d0063e041..b5d2088099 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() { @@ -681,3 +675,172 @@ 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") + } + + /// 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, + 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/WorkChatToolResultCacheTests.swift b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift new file mode 100644 index 0000000000..17a6d78ad8 --- /dev/null +++ b/apps/ios/ADETests/WorkChatToolResultCacheTests.swift @@ -0,0 +1,103 @@ +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" + ) + } + + /// 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" + ) + } +} diff --git a/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift b/apps/ios/ADETests/WorkComposerDraftAttachmentTests.swift index d572b2a454..a940214c89 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 @@ -233,6 +391,343 @@ 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 + var release: CheckedContinuation? + defer { + release?.resume() + uploads.release([id]) + } + + uploads.begin(id: id) { + // Never lands: the wedged leg this bug was made of. + 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 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 + 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 retryAfterTheDeadline = await uploads.resolve(id, timeoutNanoseconds: 50_000_000) + XCTAssertEqual( + retryAfterTheDeadline, + .stageInline, + "past the deadline the original leg is presumed dead and the retry may upload" + ) + + 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 + /// 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" + + 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. + 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(firstSend, sent: false) + XCTAssertFalse(draft.isSendInFlight(for: draftKey)) + XCTAssertEqual(WorkComposerDraftStore.load(draftKey), "look at this screenshot") + + draft.text = "look at this screenshot" + let retry = draft.beginPendingSend() + draft.finishPendingSend(retry, 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" + let firstSend = draft.beginPendingSend() + + draft.text = "second message" + draft.flushDraft() + XCTAssertEqual( + WorkComposerDraftStore.load(draftKey), + "first message", + "the unconfirmed send owns the stored draft" + ) + + draft.finishPendingSend(firstSend, 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) + ) + } + + /// 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. + 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/WorkComposerFoldStateTests.swift b/apps/ios/ADETests/WorkComposerFoldStateTests.swift new file mode 100644 index 0000000000..790d5050e5 --- /dev/null +++ b/apps/ios/ADETests/WorkComposerFoldStateTests.swift @@ -0,0 +1,149 @@ +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)) + } + + // 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), + intent: .focusChanged(false), + canCompose: true + ) + XCTAssertEqual(next, WorkComposerFoldState(collapsed: true, focused: false)) + } +} diff --git a/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift b/apps/ios/ADETests/WorkComposerTriggerDetectorTests.swift index 8395410ea3..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.consumeSendableText(), "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.consumeSendableText(), "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.") diff --git a/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift new file mode 100644 index 0000000000..817564e342 --- /dev/null +++ b/apps/ios/ADEUITests/WorkChatScrollBenchUITests.swift @@ -0,0 +1,203 @@ +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 + } + + 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 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 { + slowDrag(app, fromY: 0.25, toY: 0.72, velocity: 320) + 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 { + slowDrag(app, fromY: 0.25, toY: 0.75, velocity: 320) + usleep(500_000) + } + sleep(32) + } + + // (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 { + app.windows.firstMatch + .coordinate(withNormalizedOffset: CGVector(dx: 0.4, dy: 0.88)) + .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) + + // (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() + } + sleep(5) + } + + // (h) Cold open: where does the transcript land. + func testCaseH_initialLanding() { + _ = launch() + sleep(12) + } +} diff --git a/apps/ios/ADEUITests/WorkComposerFoldUITests.swift b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift new file mode 100644 index 0000000000..d21988ceed --- /dev/null +++ b/apps/ios/ADEUITests/WorkComposerFoldUITests.swift @@ -0,0 +1,139 @@ +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"] + } + + /// 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 { + (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) + } + + /// 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 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 + /// 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 = 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") + } + + 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 visibleFieldHeight(app) < grownHeight - 40 { + 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(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: 24, + "the fold moved the reader's place in the thread" + ) + + // The draft survives the fold, and is still the same message. + // 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" + ) + } +} 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. | 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 51f50f7ea3..c4c37a98c4 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -176,6 +176,87 @@ 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 + +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, +and they open exactly as they always have. + ## Project layout > The same Xcode project also ships `apps/ios/ADE/Debug/ADEInspectorKit/`, @@ -776,8 +857,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 @@ -785,48 +868,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 — @@ -2365,6 +2479,37 @@ 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`). 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 +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 @@ -2406,8 +2551,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 @@ -3816,9 +3961,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 +4127,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 @@ -4097,11 +4272,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 @@ -4114,6 +4288,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