diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index fe083b2a0..48b592c7f 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3402,6 +3402,23 @@ describe("adeRpcServer", () => { it("scopes PTY and terminal ADE actions to the caller's lane or chat", async () => { const fixture = createRuntime(); + const getChatEventHistory = vi.fn(async (sessionId: string) => ({ + sessionId, + events: [], + sessionFound: true, + })); + const getChatEventHistoryPage = vi.fn(async ( + sessionId: string, + options: { beforeOffset: number }, + ) => ({ + sessionId, + events: [], + startOffset: options.beforeOffset, + hasMore: options.beforeOffset > 0, + sessionFound: true, + })); + (fixture.runtime.agentChatService as any).getChatEventHistory = getChatEventHistory; + (fixture.runtime.agentChatService as any).getChatEventHistoryPage = getChatEventHistoryPage; const ownChat = { id: "chat-1", laneId: "lane-1", chatSessionId: "chat-1" }; const ownTerminal = { id: "terminal-1", laneId: "lane-1", ptyId: "pty-1", chatSessionId: "chat-1" }; const otherTerminal = { id: "terminal-2", laneId: "lane-2", ptyId: "pty-2", chatSessionId: "chat-2" }; @@ -3462,6 +3479,22 @@ describe("adeRpcServer", () => { expect(deniedChatRead.isError).toBe(true); expect(fixture.runtime.agentChatService.getChatTranscript).not.toHaveBeenCalled(); + const deniedChatHistory = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getChatEventHistory", + args: { sessionId: "chat-2", maxBytes: 65_536 }, + }); + expect(deniedChatHistory.isError).toBe(true); + expect(getChatEventHistory).not.toHaveBeenCalled(); + + const deniedChatHistoryPage = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getChatEventHistoryPage", + args: { sessionId: "chat-2", beforeOffset: 4_096, maxBytes: 65_536 }, + }); + expect(deniedChatHistoryPage.isError).toBe(true); + expect(getChatEventHistoryPage).not.toHaveBeenCalled(); + const deniedChatSend = await callTool(handler, "run_ade_action", { domain: "chat", action: "sendMessage", @@ -3481,6 +3514,27 @@ describe("adeRpcServer", () => { limit: 10, }); + const ownChatHistory = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getChatEventHistory", + args: { maxBytes: 65_536 }, + }); + expect(ownChatHistory?.isError).toBeUndefined(); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { + maxBytes: 65_536, + }); + + const ownChatHistoryPage = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getChatEventHistoryPage", + args: { beforeOffset: 4_096, maxBytes: 65_536 }, + }); + expect(ownChatHistoryPage?.isError).toBeUndefined(); + expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-1", { + beforeOffset: 4_096, + maxBytes: 65_536, + }); + const ownChatSend = await callTool(handler, "run_ade_action", { domain: "chat", action: "sendMessage", @@ -4258,6 +4312,35 @@ describe("adeRpcServer", () => { expect(fixture.runtime.agentChatService.cancelScheduledWork).not.toHaveBeenCalled(); }); + it("keeps explicit raw chat history available to unbound ade CLI callers", async () => { + const fixture = createRuntime(); + const getChatEventHistoryPage = vi.fn(async ( + sessionId: string, + options: { beforeOffset: number }, + ) => ({ + sessionId, + events: [], + startOffset: options.beforeOffset, + hasMore: options.beforeOffset > 0, + sessionFound: true, + })); + (fixture.runtime.agentChatService as any).getChatEventHistoryPage = getChatEventHistoryPage; + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + await initialize(handler, { callerId: "ade-cli:4242", role: "agent" }); + + const historyPage = await callTool(handler, "run_ade_action", { + domain: "chat", + action: "getChatEventHistoryPage", + args: { sessionId: "chat-2", beforeOffset: 4_096, maxBytes: 65_536 }, + }); + + expect(historyPage?.isError).toBeUndefined(); + expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-2", { + beforeOffset: 4_096, + maxBytes: 65_536, + }); + }); + it("invokes review.startRun through ADE actions without dropping unlimited budgets", async () => { const fixture = createRuntime(); const startArgs = { diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 55a15f862..b99640365 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2399,6 +2399,8 @@ function scopeTerminalAdeActionArgs( const SCOPED_CHAT_ACTIONS = new Set([ "readTranscript", + "getChatEventHistory", + "getChatEventHistoryPage", "sendMessage", "createScheduledWork", "listScheduledWork", diff --git a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts index a82a9b9c8..c515fa871 100644 --- a/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts +++ b/apps/ade-cli/src/services/sync/brainProjectActionsSyncHandler.ts @@ -4,6 +4,7 @@ import { randomBytes, timingSafeEqual } from "node:crypto"; import type { RawData, WebSocket } from "ws"; import type { AgentChatEventEnvelope, + AgentChatEventHistoryPage, CloneProjectInput, CreateProjectInput, ListMyGitHubReposInput, @@ -11,6 +12,7 @@ import type { PersonalChatScopeContract, SyncChatSubscribePayload, SyncChatSubscribeSnapshotPayload, + SyncChatHistoryRequestPayload, SyncChatUnsubscribePayload, SyncCommandPayload, SyncRemoteCommandDescriptor, @@ -172,6 +174,52 @@ function optionalString(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } +function unavailableChatHistoryPage( + sessionId: string, + beforeOffset: number, +): AgentChatEventHistoryPage { + return { + sessionId, + events: [], + startOffset: beforeOffset, + hasMore: beforeOffset > 0, + sessionFound: false, + unavailable: true, + }; +} + +function normalizeChatHistoryPage( + value: unknown, + sessionId: string, + beforeOffset: number, +): AgentChatEventHistoryPage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return unavailableChatHistoryPage(sessionId, beforeOffset); + } + const record = value as Record; + const startOffset = typeof record.startOffset === "number" && Number.isFinite(record.startOffset) + ? Math.max(0, Math.floor(record.startOffset)) + : null; + if ( + optionalString(record.sessionId) !== sessionId + || !Array.isArray(record.events) + || startOffset == null + || startOffset > beforeOffset + || typeof record.hasMore !== "boolean" + || typeof record.sessionFound !== "boolean" + ) { + return unavailableChatHistoryPage(sessionId, beforeOffset); + } + return { + sessionId, + events: record.events as AgentChatEventEnvelope[], + startOffset, + hasMore: record.hasMore, + sessionFound: record.sessionFound, + ...(record.unavailable === true ? { unavailable: true } : {}), + }; +} + function normalizePeerMetadata(value: unknown): SyncPeerMetadata | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; const record = value as Record; @@ -813,7 +861,7 @@ export function createBrainProjectActionsSyncHandler( } const maxBytes = typeof payload.maxBytes === "number" && Number.isFinite(payload.maxBytes) ? Math.max(1_024, Math.min(2_000_000, Math.floor(payload.maxBytes))) - : 2_000_000; + : 256 * 1_024; // Capture the tail point before reading history. Events committed // during the snapshot can then be replayed (and client-deduped), but // can never fall into the gap between history collection and offset. @@ -824,7 +872,12 @@ export function createBrainProjectActionsSyncHandler( const history = (await args.personalChatScope.call("getEventHistory", { sessionId, maxBytes, - })).result as { events?: AgentChatEventEnvelope[]; truncated?: boolean }; + })).result as { + events?: AgentChatEventEnvelope[]; + truncated?: boolean; + tailStartOffset?: number | null; + hasOlderHistory?: boolean; + }; if (!isCurrent()) return; const turnActive = await args.personalChatScope.isTurnActive(sessionId); if (!isCurrent()) return; @@ -833,11 +886,49 @@ export function createBrainProjectActionsSyncHandler( sessionId, capturedAt: nowIso(), truncated: history.truncated === true, + tailStartOffset: history.tailStartOffset ?? 0, + hasOlderHistory: history.hasOlderHistory + ?? (history.truncated === true && (history.tailStartOffset ?? 0) > 0), + cursorKind: "byte", events: history.events ?? [], turnActive, } satisfies SyncChatSubscribeSnapshotPayload, envelope.requestId); break; } + case "chat_history": { + const payload = envelope.payload as SyncChatHistoryRequestPayload | null; + const sessionId = optionalString(payload?.sessionId) ?? ""; + const beforeOffset = typeof payload?.beforeOffset === "number" && Number.isFinite(payload.beforeOffset) + ? Math.max(0, Math.floor(payload.beforeOffset)) + : 0; + if ( + !sessionId + || payload?.chatScope !== "personal" + || !args.personalChatScope + || !peer.personalChatSubscriptions.has(sessionId) + ) { + send(peer.ws, "chat_history", unavailableChatHistoryPage(sessionId, beforeOffset), envelope.requestId); + break; + } + let page = unavailableChatHistoryPage(sessionId, beforeOffset); + try { + const rawPage = (await args.personalChatScope.call("getEventHistoryPage", { + sessionId, + beforeOffset, + ...(typeof payload.maxBytes === "number" ? { maxBytes: payload.maxBytes } : {}), + })).result; + page = normalizeChatHistoryPage(rawPage, sessionId, beforeOffset); + } catch (error) { + args.logger.warn("sync_brain.chat_history_failed", { + sessionId, + beforeOffset, + error: error instanceof Error ? error.message : String(error), + }); + } + if (!isCurrent()) return; + send(peer.ws, "chat_history", page, envelope.requestId); + break; + } case "chat_unsubscribe": { const payload = envelope.payload as SyncChatUnsubscribePayload | null; if (payload?.chatScope === "personal") { diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index de82d45a8..d2498292e 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -1025,23 +1025,40 @@ describe("brain project actions fallback handler", () => { keyMaterialProvider: () => null, }); credentialStore.setSync("test.bootstrap", "bootstrap-token"); + const logger = createDiscoveryLogger(); const personalChatScope: PersonalChatScopeContract = { capabilities: vi.fn(() => ({ version: 1 as const, actions: ["list", "terminalCreate", "saveTempAttachment"], })), - call: vi.fn(async (action: unknown) => ({ - action: action as PersonalChatAction, - result: action === "getEventHistory" - ? { events: [], truncated: false } - : [{ sessionId: "personal-1", surface: "personal" }], - })), + call: vi.fn(async (action: unknown, args: unknown) => { + if ( + action === "getEventHistoryPage" + && (args as { beforeOffset?: number } | null)?.beforeOffset === 4_096 + ) { + throw new Error("personal history unavailable"); + } + return { + action: action as PersonalChatAction, + result: action === "getEventHistory" + ? { events: [], truncated: false } + : action === "getEventHistoryPage" + ? { + sessionId: "personal-1", + events: [], + startOffset: 1_024, + hasMore: true, + sessionFound: true, + } + : [{ sessionId: "personal-1", surface: "personal" }], + }; + }), streamEvents: vi.fn(async () => ({ events: [], nextCursor: 0, hasMore: false })), transcriptPath: vi.fn(async () => transcriptPath), isTurnActive: vi.fn(async () => true), }; const handler = createBrainProjectActionsSyncHandler({ - logger: createDiscoveryLogger(), + logger, projectCatalogProvider: { listProjects: vi.fn(async () => ({ projects: [] })), prepareProjectConnection: vi.fn(async () => ({ ok: false, message: "No projects." })), @@ -1121,6 +1138,67 @@ describe("brain project actions fallback handler", () => { events: [], turnActive: true, }); + + client.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "personal-history", + payload: { + sessionId: "personal-1", + chatScope: "personal", + beforeOffset: 2_048, + }, + })); + const historyPage = await waitForEnvelope(envelopes, "chat_history", "personal-history"); + expect(historyPage.payload).toMatchObject({ + sessionId: "personal-1", + startOffset: 1_024, + hasMore: true, + sessionFound: true, + }); + + client.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "personal-history-failed", + payload: { + sessionId: "personal-1", + chatScope: "personal", + beforeOffset: 4_096, + }, + })); + const failedHistoryPage = await waitForEnvelope( + envelopes, + "chat_history", + "personal-history-failed", + ); + expect(failedHistoryPage.payload).toMatchObject({ + sessionId: "personal-1", + startOffset: 4_096, + unavailable: true, + }); + expect(logger.warn).toHaveBeenCalledWith("sync_brain.chat_history_failed", { + sessionId: "personal-1", + beforeOffset: 4_096, + error: "personal history unavailable", + }); + + client.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "unsupported-project-history", + payload: { + sessionId: "project-1", + beforeOffset: 2_048, + }, + })); + const unsupportedPage = await waitForEnvelope( + envelopes, + "chat_history", + "unsupported-project-history", + ); + expect(unsupportedPage.payload).toMatchObject({ + sessionId: "project-1", + startOffset: 2_048, + unavailable: true, + }); const event: AgentChatEventEnvelope = { sessionId: "personal-1", timestamp: "2026-07-09T12:00:00.000Z", @@ -7908,6 +7986,449 @@ describe("chat_subscribe snapshots", () => { } }); + it("advertises the snapshot cursor and pages the subscribed chat without another runtime route", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "chat-page.chat.jsonl"); + const foreignTranscriptPath = path.join(projectRoot, "transcripts", "foreign-chat-page.chat.jsonl"); + const missingForeignTranscriptPath = path.join(projectRoot, "transcripts", "missing-foreign-chat.chat.jsonl"); + const foreignRootA = path.join(projectRoot, "registered-foreign-a"); + const foreignRootB = path.join(projectRoot, "registered-foreign-b"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const session = { + id: "chat-page", + laneId: "lane-1", + transcriptPath, + status: "exited", + runtimeState: "exited", + lastOutputPreview: "", + }; + const olderEvent: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-07-28T10:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "older prompt" }, + }; + const foreignEvent: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-07-28T10:01:00.000Z", + sequence: 1, + event: { type: "user_message", text: "foreign project prompt" }, + }; + fs.writeFileSync(foreignTranscriptPath, `${JSON.stringify(foreignEvent)}\n`, "utf8"); + const getChatEventHistoryPage = vi.fn().mockResolvedValue({ + sessionId: session.id, + events: [olderEvent], + startOffset: 2_048, + hasMore: true, + sessionFound: true, + }); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + projectId: "project-1", + projectIdAliases: ["project-1-alias"], + pollIntervalMs: 100, + db: { + sync: { + getSiteId: () => "site-host-chat-page", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => id === session.id ? session : null, + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory: vi.fn().mockResolvedValue({ + sessionId: session.id, + events: [], + truncated: true, + transcriptTruncated: true, + windowTruncated: false, + sessionFound: true, + hasOlderHistory: true, + tailStartOffset: 4_096, + }), + getChatEventHistoryPage, + getSessionSummary: vi.fn().mockResolvedValue({ status: "idle" }), + }, + foreignChatProvider: { + resolveTranscriptPath: vi.fn((scope: { + projectId?: string | null; + projectRootPath?: string | null; + sessionId: string; + }) => { + const requestedProjectId = scope.projectId?.trim() || null; + const requestedRoot = scope.projectRootPath?.trim() + ? path.resolve(scope.projectRootPath) + : null; + if ( + scope.sessionId === session.id + && (requestedProjectId != null || requestedRoot != null) + && (requestedProjectId == null || requestedProjectId === "foreign-project-a") + && (requestedRoot == null || requestedRoot === path.resolve(foreignRootA)) + ) { + return foreignTranscriptPath; + } + if ( + scope.sessionId === "missing-foreign-chat" + && scope.projectId === "foreign-project-missing" + ) { + return missingForeignTranscriptPath; + } + return null; + }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-page"); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-page-subscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "project-1", + maxBytes: 256 * 1_024, + }, + })); + const snapshot = await waitForEnvelope( + peer.envelopes, + "chat_subscribe", + "chat-page-subscribe", + ); + expect(snapshot.payload).toMatchObject({ + sessionId: session.id, + cursorKind: "byte", + tailStartOffset: 4_096, + hasOlderHistory: true, + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "chat-page-history", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "project-1", + beforeOffset: 4_096, + maxBytes: 256 * 1_024, + }, + })); + const page = await waitForEnvelope( + peer.envelopes, + "chat_history", + "chat-page-history", + ); + expect(page.payload).toEqual({ + sessionId: session.id, + events: [olderEvent], + startOffset: 2_048, + hasMore: true, + sessionFound: true, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledWith(session.id, { + beforeOffset: 4_096, + maxBytes: 256 * 1_024, + signal: expect.any(AbortSignal), + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "chat-page-history-host-root", + projectId: "project-1", + payload: { + sessionId: session.id, + projectRootPath: projectRoot, + beforeOffset: 2_048, + }, + })); + const rootScopedPage = await waitForEnvelope( + peer.envelopes, + "chat_history", + "chat-page-history-host-root", + ); + expect(rootScopedPage.payload).toMatchObject({ + sessionId: session.id, + startOffset: 2_048, + sessionFound: true, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledTimes(2); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "chat-page-history-conflicting-host-scope", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "project-1", + projectRootPath: foreignRootB, + beforeOffset: 2_048, + }, + })); + const conflictingHostScope = await waitForEnvelope( + peer.envelopes, + "chat_history", + "chat-page-history-conflicting-host-scope", + ); + expect(conflictingHostScope.payload).toMatchObject({ + sessionId: session.id, + startOffset: 2_048, + sessionFound: false, + unavailable: true, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledTimes(2); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_unsubscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project", + }, + })); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "chat-page-wrong-scope", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project", + beforeOffset: 2_048, + }, + })); + const refused = await waitForEnvelope( + peer.envelopes, + "chat_history", + "chat-page-wrong-scope", + ); + expect(refused.payload).toMatchObject({ + sessionId: session.id, + startOffset: 2_048, + sessionFound: false, + unavailable: true, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledTimes(2); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "chat-page-history-after-mismatched-unsubscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "project-1", + beforeOffset: 2_048, + }, + })); + const pageAfterMismatchedUnsubscribe = await waitForEnvelope( + peer.envelopes, + "chat_history", + "chat-page-history-after-mismatched-unsubscribe", + ); + expect(pageAfterMismatchedUnsubscribe.payload).toMatchObject({ + sessionId: session.id, + startOffset: 2_048, + sessionFound: true, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledTimes(3); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "foreign-chat-page-subscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project-a", + }, + })); + const foreignSnapshot = await waitForEnvelope( + peer.envelopes, + "chat_subscribe", + "foreign-chat-page-subscribe", + ); + expect(foreignSnapshot.payload).toMatchObject({ + sessionId: session.id, + events: [foreignEvent], + }); + + const foreignTranscriptSize = Buffer.byteLength(`${JSON.stringify(foreignEvent)}\n`, "utf8"); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "foreign-chat-page-wrong-root", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project-a", + projectRootPath: foreignRootB, + beforeOffset: foreignTranscriptSize, + }, + })); + const wrongForeignRoot = await waitForEnvelope( + peer.envelopes, + "chat_history", + "foreign-chat-page-wrong-root", + ); + expect(wrongForeignRoot.payload).toMatchObject({ + sessionId: session.id, + startOffset: foreignTranscriptSize, + sessionFound: false, + unavailable: true, + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_unsubscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project-b", + projectRootPath: foreignRootA, + }, + })); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "foreign-chat-page-after-mismatched-unsubscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectRootPath: `${foreignRootA}/.`, + beforeOffset: foreignTranscriptSize, + }, + })); + const exactForeignPage = await waitForEnvelope( + peer.envelopes, + "chat_history", + "foreign-chat-page-after-mismatched-unsubscribe", + ); + expect(exactForeignPage.payload).toMatchObject({ + sessionId: session.id, + events: [foreignEvent], + startOffset: 0, + sessionFound: true, + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_unsubscribe", + projectId: "project-1", + payload: { sessionId: session.id }, + })); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "foreign-chat-page-after-legacy-unsubscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "foreign-project-a", + projectRootPath: foreignRootA, + beforeOffset: foreignTranscriptSize, + }, + })); + const afterLegacyUnsubscribe = await waitForEnvelope( + peer.envelopes, + "chat_history", + "foreign-chat-page-after-legacy-unsubscribe", + ); + expect(afterLegacyUnsubscribe.payload).toMatchObject({ + sessionId: session.id, + sessionFound: false, + unavailable: true, + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "rejected-foreign-chat-subscribe", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "unregistered-foreign-project", + }, + })); + const rejectedForeignSnapshot = await waitForEnvelope( + peer.envelopes, + "chat_subscribe", + "rejected-foreign-chat-subscribe", + ); + expect(rejectedForeignSnapshot.payload).toMatchObject({ + sessionId: session.id, + events: [], + }); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_history", + requestId: "rejected-foreign-chat-history", + projectId: "project-1", + payload: { + sessionId: session.id, + projectId: "unregistered-foreign-project", + beforeOffset: foreignTranscriptSize, + }, + })); + const rejectedForeignHistory = await waitForEnvelope( + peer.envelopes, + "chat_history", + "rejected-foreign-chat-history", + ); + expect(rejectedForeignHistory.payload).toMatchObject({ + sessionId: session.id, + sessionFound: false, + unavailable: true, + }); + + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "missing-foreign-chat-subscribe", + projectId: "project-1-alias", + payload: { + sessionId: "missing-foreign-chat", + projectId: "foreign-project-missing", + }, + })); + const missingForeignSnapshot = await waitForEnvelope( + peer.envelopes, + "chat_subscribe", + "missing-foreign-chat-subscribe", + ); + expect(missingForeignSnapshot.payload).toMatchObject({ + sessionId: "missing-foreign-chat", + events: [], + truncated: false, + tailStartOffset: 0, + hasOlderHistory: false, + }); + + const appearedEvent: AgentChatEventEnvelope = { + sessionId: "missing-foreign-chat", + timestamp: "2026-07-28T10:02:00.000Z", + sequence: 1, + event: { type: "text", text: "transcript appeared after subscribe" }, + }; + fs.writeFileSync(missingForeignTranscriptPath, `${JSON.stringify(appearedEvent)}\n`, "utf8"); + const appearedEventEnvelope = await waitForValue( + () => peer?.envelopes.find((entry) => + entry.type === "chat_event" + && (entry.payload as AgentChatEventEnvelope).sessionId === "missing-foreign-chat" + ), + "foreign transcript event after the file appears", + ); + expect(appearedEventEnvelope.payload).toMatchObject(appearedEvent); + } finally { + peer?.ws.close(); + await host.dispose(); + cleanup(); + } + }); + it("holds live pumping behind a slow snapshot and replays only concurrent appends after the ack", async () => { const { projectRoot, cleanup } = createTempProjectRoot(); const transcriptPath = path.join(projectRoot, "transcripts", "slow-snapshot.chat.jsonl"); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 326adfbe3..d4e606550 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -28,6 +28,7 @@ import { } from "../../../../desktop/src/shared/syncMobileCompatibility"; import type { AgentChatEventEnvelope, + AgentChatEventHistoryPage, AgentChatEventHistorySnapshot, CrsqlChangeRow, DeviceMarker, @@ -57,6 +58,7 @@ import type { SyncDpopProof, SyncEnvelope, SyncChatEventPayload, + SyncChatHistoryRequestPayload, SyncChatSubscribePayload, SyncChatSubscribeSnapshotPayload, SyncChatUnsubscribePayload, @@ -105,6 +107,7 @@ import { SYNC_RELAY_REAUTHORIZE_V1_CAPABILITY, } from "../../../../desktop/src/shared/types"; import { parseAgentChatTranscript } from "../../../../desktop/src/shared/chatTranscript"; +import { readTranscriptHistoryPage } from "../../../../desktop/src/main/services/chat/chatTranscriptHistoryPager"; 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"; @@ -520,6 +523,17 @@ type LanePresenceEntry = { }; type ChatSubscriptionScope = "project" | "personal" | "foreign-project"; +type ChatSubscriptionBinding = + | { scope: "project" } + | { scope: "personal" } + | { + scope: "foreign-project"; + transcriptPath: string; + }; +type ChatScopeRequest = Pick< + SyncChatSubscribePayload, + "chatScope" | "projectId" | "projectRootPath" +>; type PendingTerminalSnapshotEvent = | { @@ -586,7 +600,7 @@ type PeerState = { nextTerminalSnapshotGeneration: number; subscribedChatSessionIds: Set; hydratingChatSessionIds: Set; - chatSubscriptionScopes: Map; + chatSubscriptionBindings: Map; chatTranscriptOffsets: Map; // Progress while scanning one JSONL record that exceeded a normal bounded // transcript-delta read. The durable offset above still advances only after @@ -1141,6 +1155,7 @@ const SYNC_HOST_PROJECT_SCOPED_INBOUND_ENVELOPE_TYPES = new Set { + ): Promise<{ + events: AgentChatEventEnvelope[]; + transcriptSize: number; + truncated: boolean; + tailStartOffset: number; + }> { try { const size = await readHistoryFileSize(transcriptPath); const start = Math.max(0, size - Math.max(1_024, maxBytes)); if (size <= start) { - return { events: [], transcriptSize: size, truncated: false }; + return { events: [], transcriptSize: size, truncated: false, tailStartOffset: 0 }; } const out = await readHistoryFileRange( transcriptPath, @@ -5205,20 +5228,33 @@ export function createSyncHostService(args: SyncHostServiceArgs) { signal, ); // Drop a leading partial line when starting mid-file so the parser never - // sees a truncated JSON object as the first record. + // sees a truncated JSON object as the first record. The first complete + // line's logical offset becomes the paging seam; a page ending there can + // recover the dropped straddling record without a gap. let sliceStart = 0; if (start > 0) { const firstNewline = out.indexOf(0x0a); sliceStart = firstNewline >= 0 ? firstNewline + 1 : out.length; } const raw = out.subarray(sliceStart).toString("utf8"); + const tailStartOffset = start + sliceStart; return { events: parseAgentChatTranscript(raw), transcriptSize: size, - truncated: start > 0, + truncated: tailStartOffset > 0, + tailStartOffset, }; - } catch { - return { events: [], transcriptSize: 0, truncated: false }; + } catch (error) { + signal?.throwIfAborted(); + const code = (error as NodeJS.ErrnoException | null)?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + // The provider already authorized and sandboxed this path. A session + // may be registered just before its transcript is created (or rotate + // between stat/read), so keep the subscription live and let the pump + // discover the file when it appears. + return { events: [], transcriptSize: 0, truncated: false, tailStartOffset: 0 }; + } + throw error; } } @@ -5238,6 +5274,58 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return 0; } + function requestedProjectChatScope( + payload: ChatScopeRequest | null, + ): "project" | "foreign-project" { + const requestedProjectId = toOptionalString(payload?.projectId); + const requestedRootPath = toOptionalString(payload?.projectRootPath); + if (!requestedProjectId && !requestedRootPath) return "project"; + const projectIdMatches = !requestedProjectId + || projectIdMatchesHost(requestedProjectId, args.projectId, hostProjectIdAliases); + const projectRootMatches = !requestedRootPath + || path.resolve(requestedRootPath) === path.resolve(args.projectRoot); + return projectIdMatches && projectRootMatches ? "project" : "foreign-project"; + } + + function requestedChatSubscriptionScope( + payload: ChatScopeRequest | null, + ): ChatSubscriptionScope { + return payload?.chatScope === "personal" ? "personal" : requestedProjectChatScope(payload); + } + + function chatSubscriptionMatchesRequest( + binding: ChatSubscriptionBinding | undefined, + payload: ChatScopeRequest | null, + sessionId: string, + ): boolean { + if (!binding) return false; + const requestedScope = requestedChatSubscriptionScope(payload); + if (binding.scope !== requestedScope) return false; + if (binding.scope !== "foreign-project") { + // Every host id alias and the host root normalize to the same local + // project binding; personal scope is machine-wide. + return true; + } + // The provider is the project identity/security boundary. Compare its + // canonical transcript target rather than raw selector fields so project + // id and root aliases for the same registered project remain equivalent, + // while colliding session ids from different projects stay isolated. + const requestedForeignScope = resolveForeignChatScope(payload, sessionId); + return requestedForeignScope.kind === "foreign" + && path.resolve(requestedForeignScope.transcriptPath) === binding.transcriptPath; + } + + function hasExplicitChatSubscriptionScope( + payload: ChatScopeRequest | null, + ): boolean { + return ( + payload?.chatScope === "project" + || payload?.chatScope === "personal" + || toOptionalString(payload?.projectId) != null + || toOptionalString(payload?.projectRootPath) != null + ); + } + // Resolve a foreign-project subscription's transcript path via the provider // (the security boundary — validates the project is registered and confines // the path to that project's `.ade` transcripts). `kind: "local"` means the @@ -5246,20 +5334,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // the provider could not confirm — the caller must fail closed rather than // fall back to serving whichever local session shares the sessionId. function resolveForeignChatScope( - payload: { projectId?: string | null; projectRootPath?: string | null } | null, + payload: ChatScopeRequest | null, sessionId: string, ): { kind: "local" } | { kind: "foreign"; transcriptPath: string } | { kind: "rejected" } { const requestedProjectId = toOptionalString(payload?.projectId); const requestedRootPath = toOptionalString(payload?.projectRootPath); - if (!requestedProjectId && !requestedRootPath) return { kind: "local" }; - // A payload that names THIS host's project (by id, or by rootPath alone) - // is an ordinary subscribe — let the local sessionService path serve it. - if (requestedProjectId && projectIdMatchesHost(requestedProjectId, args.projectId, hostProjectIdAliases)) { - return { kind: "local" }; - } - if (!requestedProjectId && requestedRootPath && path.resolve(requestedRootPath) === path.resolve(args.projectRoot)) { - return { kind: "local" }; - } + if (requestedProjectChatScope(payload) === "project") return { kind: "local" }; const transcriptPath = args.foreignChatProvider?.resolveTranscriptPath({ projectId: requestedProjectId, projectRootPath: requestedRootPath, @@ -5385,7 +5465,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Personal and foreign-project subscriptions get events from their // resolved transcript paths — never the active project's live broadcast, // even when a local session shares the session id. - if (peer.chatSubscriptionScopes.get(event.sessionId) !== "project") continue; + if (peer.chatSubscriptionBindings.get(event.sessionId)?.scope !== "project") continue; sendChatEvent(peer, event, seq); } // A chat lifecycle event for the host project updates its roster status @@ -6125,6 +6205,23 @@ export function createSyncHostService(args: SyncHostServiceArgs) { endOffset: beforeOffset, atStart: true, } satisfies SyncTerminalHistoryResponsePayload, requestId); + return; + } + + if (type === "chat_history") { + const historyPayload = (payload ?? {}) as { sessionId?: string; beforeOffset?: number }; + const sessionId = toOptionalString(historyPayload.sessionId) ?? ""; + const beforeOffset = typeof historyPayload.beforeOffset === "number" && Number.isFinite(historyPayload.beforeOffset) + ? Math.max(0, Math.floor(historyPayload.beforeOffset)) + : 0; + sendRequired(peer, "chat_history", { + sessionId, + events: [], + startOffset: beforeOffset, + hasMore: beforeOffset > 0, + sessionFound: false, + unavailable: true, + } satisfies AgentChatEventHistoryPage, requestId); } } @@ -7212,7 +7309,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const envelopePayload = safeObjectValue(envelope.payload); const personalChatEnvelope = - (envelope.type === "chat_subscribe" || envelope.type === "chat_unsubscribe") + ( + envelope.type === "chat_subscribe" + || envelope.type === "chat_unsubscribe" + || envelope.type === "chat_history" + ) && envelopePayload?.chatScope === "personal"; const projectScope: SyncHostProjectScopeResolution = personalChatEnvelope ? { ok: true, projectId: null, usedSingleProjectFallback: false } @@ -7595,14 +7696,86 @@ export function createSyncHostService(args: SyncHostServiceArgs) { args.ptyService.resizeBySessionId(sessionId, cols, rows, { source: "mobile" }); break; } + case "chat_history": { + const payload = envelope.payload as SyncChatHistoryRequestPayload | null; + const sessionId = toOptionalString(payload?.sessionId); + if (!sessionId) break; + const beforeOffset = typeof payload?.beforeOffset === "number" && Number.isFinite(payload.beforeOffset) + ? Math.max(0, Math.floor(payload.beforeOffset)) + : 0; + const unavailablePage = (): AgentChatEventHistoryPage => ({ + sessionId, + events: [], + startOffset: beforeOffset, + hasMore: beforeOffset > 0, + sessionFound: false, + unavailable: true, + }); + const subscribedBinding = peer.chatSubscriptionBindings.get(sessionId); + const requestedScope = requestedChatSubscriptionScope(payload); + if ( + !peer.subscribedChatSessionIds.has(sessionId) + || !chatSubscriptionMatchesRequest(subscribedBinding, payload, sessionId) + ) { + args.logger.warn("sync.chat_history_unsubscribed_or_scope_mismatch", { + sessionId, + subscribedScope: subscribedBinding?.scope ?? null, + requestedScope, + }); + sendRequired(peer, "chat_history", unavailablePage(), envelope.requestId); + break; + } + try { + let page: AgentChatEventHistoryPage; + const subscribedTranscriptPath = peer.resolvedChatTranscriptPaths.get(sessionId); + if (subscribedTranscriptPath) { + const read = await runWithAbortSignal( + () => readTranscriptHistoryPage({ + transcriptPath: subscribedTranscriptPath, + sessionId, + beforeOffset, + maxBytes: payload?.maxBytes, + signal, + }), + signal, + "Sync operation aborted.", + ); + page = { + sessionId, + events: read.envelopes, + startOffset: read.startOffset, + hasMore: read.hasMore, + sessionFound: true, + }; + } else if (args.agentChatService) { + page = await args.agentChatService.getChatEventHistoryPage(sessionId, { + beforeOffset, + ...(typeof payload?.maxBytes === "number" ? { maxBytes: payload.maxBytes } : {}), + ...(signal ? { signal } : {}), + }); + } else { + page = unavailablePage(); + } + sendRequired(peer, "chat_history", page, envelope.requestId); + } catch (error) { + args.logger.warn("sync.chat_history_failed", { + sessionId, + beforeOffset, + error: error instanceof Error ? error.message : String(error), + }); + sendRequired(peer, "chat_history", unavailablePage(), envelope.requestId); + } + break; + } case "chat_subscribe": { const payload = envelope.payload as SyncChatSubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); if (!sessionId) break; - const personalChatRequested = payload?.chatScope === "personal"; + const requestedScope = requestedChatSubscriptionScope(payload); + const personalChatRequested = requestedScope === "personal"; const priorSubscription = { subscribed: peer.subscribedChatSessionIds.has(sessionId), - scope: peer.chatSubscriptionScopes.get(sessionId), + binding: peer.chatSubscriptionBindings.get(sessionId), transcriptPath: peer.resolvedChatTranscriptPaths.get(sessionId), offset: peer.chatTranscriptOffsets.get(sessionId), scanOffset: peer.chatTranscriptScanOffsets.get(sessionId), @@ -7637,17 +7810,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const foreignTranscriptPath = foreignScope.kind === "foreign" ? foreignScope.transcriptPath : null; if (foreignScope.kind === "rejected") { peer.subscribedChatSessionIds.delete(sessionId); - peer.chatSubscriptionScopes.delete(sessionId); + peer.chatSubscriptionBindings.delete(sessionId); } else { peer.subscribedChatSessionIds.add(sessionId); - peer.chatSubscriptionScopes.set( - sessionId, - personalChatRequested - ? "personal" - : foreignScope.kind === "foreign" - ? "foreign-project" - : "project", - ); + if (foreignScope.kind === "foreign" && requestedScope === "foreign-project") { + peer.chatSubscriptionBindings.set(sessionId, { + scope: "foreign-project", + transcriptPath: path.resolve(foreignScope.transcriptPath), + }); + } else if (requestedScope === "personal") { + peer.chatSubscriptionBindings.set(sessionId, { scope: "personal" }); + } else { + peer.chatSubscriptionBindings.set(sessionId, { scope: "project" }); + } } if (foreignTranscriptPath) { peer.resolvedChatTranscriptPaths.set(sessionId, foreignTranscriptPath); @@ -7732,6 +7907,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let events: AgentChatEventEnvelope[]; let truncated: boolean; let transcriptSize: number; + let tailStartOffset = 0; + let hasOlderHistory = false; if (foreignTranscriptPath) { const foreignSnapshot = await runWithAbortSignal( () => readForeignChatSnapshot(foreignTranscriptPath, maxBytes, signal), @@ -7741,6 +7918,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { events = foreignSnapshot.events; truncated = foreignSnapshot.truncated; transcriptSize = foreignSnapshot.transcriptSize; + tailStartOffset = foreignSnapshot.tailStartOffset; + hasOlderHistory = foreignSnapshot.tailStartOffset > 0; } else if (foreignScope.kind === "rejected") { // Unresolvable explicit-foreign scope: serve an empty snapshot, never // this host's local history for the same session id. @@ -7758,6 +7937,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { events = history?.events ?? []; transcriptSize = await readTranscriptLogicalSize(transcriptPath); truncated = history?.truncated ?? (transcriptSize > maxBytes); + tailStartOffset = history?.tailStartOffset ?? 0; + hasOlderHistory = history?.hasOlderHistory + ?? (history?.truncated === true && tailStartOffset > 0); } events = events.map(compactChatEventEnvelopeForSync); peer.chatTranscriptOffsets.set(sessionId, hydrationStartOffset); @@ -7766,6 +7948,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { sessionId, capturedAt: nowIso(), truncated, + tailStartOffset, + hasOlderHistory, + cursorKind: "byte", events, ...(await resolveLiveStatusFields()), }; @@ -7785,10 +7970,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // silently disconnect a client that was already receiving events. if (priorSubscription.subscribed) peer.subscribedChatSessionIds.add(sessionId); else peer.subscribedChatSessionIds.delete(sessionId); - if (priorSubscription.scope !== undefined) { - peer.chatSubscriptionScopes.set(sessionId, priorSubscription.scope); + if (priorSubscription.binding !== undefined) { + peer.chatSubscriptionBindings.set(sessionId, priorSubscription.binding); } else { - peer.chatSubscriptionScopes.delete(sessionId); + peer.chatSubscriptionBindings.delete(sessionId); } if (priorSubscription.transcriptPath !== undefined) { peer.resolvedChatTranscriptPaths.set(sessionId, priorSubscription.transcriptPath); @@ -7817,10 +8002,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { case "chat_unsubscribe": { const payload = envelope.payload as SyncChatUnsubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); - if (sessionId) { + const subscribedBinding = sessionId ? peer.chatSubscriptionBindings.get(sessionId) : undefined; + if ( + sessionId + && ( + !hasExplicitChatSubscriptionScope(payload) + || chatSubscriptionMatchesRequest(subscribedBinding, payload, sessionId) + ) + ) { peer.subscribedChatSessionIds.delete(sessionId); peer.hydratingChatSessionIds.delete(sessionId); - peer.chatSubscriptionScopes.delete(sessionId); + peer.chatSubscriptionBindings.delete(sessionId); peer.chatTranscriptOffsets.delete(sessionId); peer.chatTranscriptScanOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); @@ -8210,7 +8402,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { continue; } const chatSubscriptions = [...peer.subscribedChatSessionIds].flatMap((sessionId) => { - const scope = peer.chatSubscriptionScopes.get(sessionId) ?? "project"; + const scope = peer.chatSubscriptionBindings.get(sessionId)?.scope ?? "project"; return scope === "foreign-project" ? [] : [{ sessionId, scope }]; }); const handedOffChatSessionIds = new Set(chatSubscriptions.map(({ sessionId }) => sessionId)); diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index 5e64bec61..55c00e3ed 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -65,7 +65,7 @@ function expandAllWorkGroups( function renderEvents( events: AgentChatEventEnvelope[], - options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | null; expanded?: boolean } = {}, + options: { maxRows?: number; scrollOffsetRows?: number; width?: number; streaming?: boolean; interrupted?: boolean; provider?: AdeCodeProvider; olderHistory?: "loading" | "available" | "exhausted" | "error" | null; expanded?: boolean } = {}, ): string { const provider = options.provider ?? "codex"; const activeSession = { ...session, provider }; @@ -855,11 +855,13 @@ describe("ChatView", () => { const idle = renderEvents(events, { maxRows, width: 80 }); const loading = renderEvents(events, { maxRows, width: 80, olderHistory: "loading" }); + const failed = renderEvents(events, { maxRows, width: 80, olderHistory: "error" }); const exhausted = renderEvents(events, { maxRows, width: 80, olderHistory: "exhausted" }); expect(idle).toContain("↑ older messages"); expect(loading).toContain("↑ loading earlier…"); expect(loading).not.toContain("↑ older messages"); + expect(failed).toContain("↑ couldn’t load earlier · Ctrl+R to retry"); // "exhausted"/"available" keep the existing indicator behavior untouched. expect(exhausted).toContain("↑ older messages"); // The indicator swaps text in the SAME row: row count is identical in all @@ -869,6 +871,25 @@ describe("ChatView", () => { expect(transcriptLines(idle)[0]).toContain("↑ older messages"); }); + it("keeps retry visible when a short remote tail cannot fill the viewport", () => { + const events: AgentChatEventEnvelope[] = [{ + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "newest tail only" }, + }]; + + const failed = renderEvents(events, { + maxRows: 8, + width: 80, + olderHistory: "error", + }); + + expect(failed).toContain("↑ couldn’t load earlier · Ctrl+R to retry"); + expect(transcriptLines(failed)).toHaveLength(8); + expect(failed).toContain("newest tail only"); + }); + it("stays at the oldest rows when the transcript is overscrolled", () => { const events = Array.from({ length: 12 }, (_, index): AgentChatEventEnvelope => ({ sessionId: "s1", diff --git a/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts b/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts index be018cb47..64bbb57da 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/olderHistory.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; import { advanceOlderHistoryCursor, + mergeDetachedTuiHistoryTail, prependOlderTuiHistory, resolveSnapshotHistoryCursor, + shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, @@ -120,13 +122,94 @@ describe("prependOlderTuiHistory", () => { expect(next.map((entry) => entry.sequence)).toEqual([7, 8, 10]); }); - it("keeps the newest events when the cap is exceeded", () => { + it("keeps the newly loaded oldest window when the cap is exceeded", () => { const existing = [envelope(5), envelope(6), envelope(7)]; const older = [envelope(1), envelope(2), envelope(3), envelope(4)]; const next = prependOlderTuiHistory(existing, older, 5); - expect(next.map((entry) => entry.sequence)).toEqual([3, 4, 5, 6, 7]); + expect(next.map((entry) => entry.sequence)).toEqual([1, 2, 3, 4, 5]); + expect(next).toHaveLength(5); + expect(next.at(-1)?.sequence).toBe(5); + }); +}); + +describe("shouldRequestOlderTuiHistory", () => { + it("backfills an underfilled viewport without waiting for a scroll gesture", () => { + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 0, + scrollOffset: 0, + bufferedEventCount: 0, + cursor: { hasMore: true, loading: false }, + status: "available", + })).toBe(true); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 0, + scrollOffset: 0, + bufferedEventCount: 3, + cursor: null, + status: null, + })).toBe(true); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 0, + scrollOffset: 0, + bufferedEventCount: 0, + cursor: null, + status: "exhausted", + })).toBe(false); + }); + + it("prefetches only near the loaded top and coalesces loading/error states", () => { + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 100, + scrollOffset: 97, + bufferedEventCount: 0, + cursor: { hasMore: true, loading: false }, + status: "available", + })).toBe(true); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 100, + scrollOffset: 50, + bufferedEventCount: 0, + cursor: { hasMore: true, loading: false }, + status: "available", + })).toBe(false); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 100, + scrollOffset: 100, + bufferedEventCount: 0, + cursor: { hasMore: true, loading: true }, + status: "loading", + })).toBe(false); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 100, + scrollOffset: 100, + bufferedEventCount: 3, + cursor: { hasMore: true, loading: false }, + status: "error", + })).toBe(true); + expect(shouldRequestOlderTuiHistory({ + scrollMaxOffset: 100, + scrollOffset: 100, + bufferedEventCount: 0, + cursor: { hasMore: true, loading: false }, + status: "error", + })).toBe(false); + }); +}); + +describe("mergeDetachedTuiHistoryTail", () => { + it("rehydrates Latest from the fresh snapshot plus buffered live events", () => { + const duplicatedSeam = envelope(100); + const merged = mergeDetachedTuiHistoryTail( + [envelope(98), envelope(99), duplicatedSeam], + [duplicatedSeam, envelope(101)], + ); + + expect(merged.map((entry) => entry.sequence)).toEqual([98, 99, 100, 101]); + expect(merged).toHaveLength(4); + expect(merged.filter((entry) => entry.sequence === 100)).toHaveLength(1); + expect(merged.at(-1)?.sequence).toBe(101); }); }); @@ -249,7 +332,6 @@ describe("advanceOlderHistoryCursor", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 4096, hasMore: true }, { startOffset: 2048, hasMore: true }, - 100, ); expect(next).toEqual({ beforeOffset: 2048, hasMore: true }); }); @@ -258,7 +340,6 @@ describe("advanceOlderHistoryCursor", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 4096, hasMore: true }, { startOffset: 0, hasMore: false }, - 100, ); expect(next).toEqual({ beforeOffset: 0, hasMore: false }); }); @@ -267,7 +348,6 @@ describe("advanceOlderHistoryCursor", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 4096, hasMore: true }, { startOffset: 0, hasMore: true }, - 100, ); expect(next).toEqual({ beforeOffset: 0, hasMore: false }); }); @@ -276,7 +356,6 @@ describe("advanceOlderHistoryCursor", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 2048, hasMore: true }, { startOffset: 2048, hasMore: true }, - 100, ); expect(next).toEqual({ beforeOffset: 2048, hasMore: false }); }); @@ -285,17 +364,27 @@ describe("advanceOlderHistoryCursor", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 2048, hasMore: true }, { startOffset: 1024, hasMore: true, sessionFound: false }, - 100, ); expect(next).toEqual({ beforeOffset: 2048, hasMore: false }); }); - it("ends paging once the resident event cap is reached", () => { + it("preserves the cursor when the runtime is temporarily unavailable", () => { + const next = advanceOlderHistoryCursor( + { beforeOffset: 4096, hasMore: true }, + { startOffset: 0, hasMore: false, sessionFound: false, unavailable: true }, + ); + expect(next).toEqual({ beforeOffset: 4096, hasMore: true }); + expect(next.beforeOffset).toBe(4096); + expect(next.hasMore).toBe(true); + }); + + it("keeps paging with a sliding resident window once the cap is reached", () => { const next = advanceOlderHistoryCursor( { beforeOffset: 4096, hasMore: true }, { startOffset: 2048, hasMore: true }, - TUI_LOADED_EVENT_CAP, ); - expect(next).toEqual({ beforeOffset: 2048, hasMore: false }); + expect(next).toEqual({ beforeOffset: 2048, hasMore: true }); + expect(next.beforeOffset).toBeLessThan(4096); + expect(next.hasMore).toBe(true); }); }); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index ea4d092b3..665c5d50f 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -548,9 +548,14 @@ export async function listPrsByLane(connection: AdeCodeConnection): Promise { - return await connection.actionList("chat", "getChatEventHistory", [sessionId, { maxEvents }]); + return await connection.actionList( + "chat", + "getChatEventHistory", + [sessionId, { maxEvents, maxBytes }], + ); } export async function getChatHistoryPage( diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index e20314673..73cf51ca9 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -264,7 +264,7 @@ import { latestExpandableFailureId, renderObject, summarizeDiffChanges } from ". import { startTuiHeartbeat, type TuiHeartbeat } from "./heartbeat"; import { clipboardScratchDir, isImageFilePath, latestOpenableImageTarget, readClipboardImageAttachment, readImageDimensions } from "./imageTargets"; import { appendReservedTuiEvent, dedupeTuiEvents, reserveTuiEventDedupKey, syncTuiEventDedupKeys } from "./eventDedup"; -import { advanceOlderHistoryCursor, prependOlderTuiHistory, resolveSnapshotHistoryCursor, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP } from "./olderHistory"; +import { advanceOlderHistoryCursor, mergeDetachedTuiHistoryTail, prependOlderTuiHistory, resolveSnapshotHistoryCursor, shouldRequestOlderTuiHistory, splitSnapshotForDisplay, takeNewestChunk, TUI_LOADED_EVENT_CAP, TUI_SNAPSHOT_DISPLAY_CAP, type OlderHistoryStatus } from "./olderHistory"; import { coalesceTextDeltaEnvelopes } from "./assistantTextIdentity"; import { EMPTY_BRACKETED_PASTE_STATE, @@ -3361,7 +3361,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // a silent gap in the transcript. const olderSnapshotBufferBySessionIdRef = useRef>({}); // Render mirror of the cursor for the "↑ loading earlier…" indicator. - const [olderHistoryStatusBySessionId, setOlderHistoryStatusBySessionId] = useState>({}); + const [olderHistoryStatusBySessionId, setOlderHistoryStatusBySessionId] = useState>({}); + const detachedHistorySessionIdsRef = useRef(new Set()); + const detachedLiveEventsBySessionIdRef = useRef>({}); + const returningHistoryToLatestSessionIdsRef = useRef(new Set()); const providerModelsCacheRef = useRef>(new Map()); const modelCatalogRef = useRef(null); const modelCatalogProviderRefreshedAtRef = useRef>(new Map()); @@ -3543,6 +3546,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, * honours the host's authoritative `hasOlderHistory` signal. */ const seedOlderHistoryCursor = useCallback((sessionId: string, tailStartOffset: number | null | undefined) => { + detachedHistorySessionIdsRef.current.delete(sessionId); + delete detachedLiveEventsBySessionIdRef.current[sessionId]; const pageable = tailStartOffset != null && tailStartOffset > 0; if (pageable) { olderHistoryCursorBySessionIdRef.current[sessionId] = { @@ -3566,6 +3571,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (!sessionId) return; delete olderHistoryCursorBySessionIdRef.current[sessionId]; delete olderSnapshotBufferBySessionIdRef.current[sessionId]; + detachedHistorySessionIdsRef.current.delete(sessionId); + delete detachedLiveEventsBySessionIdRef.current[sessionId]; setOlderHistoryStatusBySessionId((prev) => { if (!(sessionId in prev)) return prev; const { [sessionId]: _dropped, ...rest } = prev; @@ -3576,6 +3583,15 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const mergeHydratedEventsWithLive = useCallback((sessionId: string, displayEvents: AgentChatEventEnvelope[]) => { const existing = eventsBySessionIdRef.current[sessionId] ?? []; const pending = pendingChatEnvelopesRef.current.filter((envelope) => envelope.sessionId === sessionId); + if (detachedHistorySessionIdsRef.current.has(sessionId)) { + // The cached resident window now represents the OLDEST loaded slice. + // Never append it after a fresh tail snapshot or it can displace the + // latest events. Only live events buffered while detached continue it. + return mergeDetachedTuiHistoryTail( + displayEvents, + [...(detachedLiveEventsBySessionIdRef.current[sessionId] ?? []), ...pending], + ); + } if (existing.length === 0 && pending.length === 0) return displayEvents; return dedupeTuiEvents( [...displayEvents, ...existing, ...pending], @@ -4627,7 +4643,17 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, selectActiveSessionId(sessionId); } if (loadedSessionIdRef.current === sessionId) return; - clearLoadedTranscript(); + const cachedEvents = eventsBySessionIdRef.current[sessionId] ?? []; + if (cachedEvents.length > 0) { + // Paint the last resident window synchronously. Revalidation below may + // replace it, but a chat revisit never flashes an empty transcript. + commitActiveSessionEvents(sessionId, cachedEvents); + // The cached window remains a valid paging base if background + // revalidation is temporarily unavailable. + loadedSessionIdRef.current = sessionId; + } else { + clearLoadedTranscript(); + } const conn = connectionRef.current; if (!conn) return; @@ -4641,6 +4667,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (activeSessionIdRef.current !== sessionId) return; if (selectedDrawerChatIdRef.current !== sessionId) return; + if (history.unavailable === true) { + setOlderHistoryStatusBySessionId((prev) => ( + olderHistoryCursorBySessionIdRef.current[sessionId] + ? { ...prev, [sessionId]: "error" } + : prev + )); + return; + } if (history.sessionFound === false) { clearOlderHistoryCursor(sessionId); loadedSessionIdRef.current = sessionId; @@ -4689,7 +4723,16 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setInterrupted(false); } } catch { - // Best-effort preview hydration; leave prior content on transient errors. + if (generation !== drawerPreviewGenerationRef.current) return; + if (activeSessionIdRef.current !== sessionId) return; + if (selectedDrawerChatIdRef.current !== sessionId) return; + // Best-effort preview hydration leaves prior content visible, but the + // cached cursor must remain explicitly retryable. + setOlderHistoryStatusBySessionId((prev) => ( + olderHistoryCursorBySessionIdRef.current[sessionId] + ? { ...prev, [sessionId]: "error" } + : prev + )); } })(); }, [clearOlderHistoryCursor, commitActiveSessionEvents, mergeHydratedEventsWithLive, seedOlderHistoryCursor, selectActiveLaneId, selectActiveSessionId, setDraftChatMode, setGridView, setSessionInterrupted, setSessionStreaming, setStreaming]); @@ -5291,17 +5334,6 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // hasn't locally cleared — paging would just resurrect pre-clear events). if (loadedSessionIdRef.current !== sessionId) return; if (clearedAtRef.current) return; - if (eventCountRef.current >= TUI_LOADED_EVENT_CAP) { - // Resident-event cap reached: end ALL scroll-back for this session - // (drop the snapshot buffer and the byte cursor). - delete olderSnapshotBufferBySessionIdRef.current[sessionId]; - const cappedCursor = olderHistoryCursorBySessionIdRef.current[sessionId]; - if (cappedCursor) { - cappedCursor.hasMore = false; - setOlderHistoryStatusBySessionId((prev) => ({ ...prev, [sessionId]: "exhausted" })); - } - return; - } // Phase 1 — drain the snapshot remainder locally. The displayed window is // the newest 500 snapshot events; everything older from the SAME snapshot // sits in this buffer, so prepending its newest chunk is contiguous by @@ -5314,6 +5346,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setEvents((prev) => { const next = prependOlderTuiHistory(prev, chunk); if (next === prev) return prev; + if (prev.length + chunk.length > TUI_LOADED_EVENT_CAP) { + detachedHistorySessionIdsRef.current.add(sessionId); + } eventDedupKeyOrderRef.current = syncTuiEventDedupKeys(eventDedupKeysRef.current, next); eventCountRef.current = next.length; lastSeenAtBottomEventCountRef.current += next.length - prev.length; @@ -5333,11 +5368,30 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const beforeOffset = cursor.beforeOffset; if (cursor.lastRequestedBeforeOffset === beforeOffset) break; cursor.lastRequestedBeforeOffset = beforeOffset; - const page = await getChatHistoryPage(conn, sessionId, beforeOffset); + let page: Awaited> | null = null; + let pageError: unknown = null; + for (let retry = 0; retry < 3; retry += 1) { + if (retry > 0) { + await new Promise((resolve) => setTimeout(resolve, retry === 1 ? 250 : 750)); + } + try { + const candidate = await getChatHistoryPage(conn, sessionId, beforeOffset); + if (candidate.unavailable === true) { + throw new Error("Chat history is temporarily unavailable."); + } + page = candidate; + break; + } catch (error) { + pageError = error; + } + } + if (!page) throw pageError ?? new Error("Couldn’t load earlier messages."); + // Returning to Latest or rehydrating the session replaces the cursor + // object. Never let an older in-flight page mutate that fresh window. + if (olderHistoryCursorBySessionIdRef.current[sessionId] !== cursor) return; const advanced = advanceOlderHistoryCursor( { beforeOffset, hasMore: cursor.hasMore }, page, - eventCountRef.current + page.events.length, ); cursor.beforeOffset = advanced.beforeOffset; cursor.hasMore = advanced.hasMore; @@ -5346,6 +5400,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setEvents((prev) => { const next = prependOlderTuiHistory(prev, page.events); if (next === prev) return prev; + if (prev.length + page.events.length > TUI_LOADED_EVENT_CAP) { + detachedHistorySessionIdsRef.current.add(sessionId); + } // Prepending rows above a bottom-anchored viewport keeps the // visible rows in place (offset counts up from the newest row), // but the dedup-key order and the "new messages since bottom" @@ -5373,6 +5430,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // Transient fetch failure — re-arm the same offset so the next scroll // trigger can retry. cursor.lastRequestedBeforeOffset = null; + setOlderHistoryStatusBySessionId((prev) => ({ ...prev, [sessionId]: "error" })); } finally { cursor.loading = false; setOlderHistoryStatusBySessionId((prev) => { @@ -5380,25 +5438,64 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // (re-hydration) while this fetch was in flight — don't resurrect a // stale status entry for it. if (olderHistoryCursorBySessionIdRef.current[sessionId] !== cursor) return prev; + if (prev[sessionId] === "error") return prev; return { ...prev, [sessionId]: cursor.hasMore ? "available" : "exhausted" }; }); } }, []); + const returnActiveHistoryToLatest = useCallback(async () => { + const sessionId = activeSessionIdRef.current; + const conn = connectionRef.current; + if (!sessionId || !conn || !detachedHistorySessionIdsRef.current.has(sessionId)) return; + if (returningHistoryToLatestSessionIdsRef.current.has(sessionId)) return; + returningHistoryToLatestSessionIdsRef.current.add(sessionId); + try { + const history = await getChatHistory(conn, sessionId); + // The user may switch chats, or a normal hydration may restore this + // session, while the tail request is in flight. Never overwrite that + // newer active view with this stale response. + if ( + activeSessionIdRef.current !== sessionId + || !detachedHistorySessionIdsRef.current.has(sessionId) + ) return; + if (history.unavailable === true || history.sessionFound === false) return; + const bufferedLive = detachedLiveEventsBySessionIdRef.current[sessionId] ?? []; + const deduped = mergeDetachedTuiHistoryTail(history.events, bufferedLive); + const { display, buffer } = splitSnapshotForDisplay(deduped); + commitActiveSessionEvents(sessionId, display, history.events.length); + if (buffer.length > 0) olderSnapshotBufferBySessionIdRef.current[sessionId] = buffer; + else delete olderSnapshotBufferBySessionIdRef.current[sessionId]; + seedOlderHistoryCursor(sessionId, resolveSnapshotHistoryCursor(history)); + detachedHistorySessionIdsRef.current.delete(sessionId); + delete detachedLiveEventsBySessionIdRef.current[sessionId]; + setChatScrollOffsetRows(0); + chatScrollOffsetRowsRef.current = 0; + } catch { + setOlderHistoryStatusBySessionId((prev) => ({ ...prev, [sessionId]: "error" })); + } finally { + returningHistoryToLatestSessionIdsRef.current.delete(sessionId); + } + }, [commitActiveSessionEvents, seedOlderHistoryCursor]); + // Infinite scroll-back trigger: the user is at (or within ~3 rows of) the // top of the loaded transcript in the ACTIVE single-chat view. Wheel and // keyboard scrolling both feed effectiveChatScrollOffsetRows, so this effect // is the single trigger point. useEffect(() => { if (!activeSessionId || gridViewActive || activeTerminalSession || selectedAgentSnapshot) return; - if (chatScrollMaxOffset <= 0) return; - if (effectiveChatScrollOffsetRows < chatScrollMaxOffset - 3) return; // Local snapshot buffer first, then the byte cursor — sessions with a // >500-event snapshot are drainable even when the transcript was never // file-truncated (no byte cursor at all). const buffered = olderSnapshotBufferBySessionIdRef.current[activeSessionId]?.length ?? 0; const cursor = olderHistoryCursorBySessionIdRef.current[activeSessionId]; - if (buffered === 0 && (!cursor || !cursor.hasMore || cursor.loading)) return; + if (!shouldRequestOlderTuiHistory({ + scrollMaxOffset: chatScrollMaxOffset, + scrollOffset: effectiveChatScrollOffsetRows, + bufferedEventCount: buffered, + cursor: cursor ?? null, + status: olderHistoryStatusBySessionId[activeSessionId] ?? null, + })) return; void loadOlderHistoryForActiveSession(); }, [ activeSessionId, @@ -5407,6 +5504,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, effectiveChatScrollOffsetRows, gridViewActive, loadOlderHistoryForActiveSession, + olderHistoryStatusBySessionId, selectedAgentSnapshot, ]); @@ -7205,7 +7303,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, if (shouldHydrateHistory) { const history = await getChatHistory(conn, nextSessionId); if (!isCurrentRefresh()) return; - if (history.sessionFound === false) { + if (history.unavailable === true) { + nextEvents = eventsBySessionIdRef.current[nextSessionId] ?? eventsRef.current; + loadedSessionIdRef.current = null; + } else if (history.sessionFound === false) { selectedSessionFound = false; clearOlderHistoryCursor(nextSessionId); setCurrentGoal(null); @@ -7798,6 +7899,13 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } const next = { ...prev }; for (const [sessionId, envelopes] of grouped) { + if (detachedHistorySessionIdsRef.current.has(sessionId)) { + detachedLiveEventsBySessionIdRef.current[sessionId] = [ + ...(detachedLiveEventsBySessionIdRef.current[sessionId] ?? []), + ...envelopes, + ].slice(-TUI_SNAPSHOT_DISPLAY_CAP); + continue; + } const existing = prev[sessionId] ?? []; // Keep the live-append window at least as large as what's already // loaded: scroll-back paging can grow a session past the default 500, @@ -7811,7 +7919,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, // (2) Active-session transcript — incremental reserve/append, batched into a // single setState so a burst of tokens triggers one React render, not N. const activeId = activeSessionIdRef.current; - if (activeId) { + if (activeId && !detachedHistorySessionIdsRef.current.has(activeId)) { const reserved: Array<{ envelope: AgentChatEventEnvelope; key: string }> = []; for (const envelope of pending) { if (envelope.sessionId !== activeId) continue; @@ -13459,9 +13567,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, return; } if (end) { + if ( + activeSessionId + && detachedHistorySessionIdsRef.current.has(activeSessionId) + ) { + void returnActiveHistoryToLatest(); + return; + } setChatScrollOffset(0); return; } + if ( + !paletteOpen + && isCtrlInput(input, key, "r") + && promptRef.current.length === 0 + && activeSessionId + && olderHistoryStatusBySessionId[activeSessionId] === "error" + ) { + setOlderHistoryStatusBySessionId((prev) => ({ ...prev, [activeSessionId]: "available" })); + void loadOlderHistoryForActiveSession(); + return; + } if (activeMentionRange && mentionSuggestions.length) { if (key.upArrow) { setMentionIndex((index) => (index <= 0 ? mentionSuggestions.length - 1 : index - 1)); diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 769b995d0..d0d5d87da 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -31,6 +31,7 @@ import { } from "./AdeWordmark"; import { laneIconGlyph } from "./Header"; import type { AdeCodeProvider } from "../types"; +import type { OlderHistoryStatus } from "../olderHistory"; import { MOSAIC_FENCE_LANGUAGE, parseMosaicCard, @@ -1352,8 +1353,22 @@ function sliceRows( maxRows?: number, scrollOffsetRows = 0, unseenMessageCount = 0, - olderHistoryLoading = false, + olderHistoryStatus: OlderHistoryStatus | null = null, ): RenderedChatRow[] { + const remoteOlderAvailable = olderHistoryStatus === "loading" + || olderHistoryStatus === "available" + || olderHistoryStatus === "error"; + const olderIndicator = (): RenderedChatRow => ({ + id: "older-indicator", + tone: "indicator", + text: olderHistoryStatus === "loading" + ? "↑ loading earlier…" + : olderHistoryStatus === "error" + ? "↑ couldn’t load earlier · Ctrl+R to retry" + : "↑ older messages", + dim: true, + rail: null, + }); // Preserve object identity when sourceRowIndex is already correct (pre-indexed // historical rows), so React.memo(ChatRow) can skip re-rendering unchanged rows // on every spinner tick. Rows without a matching index are cloned as before. @@ -1363,10 +1378,13 @@ function sliceRows( if (!maxRows || maxRows <= 0) return indexedRows; const viewportRows = Math.max(1, maxRows); if (indexedRows.length <= viewportRows) { + const visible = remoteOlderAvailable + ? [olderIndicator(), ...indexedRows.slice(-Math.max(0, viewportRows - 1))] + : [...indexedRows]; return [ - ...indexedRows, - ...Array.from({ length: viewportRows - indexedRows.length }, (_, index) => ( - spacerRow(`scroll-filler:${indexedRows.length + index}`) + ...visible, + ...Array.from({ length: viewportRows - visible.length }, (_, index) => ( + spacerRow(`scroll-filler:${visible.length + index}`) )), ]; } @@ -1375,7 +1393,7 @@ function sliceRows( const hasNewer = offset > 0; let contentRows = Math.max(1, viewportRows - (hasNewer ? 1 : 0)); let start = Math.max(0, end - contentRows); - const hasOlder = start > 0; + const hasOlder = start > 0 || remoteOlderAvailable; if (hasOlder) { contentRows = Math.max(1, viewportRows - 1 - (hasNewer ? 1 : 0)); start = Math.max(0, end - contentRows); @@ -1385,13 +1403,7 @@ function sliceRows( if (hasOlder) { // While a scroll-back page fetch is in flight the indicator swaps text in // place — same row, same count — so the scroll math is untouched. - result.push({ - id: "older-indicator", - tone: "indicator", - text: olderHistoryLoading ? "↑ loading earlier…" : "↑ older messages", - dim: true, - rail: null, - }); + result.push(olderIndicator()); } result.push(...visible); while (result.length < viewportRows - (hasNewer ? 1 : 0)) { @@ -1964,7 +1976,7 @@ function ChatViewComponent({ * is identical in all states); "available"/"exhausted"/null keep the * existing indicator behavior. */ - olderHistory?: "loading" | "available" | "exhausted" | null; + olderHistory?: OlderHistoryStatus | null; selection?: ChatTextSelection | null; width?: number; focused?: boolean; @@ -2053,7 +2065,7 @@ function ChatViewComponent({ } else if (interrupted) { withSuffix = [...baseRows, ...modelInterruptedRows()]; } - return sliceRows(withSuffix, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory === "loading"); + return sliceRows(withSuffix, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory); }, [historicalRows, historicalBlocks, tailBlocks, rowInnerWidth, brailleFrame, spinFrame, dotPulse, shimmerTick, streaming, interrupted, showWorkingIndicator, bodyRows, scrollOffsetRows, unseenMessageCount, olderHistory, expandedLineIds, activeToolEntries]); const isEmpty = !hasConversationContent(blocks) && !streaming && !interrupted; let content: React.ReactNode; diff --git a/apps/ade-cli/src/tuiClient/olderHistory.ts b/apps/ade-cli/src/tuiClient/olderHistory.ts index 5c37ec863..d4eed966d 100644 --- a/apps/ade-cli/src/tuiClient/olderHistory.ts +++ b/apps/ade-cli/src/tuiClient/olderHistory.ts @@ -5,7 +5,7 @@ import { dedupeTuiEvents, tuiEventDedupKey } from "./eventDedup"; * Hard ceiling on how many transcript events the TUI keeps loaded for one * session (tail + paged-in older history). A 50MB transcript can hold far * more JSONL lines than a terminal process should inflate into JS objects, - * so scroll-back paging stops once this many events are resident. + * so scroll-back uses a sliding window once this many events are resident. */ export const TUI_LOADED_EVENT_CAP = 60_000; @@ -88,8 +88,11 @@ export function prependOlderTuiHistory( const fresh = dedupedOlder.filter((envelope) => !seamKeys.has(seamIdentityKey(envelope))); if (fresh.length === 0) return existing as AgentChatEventEnvelope[]; const combined = [...fresh, ...existing]; - // Over the cap: keep the NEWEST `limit` events (drop the oldest prepended). - return combined.length > limit ? combined.slice(-limit) : combined; + // Scroll-back is a sliding window. Once the resident cap is full, keep the + // newly requested OLDEST side and evict the newest tail; otherwise every + // page beyond the cap is fetched and immediately discarded. The caller + // marks the session detached and rehydrates the authoritative tail on End. + return combined.length > limit ? combined.slice(0, limit) : combined; } /** @@ -124,6 +127,41 @@ export type OlderHistoryCursorAdvance = { hasMore: boolean; }; +export type OlderHistoryStatus = "loading" | "available" | "exhausted" | "error"; + +/** + * Rebuild the authoritative latest tail after a detached sliding-window view. + * The detached resident window is intentionally excluded by callers: it + * contains the oldest loaded events, not a continuation of this fresh tail. + * Live events buffered while detached are appended and deduped at the seam. + */ +export function mergeDetachedTuiHistoryTail( + snapshotTail: readonly AgentChatEventEnvelope[], + bufferedLiveEvents: readonly AgentChatEventEnvelope[], +): AgentChatEventEnvelope[] { + const combined = [...snapshotTail, ...bufferedLiveEvents]; + return dedupeTuiEvents( + combined, + Math.min(TUI_LOADED_EVENT_CAP, Math.max(1, combined.length)), + ); +} + +export function shouldRequestOlderTuiHistory(args: { + scrollMaxOffset: number; + scrollOffset: number; + bufferedEventCount: number; + cursor: { hasMore: boolean; loading: boolean } | null; + status: OlderHistoryStatus | null; +}): boolean { + if (args.status === "loading") return false; + if (args.scrollMaxOffset > 0 && args.scrollOffset < args.scrollMaxOffset - 3) return false; + // A cached snapshot remainder is local and remains immediately usable even + // when background revalidation of the remote byte cursor failed. + if (args.bufferedEventCount > 0) return true; + if (args.status === "error") return false; + return Boolean(args.cursor?.hasMore && !args.cursor.loading); +} + /** * Advance the scroll-back cursor after one page arrives. * @@ -131,14 +169,12 @@ export type OlderHistoryCursorAdvance = { * `startOffset` becomes the next `beforeOffset` and must be strictly * decreasing; `startOffset` 0 means the head of the transcript was reached. * A non-decreasing offset would loop forever, so it defensively ends paging. - * Paging also ends once `loadedEventCount` reaches the resident-event cap. */ export function advanceOlderHistoryCursor( cursor: OlderHistoryCursorAdvance, - page: { startOffset: number; hasMore: boolean; sessionFound?: boolean }, - loadedEventCount: number, - cap = TUI_LOADED_EVENT_CAP, + page: { startOffset: number; hasMore: boolean; sessionFound?: boolean; unavailable?: boolean }, ): OlderHistoryCursorAdvance { + if (page.unavailable === true) return cursor; if (page.sessionFound === false) { return { beforeOffset: cursor.beforeOffset, hasMore: false }; } @@ -148,6 +184,6 @@ export function advanceOlderHistoryCursor( } return { beforeOffset: page.startOffset, - hasMore: page.hasMore && page.startOffset > 0 && loadedEventCount < cap, + hasMore: page.hasMore && page.startOffset > 0, }; } diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 135ecf3a3..6772b7c5d 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7251,7 +7251,7 @@ export function registerIpc({ ipcMain.handle(IPC.agentChatGetEventHistory, async ( _event, - arg: { sessionId?: string; maxEvents?: number }, + arg: { sessionId?: string; maxEvents?: number; maxBytes?: number }, ): Promise => { const ctx = getCtx(); const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; @@ -7271,7 +7271,17 @@ export function registerIpc({ rawMaxEvents != null && Number.isFinite(rawMaxEvents) && rawMaxEvents > 0 ? rawMaxEvents : undefined; - const options = maxEvents != null ? { maxEvents } : undefined; + const rawMaxBytes = typeof arg?.maxBytes === "number" ? arg.maxBytes : undefined; + const maxBytes = + rawMaxBytes != null && Number.isFinite(rawMaxBytes) && rawMaxBytes > 0 + ? rawMaxBytes + : undefined; + const options = maxEvents != null || maxBytes != null + ? { + ...(maxEvents != null ? { maxEvents } : {}), + ...(maxBytes != null ? { maxBytes } : {}), + } + : undefined; return await service.getChatEventHistory(sessionId, options); }); diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index fcbd3e719..b80f41047 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -2301,11 +2301,14 @@ describe("registerIpc sync bridge", () => { await expect( ipcHandlers.get(IPC.agentChatGetEventHistory)?.( eventForSender(), - { sessionId: " chat-1 ", maxEvents: 25 }, + { sessionId: " chat-1 ", maxEvents: 25, maxBytes: 256 * 1024 }, ), ).resolves.toBe(snapshot); - expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 25 }); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { + maxEvents: 25, + maxBytes: 256 * 1024, + }); }); it("validates and forwards main transcript requests", async () => { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ed3fe4891..d4fce1a1b 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1672,6 +1672,7 @@ declare global { args: { sessionId: string; maxEvents?: number; + maxBytes?: number; }, pin?: OpenProjectBinding | null, ) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 84e4ff191..923a2d8e4 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -6476,7 +6476,11 @@ describe("per-chat runtime routing", () => { throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); }); - await bridge.agentChat.getEventHistory({ sessionId: "chat-on-a", maxEvents: 5 }, machineA); + await bridge.agentChat.getEventHistory({ + sessionId: "chat-on-a", + maxEvents: 5, + maxBytes: 256 * 1024, + }, machineA); const callback = vi.fn(); const unsubscribe = bridge.agentChat.onEvent(callback, machineA); await vi.advanceTimersByTimeAsync(0); @@ -6500,7 +6504,7 @@ describe("per-chat runtime routing", () => { request: { domain: "chat", action: "getChatEventHistory", - argsList: ["chat-on-a", { maxEvents: 5 }], + argsList: ["chat-on-a", { maxEvents: 5, maxBytes: 256 * 1024 }], }, }); expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeStreamEvents, { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 87a538c2a..7a8f5d711 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -6305,16 +6305,21 @@ contextBridge.exposeInMainWorld("ade", { args: { sessionId: string; maxEvents?: number; + maxBytes?: number; }, pin?: OpenProjectBinding | null, ): Promise => { + const historyOptions = { + ...(args.maxEvents != null ? { maxEvents: args.maxEvents } : {}), + ...(args.maxBytes != null ? { maxBytes: args.maxBytes } : {}), + }; if (pin) { return callPinnedRuntimeAction(pin, "chat", "getChatEventHistory", { - argsList: [args.sessionId, { maxEvents: args.maxEvents }], + argsList: [args.sessionId, historyOptions], }); } const runtime = await callProjectRuntimeActionIfBound("chat", "getChatEventHistory", { - argsList: [args.sessionId, { maxEvents: args.maxEvents }], + argsList: [args.sessionId, historyOptions], }); if (runtime.handled) return runtime.result; // Read-only chat calls are intentionally left unhandled while a project diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 520b63fb2..b9123ba81 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -6917,6 +6917,10 @@ function AgentChatMessageListMain({ entries={minimapSourceEntries} activeIndex={activeFullUserOrdinal} onJumpToRow={jumpToRowFromMinimap} + hasOlderHistory={hasOlderHistory} + loadingOlderHistory={loadingOlderHistory} + olderHistoryError={olderHistoryError} + onLoadOlderHistory={onLoadOlderHistory} listWidthPx={listRootBoxPx.width} listHeightPx={listRootBoxPx.height} listTopViewportPx={listRootBoxPx.top} diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 787a68c3f..28ed9ac17 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -8922,6 +8922,53 @@ describe("AgentChatPane per-chat runtime routing", () => { expect(useAppStore.getState().projectBinding).toEqual(machineA); }); + it("routes a prop-driven incoming local chat independently of the outgoing remote selection", async () => { + bindWindowToMachineA(); + const outgoing = buildSession("chat-on-b", { laneId: "lane-b", title: "Outgoing remote chat" }); + const incoming = buildSession("chat-on-a", { laneId: "lane-a", title: "Incoming local chat" }); + installAdeMocks({ + sessions: [outgoing, incoming], + eventHistory: (args) => emptyHistory(args.sessionId), + }); + + const view = render( + + + , + ); + const getEventHistory = window.ade.agentChat.getEventHistory as ReturnType; + await waitFor(() => expect(getEventHistory).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: outgoing.sessionId }), + machineB, + )); + + getEventHistory.mockClear(); + view.rerender( + + + , + ); + + await waitFor(() => expect(getEventHistory).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: incoming.sessionId }), + )); + expect(getEventHistory).not.toHaveBeenCalledWith( + expect.objectContaining({ sessionId: incoming.sessionId }), + machineB, + ); + expect(useAppStore.getState().projectBinding).toEqual(machineA); + }); + it("does not pin when the lane's machine is not open in this window", async () => { bindWindowToMachineA({ includeMachineB: false }); const session = buildSession("chat-on-b", { laneId: "lane-b", title: "Unreachable machine" }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c8a10bb06..594b640da 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -760,6 +760,8 @@ const CHAT_HISTORY_PAGE_MAX_BYTES = 256 * 1024; const OLDER_HISTORY_RETRY_DELAYS_MS = [800, 2_400]; const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; const MAX_SELECTED_CHAT_SESSION_EVENTS = 20_000; +const INITIAL_SELECTED_CHAT_HISTORY_EVENTS = 1_000; +const INITIAL_SELECTED_CHAT_HISTORY_BYTES = 256 * 1024; const MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS = 60_000; const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; const MAX_SELECTED_CHAT_SESSION_RESIDENT_BYTES = 32 * 1024 * 1024; @@ -795,7 +797,13 @@ const EMPTY_CHAT_PIN_ARGS: readonly [] = []; function chatPinArgsFor( ref: { current: OpenProjectBinding | null }, ): readonly [] | readonly [OpenProjectBinding] { - return ref.current ? [ref.current] : EMPTY_CHAT_PIN_ARGS; + return chatPinArgsForBinding(ref.current); +} + +function chatPinArgsForBinding( + pin: OpenProjectBinding | null, +): readonly [] | readonly [OpenProjectBinding] { + return pin ? [pin] : EMPTY_CHAT_PIN_ARGS; } type DraftLaunchLaneTarget = { @@ -3963,6 +3971,15 @@ export function AgentChatPane({ const preserveDraftAcrossMachineSwitchRef = useRef(false); const recoveredParallelLaunchKeyRef = useRef(null); const paneMountedRef = useRef(true); + // `selectedSessionId` trails a prop-driven chat switch by one render. Resolve + // the visible session before deriving either its events or its runtime pin so + // Retry can never send the incoming session id to the outgoing machine. + const renderedSessionId = resolveRenderedChatSessionId({ + lockSessionId, + initialSessionId, + appliedInitialSessionId: appliedInitialSessionIdRef.current, + selectedSessionId, + }); const selectedSession = useMemo( () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), [sessions, selectedSessionId] @@ -3985,6 +4002,27 @@ export function AgentChatPane({ ); const chatRuntimePinRef = useRef(chatRuntimePin); chatRuntimePinRef.current = chatRuntimePin; + const renderedSession = useMemo( + () => ( + renderedSessionId + ? sessions.find((session) => session.sessionId === renderedSessionId) + ?? (initialSessionSummary?.sessionId === renderedSessionId ? initialSessionSummary : null) + : null + ), + [initialSessionSummary, renderedSessionId, sessions], + ); + const foreignRenderedLaneId = useRootAppStore((state) => { + if (!renderedSessionId || renderedSession) return null; + for (const machine of Object.values(state.crossMachineLanesByMachineId)) { + const session = machine.sessions.find((candidate) => candidate.id === renderedSessionId); + if (session) return session.laneId; + } + return null; + }); + const renderedChatRuntimePin = useMemo( + () => chatMachineRouter.pinForLane(renderedSession?.laneId ?? foreignRenderedLaneId ?? laneId), + [chatMachineRouter, foreignRenderedLaneId, laneId, renderedSession?.laneId], + ); turnActiveBySessionRef.current = turnActiveBySession; const promptSuggestion = selectedSessionId ? promptSuggestionsBySession[selectedSessionId] ?? null : null; @@ -4151,12 +4189,6 @@ export function AgentChatPane({ // at a different chat. Render against the incoming (prop-derived) id instead: // the live event map when it already holds that chat, else its cached view, // else nothing — never the previous chat's events. - const renderedSessionId = resolveRenderedChatSessionId({ - lockSessionId, - initialSessionId, - appliedInitialSessionId: appliedInitialSessionIdRef.current, - selectedSessionId, - }); const selectedEvents = renderedSessionId ? eventsBySession[renderedSessionId] ?? peekAgentChatSessionViewCache(renderedSessionId)?.events @@ -6113,7 +6145,16 @@ export function AgentChatPane({ return true; }, [applyOlderHistoryCursor, initialSessionSummary]); - const loadHistory = useCallback(async (sessionId: string, options?: { force?: boolean }) => { + const loadHistory = useCallback(async ( + sessionId: string, + options?: { force?: boolean; pin?: OpenProjectBinding | null }, + ) => { + // Freeze routing for this request. Reading chatRuntimePinRef after an await + // can cross a project transition and splice the result into the wrong chat. + const historyPin = options && "pin" in options + ? options.pin ?? null + : chatRuntimePinRef.current; + const historyPinArgs = chatPinArgsForBinding(historyPin); if (options?.force) { loadedHistoryRef.current.delete(sessionId); } @@ -6165,8 +6206,9 @@ export function AgentChatPane({ if (typeof window.ade.agentChat.getEventHistory === "function") { const snapshot: AgentChatEventHistorySnapshot = await window.ade.agentChat.getEventHistory({ sessionId, - maxEvents: MAX_SELECTED_CHAT_SESSION_EVENTS, - }, ...chatPinArgsFor(chatRuntimePinRef)); + maxEvents: INITIAL_SELECTED_CHAT_HISTORY_EVENTS, + maxBytes: INITIAL_SELECTED_CHAT_HISTORY_BYTES, + }, ...historyPinArgs); if (snapshot?.sessionId === sessionId && snapshot.unavailable === true) { applyHistoryMiss({ unavailable: true }); return; @@ -6176,7 +6218,7 @@ export function AgentChatPane({ return; } if (snapshot?.sessionId === sessionId && !snapshot.events?.length && snapshot.sessionFound !== true) { - const summary = await window.ade.agentChat.getSummary({ sessionId }, ...chatPinArgsFor(chatRuntimePinRef)).catch(() => null); + const summary = await window.ade.agentChat.getSummary({ sessionId }, ...historyPinArgs).catch(() => null); if (!summary) { applyHistoryMiss({ unavailable: snapshot.unavailable }); return; @@ -6192,7 +6234,7 @@ export function AgentChatPane({ usedSnapshotPath = false; } if (!usedSnapshotPath) { - const summary = await window.ade.sessions.get(sessionId, ...chatPinArgsFor(chatRuntimePinRef)); + const summary = await window.ade.sessions.get(sessionId, ...historyPinArgs); if (!summary || !isChatToolType(summary.toolType)) { // Clear the loaded flag so a subsequent remount/tab switch can retry. // Without this, a transient lookup miss (e.g. session summary not yet @@ -6205,7 +6247,7 @@ export function AgentChatPane({ sessionId, maxBytes: CHAT_HISTORY_READ_MAX_BYTES, raw: true - }, ...chatPinArgsFor(chatRuntimePinRef)); + }, ...historyPinArgs); parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); } @@ -6299,7 +6341,10 @@ export function AgentChatPane({ * shown: a single blip on a remote hop should not make the user look at (and * press) a retry affordance for something we can just do ourselves. */ - const loadOlderHistory = useCallback(async (sessionId: string) => { + const loadOlderHistory = useCallback(async ( + sessionId: string, + pin: OpenProjectBinding | null, + ) => { const cursor = olderHistoryCursorRef.current[sessionId]; if (cursor == null || cursor <= 0) return; if (olderHistoryInFlightRef.current.has(sessionId)) return; @@ -6319,7 +6364,7 @@ export function AgentChatPane({ sessionId, beforeOffset, maxBytes: CHAT_HISTORY_PAGE_MAX_BYTES, - }, ...chatPinArgsFor(chatRuntimePinRef)); + }, ...chatPinArgsForBinding(pin)); // `unavailable` is "we could not reach the runtime", NOT "this chat is // gone" — it arrives with `sessionFound: false` (preload synthesises // exactly that shape), so it MUST be caught first. Treating it as a @@ -6420,11 +6465,6 @@ export function AgentChatPane({ } }, [applyOlderHistoryCursor, lockSessionId, waitBeforeOlderHistoryRetry]); - const loadOlderHistoryForSelectedSession = useCallback(() => { - const sessionId = selectedSessionIdRef.current; - if (sessionId) void loadOlderHistory(sessionId); - }, [loadOlderHistory]); - // Cancel pending paging retries when the selection moves or the pane // unmounts, so no timer outlives the view that scheduled it. useEffect(() => () => cancelOlderHistoryRetryWaits(), [cancelOlderHistoryRetryWaits, selectedSessionId]); @@ -7181,13 +7221,12 @@ export function AgentChatPane({ setPendingSteersBySession((steerPrev) => ({ ...steerPrev, ...pendingSteerPatch })); }, [initialSessionSummary, lockSessionId]); - const returnSelectedHistoryToLatest = useCallback(() => { - const sessionId = selectedSessionIdRef.current; + const returnHistoryToLatest = useCallback((sessionId: string, pin: OpenProjectBinding | null) => { if (!sessionId || !detachedHistorySessionsRef.current.has(sessionId)) return; // Keep buffering live events until the authoritative tail snapshot lands. // loadHistory clears detached state only after a successful hydrate, so a // transient remote failure leaves the historical window retryable. - void loadHistory(sessionId, { force: true }); + void loadHistory(sessionId, { force: true, pin }); }, [loadHistory]); const scheduleQueuedEventFlush = useCallback(() => { @@ -12753,8 +12792,16 @@ export function AgentChatPane({ ? olderHistoryErrorBySession[renderedSessionId] ?? null : null } - onLoadOlderHistory={!subagentView && renderedSessionId ? loadOlderHistoryForSelectedSession : undefined} - onReturnToLatest={!subagentView ? returnSelectedHistoryToLatest : undefined} + onLoadOlderHistory={ + !subagentView && renderedSessionId + ? () => { void loadOlderHistory(renderedSessionId, renderedChatRuntimePin); } + : undefined + } + onReturnToLatest={ + !subagentView && renderedSessionId + ? () => returnHistoryToLatest(renderedSessionId, renderedChatRuntimePin) + : undefined + } respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} laneId={laneId} diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx index 48ac189f8..c9a5acdbf 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.test.tsx @@ -64,4 +64,47 @@ describe("ChatUserMinimap", () => { expect(hoveredTick?.className).toContain("bg-[var(--color-fg)]/75"); expect(hoveredTick?.className).toContain("w-6"); }); + + it("keeps the paging marker visible and stateful before the loaded cutoff", () => { + const onLoadOlderHistory = vi.fn(); + const view = render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Load earlier message markers" })); + + expect(onLoadOlderHistory).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("chat-user-minimap")).toBeTruthy(); + expect(document.querySelectorAll("[data-minimap-tick]")).toHaveLength(0); + + view.rerender( + , + ); + + const marker = screen.getByRole("button", { name: "Loading earlier message markers" }); + expect(marker.hasAttribute("disabled")).toBe(true); + expect(marker.getAttribute("title")).toBe("Earlier messages are available"); + expect(screen.getByTestId("chat-user-minimap")).toBeTruthy(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx index 9ae858966..c5f51d790 100644 --- a/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatUserMinimap.tsx @@ -22,6 +22,14 @@ type ChatUserMinimapProps = { /** `activeFullUserOrdinal` — ticks are 1:1 with entries, so this is already an index. */ activeIndex: number | null; onJumpToRow: (rowIndex: number) => void; + /** Older transcript pages exist before the currently resident row window. */ + hasOlderHistory?: boolean; + /** Keeps the continuation marker stable while its page is in flight. */ + loadingOlderHistory?: boolean; + /** Retry detail for the continuation marker; exposed as a tooltip. */ + olderHistoryError?: string | null; + /** Pages the next older transcript window without loading the whole file. */ + onLoadOlderHistory?: () => void; /** Measured width of the message-list root. */ listWidthPx: number; /** Measured height of the message-list root. */ @@ -88,6 +96,10 @@ export function ChatUserMinimap({ entries, activeIndex, onJumpToRow, + hasOlderHistory = false, + loadingOlderHistory = false, + olderHistoryError = null, + onLoadOlderHistory, listWidthPx, listHeightPx, listTopViewportPx, @@ -149,14 +161,25 @@ export function ChatUserMinimap({ const hoverEntry = resolvedHoverIndex === null ? null : (entries[resolvedHoverIndex] ?? null); const hoverOutcomeLabel = turnOutcomeLabel(hoverEntry?.turnOutcome ?? null); - // A single tick is a rail with nothing to navigate between. - if (!chatUserMinimapEnabled || itemCount < 2 || minimapRailInert(availablePx)) { + // Keep a durable continuation marker when the resident tail has fewer than + // two user turns. Otherwise the whole rail disappears at the transcript + // cutoff and falsely implies that the loaded window is the complete chat. + if ( + !chatUserMinimapEnabled + || (itemCount < 2 && !hasOlderHistory) + || minimapRailInert(availablePx) + ) { return null; } const ariaLabel = `Jump to message: ${hoverEntry?.preview ?? "User message"}${ hoverOutcomeLabel ? ` (${hoverOutcomeLabel})` : "" }`; + const continuationLabel = olderHistoryError + ? "Retry loading earlier message markers" + : loadingOlderHistory + ? "Loading earlier message markers" + : "Load earlier message markers"; return (
- + ) : null} + {itemCount > 0 ? ( + + + ) : null}
); diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index c3b5e6aac..9126e4192 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -373,6 +373,36 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("uses the legacy history-page alias when that is all the connected host advertises", async () => { + fake.descriptors = descriptors(["agentChat.getEventHistoryPage"]); + fake.commandResults.set("agentChat.getEventHistoryPage", { + sessionId: "chat-long-running", + events: [], + startOffset: 1024, + hasMore: true, + sessionFound: true, + }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + + await adapter.ade.agentChat.getEventHistoryPage({ + sessionId: "chat-long-running", + beforeOffset: 4096, + }); + + const call = fake.commandCalls.at(-1); + expect(call?.action).toBe("agentChat.getEventHistoryPage"); + expect(call?.args).toMatchObject({ + sessionId: "chat-long-running", + beforeOffset: 4096, + maxBytes: 256 * 1024, + }); + expect(fake.commandCalls).toContainEqual(expect.objectContaining({ + action: "agentChat.getEventHistoryPage", + })); + adapter.dispose(); + }); + it("keeps only the eight most recently used project chat subscriptions", async () => { fake.descriptors = descriptors(["chat.getChatEventHistory"]); const adapter = createAdeWebAdapter(fake.asClient()); diff --git a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts index 2ad9dffdc..b385313cb 100644 --- a/apps/desktop/src/renderer/webclient/adapter/agentChat.ts +++ b/apps/desktop/src/renderer/webclient/adapter/agentChat.ts @@ -322,8 +322,13 @@ export function createAgentChatNamespace(infra: AdapterInfra): AdeNamespace<"age getEventHistoryPage: async (args: unknown) => { const record = asRecord(args); ensureChatSubscription(stringField(record, "sessionId"), { visible: true }); + const historyPageAction = commands.hasAction("chat.getChatEventHistoryPage") + ? "chat.getChatEventHistoryPage" + : commands.hasAction("agentChat.getEventHistoryPage") + ? "agentChat.getEventHistoryPage" + : "chat.getChatEventHistoryPage"; return await callRequiredRead( - "chat.getChatEventHistoryPage", + historyPageAction, { ...record, maxBytes: boundedPositiveInteger( diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 8ae99e2fa..c2dbf35e1 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -3,6 +3,7 @@ import type { AgentChatCodexConfigSource, AgentChatCodexSandbox, AgentChatEventEnvelope, + AgentChatEventHistoryPage, AgentChatPermissionMode, } from "./chat"; import type { PersonalChatRemoteCommandAction } from "./personalChats"; @@ -483,6 +484,14 @@ export type SyncFeatureFlags = { crossProjectChat?: { enabled: boolean; }; + /** + * Cursor-paged chat history over the already-authorized chat subscription. + * Works for project, personal, and foreign-project quick-look scopes without + * activating a runtime just to read older transcript bytes. + */ + chatHistoryPaging?: { + enabled: true; + }; projectCatalog: { enabled: boolean; }; @@ -1256,6 +1265,12 @@ export type SyncChatSubscribeSnapshotPayload = { sessionId: string; capturedAt: string; truncated: boolean; + /** Logical byte cursor for the first event represented by this tail. */ + tailStartOffset?: number; + /** Authoritative older-page availability for this snapshot. */ + hasOlderHistory?: boolean; + /** Additive cursor discriminator for future non-file transcript stores. */ + cursorKind?: "byte"; events: AgentChatEventEnvelope[]; /** * True when the host honored `sinceSeq` and replayed buffered events @@ -1285,6 +1300,13 @@ export type SyncChatUnsubscribePayload = { projectRootPath?: string; }; +export type SyncChatHistoryRequestPayload = SyncChatUnsubscribePayload & { + beforeOffset: number; + maxBytes?: number; +}; + +export type SyncChatHistoryResponsePayload = AgentChatEventHistoryPage; + /** * Live chat event envelope. `seq` is a host-assigned, per-session, * monotonically increasing counter used for resumable streams: clients track @@ -1808,6 +1830,10 @@ 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 SyncChatHistoryEnvelope = SyncEnvelopeWithPayload< + "chat_history", + SyncChatHistoryRequestPayload | SyncChatHistoryResponsePayload +>; export type SyncBrainStatusEnvelope = SyncEnvelopeWithPayload<"brain_status", SyncBrainStatusPayload>; export type SyncPrsUpdatedEnvelope = SyncEnvelopeWithPayload<"prs_updated", { updatedAt: string }>; export type SyncRosterSubscribeEnvelope = SyncEnvelopeWithPayload<"roster_subscribe", SyncRosterSubscribePayload>; @@ -1879,6 +1905,7 @@ export type SyncEnvelope = | SyncChatSubscribeEnvelope | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope + | SyncChatHistoryEnvelope | SyncBrainStatusEnvelope | SyncPrsUpdatedEnvelope | SyncRosterSubscribeEnvelope diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index b0bdadf92..891284459 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2069,6 +2069,7 @@ struct AgentChatEventHistoryPage: Decodable, Equatable { var startOffset: Int var hasMore: Bool var sessionFound: Bool + var unavailable: Bool? } struct AgentChatFileRef: Codable, Equatable, Hashable { @@ -3066,6 +3067,9 @@ struct SyncChatSubscribeSnapshotPayload: Decodable, Equatable { var sessionId: String var capturedAt: String var truncated: Bool + var tailStartOffset: Int? + var hasOlderHistory: Bool? + var cursorKind: String? @ADELossyArray var events: [AgentChatEventEnvelope] /// Live turn state from the host's agent chat service at subscribe time. /// Snapshots are byte-capped transcript tails, so a long turn's diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index d39776e5b..436389b8b 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -519,13 +519,12 @@ private let syncTerminalSubscriptionMaxBytes = 240_000 /// real scrollback instead of a trimmed preview string. private let syncTerminalStreamMaxBytes = 512_000 private let syncTerminalHistoryMaxBytes = 262_144 -private let syncChatSubscriptionMaxBytes = 2_000_000 +private let syncChatSubscriptionMaxBytes = 256 * 1024 private let syncChatHistoryTailPageProbeOffset = 1_000_000_000 private let syncChatHistoryTailPageMaxBytes = 600_000 -// 512KB, up from 160KB: the old budget silently truncated reasoning-heavy -// turns on cellular/Tailscale routes. Chunked envelopes plus off-main decode -// make the larger snapshot cheap to receive. -private let syncReducedLoadChatSubscriptionMaxBytes = 512_000 +// A bounded tail keeps chat switches instant on constrained routes. Complete +// reasoning remains reachable through the same byte-cursor paging contract. +private let syncReducedLoadChatSubscriptionMaxBytes = 256 * 1024 private let syncTerminalBufferMaxCharacters = 240_000 private let chatEventHistoryMaxEvents = 1_000 private let chatEventHistoryMaxSessions = 64 @@ -2270,11 +2269,54 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St "terminal_history", "chat_subscribe", "chat_unsubscribe", + "chat_history", ] guard projectScopedTypes.contains(type) else { return nil } return syncNormalizedCommandScopeValue(activeProjectId) } +/// Resolve the cursor state carried by a full `chat_subscribe` snapshot. +/// +/// `nil` means the snapshot did not establish a cursor (legacy host, or a +/// malformed modern ack) and callers must preserve any canonical-history +/// cursor they already have. Only an explicit `hasOlderHistory: false` is an +/// authoritative exhausted cursor. +func syncChatSubscribeHistoryCursor( + hasOlderHistory: Bool?, + tailStartOffset: Int?, + cursorKind: String? = nil +) -> Int? { + if hasOlderHistory == false { return 0 } + if let cursorKind, cursorKind != "byte" { return nil } + guard let tailStartOffset, tailStartOffset > 0 else { return nil } + return tailStartOffset +} + +/// Resolve the next stored cursor from a validated history page. +/// +/// `nil` means preserve the existing cursor because the response is +/// unavailable, mismatched, or non-progressing. A returned `0` is the +/// authoritative exhausted sentinel. +func syncChatHistoryPageCursor( + requestedSessionId: String, + beforeOffset: Int, + page: AgentChatEventHistoryPage +) -> Int? { + guard page.unavailable != true, + page.sessionId == requestedSessionId, + beforeOffset > 0 + else { return nil } + guard page.sessionFound else { return 0 } + guard page.startOffset >= 0, + page.startOffset < beforeOffset + else { return nil } + if page.hasMore { + guard page.startOffset > 0 else { return nil } + return page.startOffset + } + return 0 +} + /// Lane ids that must be hydrated before a `work.listSessions` snapshot can be /// installed safely. The database deliberately rejects sessions whose lane is /// absent, so replacing Work first would silently discard a newly-created @@ -2731,6 +2773,8 @@ final class SyncService: ObservableObject { private var terminalSnapshotRecoveryDelayOverrideForTesting: UInt64? #endif private(set) var chatEventEnvelopesBySession: [String: [AgentChatEventEnvelope]] = [:] + private var chatHistoryCursorBySession: [String: Int] = [:] + private var chatHistoryCursorAdvancedByPageSessionIds = Set() private(set) var chatEventRevisionsBySession: [String: Int] = [:] /// Highest host-assigned `seq` applied per chat session. Sent back as /// `sinceSeq` on re-subscribe so the host can replay exactly the missed @@ -2937,6 +2981,7 @@ final class SyncService: ObservableObject { private var supportsProjectCatalog = false private var supportsProjectActions = false private var supportsChatStreaming = false + private var supportsChatHistoryPaging = false private let chatSnapshotRequestCoalescingInterval: TimeInterval = 5 private let chatEventUnsubscribeRetentionLimit = 4 private var recentFullChatSnapshotRequestBySession: [ @@ -8394,6 +8439,10 @@ final class SyncService: ObservableObject { requestId: nil, payload: payload ) else { return false } + // This subscribe establishes a fresh snapshot generation. If a history + // page lands before its ack, recording that page re-arms the stale-ack + // guard below. + chatHistoryCursorAdvancedByPageSessionIds.remove(trimmedSessionId) if requestSnapshot { recentFullChatSnapshotRequestBySession[trimmedSessionId] = ( uptime: requestUptime, @@ -8500,6 +8549,21 @@ final class SyncService: ObservableObject { chatEventEnvelopesBySession[sessionId] ?? [] } + func chatOlderHistoryCursor(sessionId: String) -> Int? { + guard let cursor = chatHistoryCursorBySession[sessionId], cursor > 0 else { return nil } + return cursor + } + + /// `nil` means no full subscribe snapshot has established a cursor yet; + /// `0` is an authoritative exhausted cursor. + func chatOlderHistoryCursorState(sessionId: String) -> Int? { + chatHistoryCursorBySession[sessionId] + } + + func supportsSubscribedChatHistory(sessionId: String) -> Bool { + supportsChatHistoryPaging && subscribedChatSessionIds.contains(sessionId) + } + @discardableResult func pruneChatEventHistory(sessionId: String, keepingTail limit: Int) -> [AgentChatEventEnvelope] { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) @@ -9740,8 +9804,11 @@ final class SyncService: ObservableObject { beforeOffset: syncChatHistoryTailPageProbeOffset, maxBytes: syncChatHistoryTailPageMaxBytes ) - guard page.sessionFound != false else { return page } - mergeChatEventHistory(sessionId: page.sessionId, events: page.events) + guard page.unavailable != true, + page.sessionFound, + page.sessionId == sessionId + else { return page } + mergeChatEventHistory(sessionId: sessionId, events: page.events) return page } @@ -9750,18 +9817,54 @@ final class SyncService: ObservableObject { beforeOffset: Int, maxBytes: Int? = nil ) async throws -> AgentChatEventHistoryPage { + if supportsChatHistoryPaging, + subscribedChatSessionIds.contains(sessionId) { + let requestId = makeRequestId() + var payload = chatSubscriptionPayload( + sessionId: sessionId, + maxBytes: nil, + includeSinceSeq: false + ) + payload["beforeOffset"] = max(0, beforeOffset) + if let maxBytes, maxBytes > 0 { + payload["maxBytes"] = max(1_024, min(syncChatHistoryTailPageMaxBytes, maxBytes)) + } + let raw = try await awaitResponse( + requestId: requestId, + disconnectOnTimeout: false, + timeoutMessage: "Timed out loading earlier chat messages.", + timeoutNanoseconds: 8_000_000_000 + ) { + self.sendEnvelope(type: "chat_history", requestId: requestId, payload: payload) + } + let page = try decode(raw, as: AgentChatEventHistoryPage.self) + recordChatHistoryPageCursor( + requestedSessionId: sessionId, + beforeOffset: beforeOffset, + page: page + ) + return page + } var args: [String: Any] = ["sessionId": sessionId, "beforeOffset": beforeOffset] if let maxBytes, maxBytes > 0 { args["maxBytes"] = maxBytes } let scope = chatCommandScope(for: sessionId) - return try await sendDecodableCommand( + let page = try await sendDecodableCommand( action: chatActionName("chat.getChatEventHistoryPage", sessionId: sessionId), args: args, + disconnectOnTimeout: false, + timeoutNanoseconds: 8_000_000_000, targetProjectId: scope.projectId, targetProjectRootPath: scope.rootPath, as: AgentChatEventHistoryPage.self ) + recordChatHistoryPageCursor( + requestedSessionId: sessionId, + beforeOffset: beforeOffset, + page: page + ) + return page } func fetchChatTranscriptResponse(sessionId: String, limit: Int = 500, maxChars: Int = 600_000) async throws -> AgentChatTranscriptResponse { @@ -13889,6 +13992,30 @@ final class SyncService: ObservableObject { completesCapturedRefreshRequestsForTesting = false } + func seedChatHistoryCursorForTesting( + sessionId: String, + cursor: Int, + allowForward: Bool = true + ) { + updateChatHistoryCursor( + sessionId: sessionId, + cursor: cursor, + allowForward: allowForward + ) + } + + func recordChatHistoryPageCursorForTesting( + requestedSessionId: String, + beforeOffset: Int, + page: AgentChatEventHistoryPage + ) { + recordChatHistoryPageCursor( + requestedSessionId: requestedSessionId, + beforeOffset: beforeOffset, + page: page + ) + } + private func capturedRefreshResponseForTesting(type: String, payload: Any) -> Any? { if type == "project_catalog_request" { return ["projects": [Any]()] as [String: Any] @@ -13990,6 +14117,7 @@ final class SyncService: ObservableObject { return false } supportsChatStreaming = featureEnabled("chatStreaming", "chat_streaming") + supportsChatHistoryPaging = featureEnabled("chatHistoryPaging", "chat_history_paging") supportsCrossProjectChat = featureEnabled("crossProjectChat", "cross_project_chat") supportsPersonalChats = false supportsProjectCatalog = featureEnabled("projectCatalog", "project_catalog") @@ -14468,7 +14596,7 @@ 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": + case "command_result", "file_response", "terminal_snapshot", "terminal_history", "chat_history": resolve(requestId: requestId, result: .success(payload)) case "chat_subscribe": if supportsChatStreaming, @@ -14485,6 +14613,17 @@ final class SyncService: ObservableObject { // from the next live event — otherwise we would discard the first // events of the new stream as "old". chatEventLastSeqBySession.removeValue(forKey: snapshot.sessionId) + if let cursor = syncChatSubscribeHistoryCursor( + hasOlderHistory: snapshot.hasOlderHistory, + tailStartOffset: snapshot.tailStartOffset, + cursorKind: snapshot.cursorKind + ) { + updateChatHistoryCursor( + sessionId: snapshot.sessionId, + cursor: cursor, + allowForward: !chatHistoryCursorAdvancedByPageSessionIds.contains(snapshot.sessionId) + ) + } } if resumed { mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) @@ -15977,6 +16116,46 @@ final class SyncService: ObservableObject { markChatEventsChanged(immediate: true) } + private func updateChatHistoryCursor( + sessionId: String, + cursor: Int, + allowForward: Bool = false + ) { + let normalizedCursor = max(0, cursor) + if let existingCursor = chatHistoryCursorBySession[sessionId], + normalizedCursor > existingCursor, + !allowForward { + return + } + guard chatHistoryCursorBySession[sessionId] != normalizedCursor else { return } + chatHistoryCursorBySession[sessionId] = normalizedCursor + // The destination's live observation task owns both transcript events and + // the page cursor. Publish cursor-only subscribe acks even when the event + // tail was identical to the cached window. + chatEventRevisionsBySession[sessionId, default: 0] += 1 + markChatEventsChanged(immediate: true) + } + + private func recordChatHistoryPageCursor( + requestedSessionId: String, + beforeOffset: Int, + page: AgentChatEventHistoryPage + ) { + guard let cursor = syncChatHistoryPageCursor( + requestedSessionId: requestedSessionId, + beforeOffset: beforeOffset, + page: page + ) else { return } + // Only the page for the cursor we still own may advance it. A duplicate or + // delayed response must not overwrite a newer page or full snapshot. + if let currentCursor = chatHistoryCursorBySession[requestedSessionId], + currentCursor != beforeOffset { + return + } + chatHistoryCursorAdvancedByPageSessionIds.insert(requestedSessionId) + updateChatHistoryCursor(sessionId: requestedSessionId, cursor: cursor) + } + private func deduplicatedChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { var seen = Set() var unique: [AgentChatEventEnvelope] = [] @@ -16279,6 +16458,8 @@ final class SyncService: ObservableObject { subscribedChatSessionIds.removeAll() chatSubscriptionsNeedingRemoteActivation.removeAll() recentFullChatSnapshotRequestBySession.removeAll() + chatHistoryCursorBySession.removeAll() + chatHistoryCursorAdvancedByPageSessionIds.removeAll() // Turn-active hints are scoped to the live connection's event stream — // a stale "running" hint must not survive a project switch or reconnect. chatTurnActiveHintBySession.removeAll() diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index d42f0e4d5..84749423e 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2428,171 +2428,6 @@ private struct WorkPlanCopyButton: View { } } -/// Horizontal chip strip surfacing running/recently-finished subagents above -/// the transcript, so the user can see at a glance what's in flight without -/// hunting through the timeline. -struct WorkSubagentStrip: View { - let snapshots: [WorkSubagentSnapshot] - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var expandedTaskId: String? = nil - - var body: some View { - if snapshots.isEmpty { - EmptyView() - } else { - VStack(alignment: .leading, spacing: 8) { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(snapshots) { snapshot in - Button { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - expandedTaskId = expandedTaskId == snapshot.taskId ? nil : snapshot.taskId - } - } label: { - chipBody(for: snapshot, expanded: expandedTaskId == snapshot.taskId) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel(for: snapshot)) - } - } - .padding(.horizontal, 2) - } - - if let expanded = expandedTaskId, - let snapshot = snapshots.first(where: { $0.taskId == expanded }) { - expandedCard(for: snapshot) - } - } - } - } - - @ViewBuilder - private func chipBody(for snapshot: WorkSubagentSnapshot, expanded: Bool) -> some View { - let tint = tint(for: snapshot.status) - HStack(spacing: 6) { - statusDot(for: snapshot.status, tint: tint) - Text(truncated(workSubagentMeaningfulName(snapshot), limit: 28)) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if snapshot.background { - Image(systemName: "moon.zzz.fill") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(tint.opacity(expanded ? 0.22 : 0.12), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(tint.opacity(expanded ? 0.55 : 0.3), lineWidth: 0.8) - ) - } - - @ViewBuilder - private func statusDot(for status: WorkSubagentSnapshot.Status, tint: Color) -> some View { - switch status { - case .running: - Circle() - .fill(tint) - .frame(width: 7, height: 7) - .overlay(Circle().stroke(tint.opacity(0.3), lineWidth: 2).scaleEffect(1.6)) - case .succeeded: - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(tint) - case .failed: - Image(systemName: "xmark.circle.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(tint) - case .stopped: - Image(systemName: "pause.circle.fill") - .font(.system(size: 11, weight: .bold)) - .foregroundStyle(tint) - } - } - - @ViewBuilder - private func expandedCard(for snapshot: WorkSubagentSnapshot) -> some View { - let tint = tint(for: snapshot.status) - let runtime = workSubagentRuntimeLabel(snapshot) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - statusDot(for: snapshot.status, tint: tint) - Text(workSubagentMeaningfulName(snapshot)) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Spacer(minLength: 6) - Text(statusLabel(for: snapshot.status)) - .font(.caption2.weight(.semibold)) - .foregroundStyle(tint) - } - if let tool = snapshot.lastToolName, !tool.isEmpty { - HStack(spacing: 4) { - Image(systemName: "wrench.and.screwdriver") - .font(.system(size: 9, weight: .semibold)) - Text(tool) - .font(.caption2) - } - .foregroundStyle(ADEColor.textMuted) - } - if let runtime { - HStack(spacing: 4) { - Image(systemName: "cpu") - .font(.system(size: 9, weight: .semibold)) - Text(runtime) - .font(.caption2) - } - .foregroundStyle(ADEColor.textMuted) - } - if let summary = snapshot.latestSummary, !summary.isEmpty { - Text(summary) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(4) - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(ADEColor.surfaceBackground.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(tint.opacity(0.28), lineWidth: 0.75) - ) - .transition(.opacity.combined(with: .move(edge: .top))) - } - - private func tint(for status: WorkSubagentSnapshot.Status) -> Color { - switch status { - case .running: return ADEColor.accent - case .succeeded: return ADEColor.success - case .failed: return ADEColor.danger - case .stopped: return ADEColor.warning - } - } - - private func statusLabel(for status: WorkSubagentSnapshot.Status) -> String { - switch status { - case .running: return "Running" - case .succeeded: return "Done" - case .failed: return "Failed" - case .stopped: return "Halted" - } - } - - private func accessibilityLabel(for snapshot: WorkSubagentSnapshot) -> String { - let status = statusLabel(for: snapshot.status) - return "Subagent \(workSubagentMeaningfulName(snapshot)), \(status). Tap for details." - } - - private func truncated(_ value: String, limit: Int) -> String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.count <= limit { return trimmed } - return String(trimmed.prefix(limit - 1)) + "…" - } -} - struct WorkComposerBadgeCapsule: View { let tint: Color let spacing: CGFloat @@ -3702,8 +3537,8 @@ private let workSubagentIsoFallbackFormatter: ISO8601DateFormatter = { /// Compact in-transcript rows for subagent lifecycle. Mirrors the desktop /// spawn/result/background-chip rows produced by `deriveSubagentTimelineRows` -/// (chatSubagents.ts). Live activity still rides `WorkSubagentStrip`; these rows -/// are hard timeline boundaries anchored where the subagent started and ended. +/// (chatSubagents.ts). These rows are hard timeline boundaries anchored where +/// the subagent started and ended; the full roster lives in Chat Info. struct WorkSubagentTimelineRowView: View { let row: WorkSubagentTimelineRow /// Tapping a real spawn/result row opens the subagent detail/transcript, the diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 0bcc141e9..0b31daab2 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -537,6 +537,12 @@ extension WorkChatSessionView { scrollMetrics = WorkChatScrollMetrics() timelineDragActive = false bottomStickinessReleasedByUser = false + olderHistoryLoadTask?.cancel() + olderHistoryLoadTask = nil + olderHistoryLoadInFlight = false + olderHistoryLoadError = nil + olderHistoryTriggerArmed = true + olderHistoryAutomaticContinuationPending = false pendingInitialBottomPinSessionId = session.id cancelLatestPinTask() timelineIncrementalCache.reset() @@ -747,6 +753,12 @@ extension WorkChatSessionView { scrollMetrics = WorkChatScrollMetrics() timelineDragActive = false bottomStickinessReleasedByUser = false + olderHistoryLoadTask?.cancel() + olderHistoryLoadTask = nil + olderHistoryLoadInFlight = false + olderHistoryLoadError = nil + olderHistoryTriggerArmed = true + olderHistoryAutomaticContinuationPending = false pendingInitialBottomPinSessionId = session.id cancelLatestPinTask() timelineRebuildTask?.cancel() @@ -800,19 +812,67 @@ extension WorkChatSessionView { } @MainActor - func loadEarlierTimelineEntries() { - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - visibleTimelineCount += workTimelinePageSize - refreshTimelinePresentation() + func requestEarlierTimelineEntries(automatically: Bool = false) { + guard !olderHistoryLoadInFlight else { return } + olderHistoryAutomaticContinuationPending = false + olderHistoryLoadError = nil + let revealedBufferedEntries = hiddenTimelineCount > 0 + if hiddenTimelineCount > 0 { + withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + visibleTimelineCount += workTimelinePageSize + refreshTimelinePresentation() + } } // Once the locally-buffered timeline is nearly exhausted, pull the next // older transcript page from the host so scroll-back continues through // the full history instead of stopping at the initial tail fetch. - if hasOlderTranscriptHistory, + if canRequestOlderTranscriptHistory, hiddenTimelineCount <= workTimelinePageSize * 2, let onLoadOlderTranscript { - Task { await onLoadOlderTranscript() } + olderHistoryLoadInFlight = true + let requestedSessionId = session.id + olderHistoryLoadTask = Task { + let result = await onLoadOlderTranscript() + guard !Task.isCancelled, session.id == requestedSessionId else { return } + olderHistoryLoadTask = nil + olderHistoryLoadInFlight = false + if !result.succeeded { + olderHistoryAutomaticContinuationPending = automatically + olderHistoryLoadError = "The connected machine did not return this history page. Your cursor was preserved." + return + } + guard automatically, + hiddenTimelineCount > 0 || result.hasMoreHistory + else { return } + olderHistoryAutomaticContinuationPending = true + if !revealedBufferedEntries, !result.addedTimelineEntries { + continueAutomaticOlderHistoryIfNeeded() + } + } + } else if automatically, hiddenTimelineCount > 0 { + olderHistoryAutomaticContinuationPending = true + } + } + + @MainActor + func continueAutomaticOlderHistoryIfNeeded() { + guard olderHistoryAutomaticContinuationPending, + !olderHistoryLoadInFlight, + olderHistoryLoadError == nil + else { return } + guard workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: scrollMetrics.distanceFromBottom, + loading: olderHistoryLoadInFlight, + hasError: olderHistoryLoadError != nil, + hasBufferedEntries: hiddenTimelineCount > 0, + hasHostHistory: canRequestOlderTranscriptHistory + ) else { + olderHistoryAutomaticContinuationPending = false + return } + olderHistoryAutomaticContinuationPending = false + olderHistoryTriggerArmed = false + requestEarlierTimelineEntries(automatically: true) } @MainActor diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index f0653dcf5..9b1c44adc 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -9,6 +9,57 @@ let workChatTouchScrollDeadband: CGFloat = 2 let workChatBottomAnchorSpacerHeight: CGFloat = 1 let workChatContentBottomGutterHeight: CGFloat = 2 let workChatSubagentActivePopupHeight: CGFloat = 34 +let workChatOlderHistoryTriggerDistance: CGFloat = 240 +let workChatOlderHistoryRearmDistance: CGFloat = 420 +let workChatOlderHistoryScrollableDistance: CGFloat = 1 + +struct WorkChatOlderHistoryLoadResult { + let succeeded: Bool + let hasMoreHistory: Bool + let addedTimelineEntries: Bool + + static let failed = WorkChatOlderHistoryLoadResult( + succeeded: false, + hasMoreHistory: false, + addedTimelineEntries: false + ) + + static func loaded(hasMoreHistory: Bool, addedTimelineEntries: Bool) -> Self { + WorkChatOlderHistoryLoadResult( + succeeded: true, + hasMoreHistory: hasMoreHistory, + addedTimelineEntries: addedTimelineEntries + ) + } +} + +func workChatShouldRequestOlderHistory( + topY: CGFloat, + triggerArmed: Bool, + loading: Bool, + hasError: Bool, + hasBufferedEntries: Bool, + hasHostHistory: Bool +) -> Bool { + topY >= -workChatOlderHistoryTriggerDistance + && triggerArmed + && !loading + && !hasError + && (hasBufferedEntries || hasHostHistory) +} + +func workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: CGFloat, + loading: Bool, + hasError: Bool, + hasBufferedEntries: Bool, + hasHostHistory: Bool +) -> Bool { + distanceFromBottom <= workChatOlderHistoryScrollableDistance + && !loading + && !hasError + && (hasBufferedEntries || hasHostHistory) +} final class WorkChatScrollMetrics { var distanceFromBottom: CGFloat = 0 @@ -163,6 +214,11 @@ struct WorkChatSessionView: View { @State var scrollStateSessionId: String? @State var pendingInitialBottomPinSessionId: String? @State var timelineLayoutPinToken = 0 + @State var olderHistoryLoadInFlight = false + @State var olderHistoryLoadError: String? + @State var olderHistoryTriggerArmed = true + @State var olderHistoryAutomaticContinuationPending = false + @State var olderHistoryLoadTask: Task? let isLive: Bool let hostUnreachable: Bool let canComposeMessages: Bool @@ -204,7 +260,7 @@ struct WorkChatSessionView: View { // Host-side scroll-back: true while older transcript pages remain on the // host beyond what the phone has fetched; the callback pulls the next page. var hasOlderTranscriptHistory: Bool = false - var onLoadOlderTranscript: (@MainActor () async -> Void)? = nil + var onLoadOlderTranscript: (@MainActor () async -> WorkChatOlderHistoryLoadResult)? = nil var subagentSnapshots: [WorkSubagentSnapshot] = [] var subagentSnapshotsRenderSignature: Int = 0 var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] @@ -499,19 +555,14 @@ struct WorkChatSessionView: View { "chat-end" } - @ViewBuilder - var subagentOverviewSection: some View { - if selectedSubagentTaskId == nil && !subagentSnapshots.isEmpty { - WorkSubagentStrip(snapshots: subagentSnapshots) - .frame(maxWidth: .infinity, alignment: .leading) - .id("chat-subagents-strip") - } - } - var hiddenTimelineCount: Int { timelinePresentation.hiddenCount } + var canRequestOlderTranscriptHistory: Bool { + hasOlderTranscriptHistory && onLoadOlderTranscript != nil + } + @MainActor func refreshTimelinePresentation(sourceTimeline: [WorkTimelineEntry]? = nil) { let timeline = sourceTimeline ?? timelineSnapshot.timeline @@ -538,7 +589,10 @@ struct WorkChatSessionView: View { && timelinePresentation.timelineFirstId != nextPresentation.timelineFirstId ) if prependedHistory { - visibleTimelineCount += timelineDelta + visibleTimelineCount = workTimelineVisibleCountAfterHistoryPrepend( + currentVisibleCount: visibleTimelineCount, + prependedCount: timelineDelta + ) nextPresentation = makeWorkTimelinePresentation( timeline: presentedTimeline, visibleCount: visibleTimelineCount, @@ -637,6 +691,45 @@ struct WorkChatSessionView: View { @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) + } + } + .font(.footnote.weight(.semibold)) + .background( + GeometryReader { geometry in + Color.clear.preference( + key: WorkChatContentTopPreferenceKey.self, + value: geometry.frame(in: .named(workChatScrollCoordinateSpace)).minY + ) + } + ) + } + if timeline.isEmpty { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", @@ -646,29 +739,6 @@ struct WorkChatSessionView: View { : "This subagent did not publish detailed transcript output." ) } else { - if hiddenTimelineCount > 0 || hasOlderTranscriptHistory { - let nextPageCount = min(hiddenTimelineCount, workTimelinePageSize) - let loadEarlierTitle = nextPageCount > 0 - ? "Load \(nextPageCount) earlier message\(nextPageCount == 1 ? "" : "s")" - : "Load earlier messages" - Button { - loadEarlierTimelineEntries() - } label: { - Label(loadEarlierTitle, systemImage: "chevron.up.circle") - .font(.footnote.weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .background(ADEColor.cardBackground.opacity(0.4), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Load earlier messages") - } - let streamingMessageId = streamingAssistantMessageId let userBubbleWidth = maxUserBubbleWidth ForEach(visibleTimelineRenderEntries) { entry in @@ -870,9 +940,8 @@ struct WorkChatSessionView: View { ScrollViewReader { proxy in VStack(spacing: 0) { ScrollView { - VStack(alignment: .leading, spacing: 14) { + LazyVStack(alignment: .leading, spacing: 14) { sessionOverviewSection - subagentOverviewSection timelineSection(proxy: proxy) streamingStatusSection @@ -1005,8 +1074,24 @@ struct WorkChatSessionView: View { distanceFromBottom: max(0, bottomY - scrollViewportHeight), proxy: proxy ) + continueAutomaticOlderHistoryIfNeeded() resolvePendingInitialBottomPinAfterLayout(proxy, reason: "content-bottom") } + .onPreferenceChange(WorkChatContentTopPreferenceKey.self) { topY in + if topY < -workChatOlderHistoryRearmDistance { + olderHistoryTriggerArmed = true + } + guard workChatShouldRequestOlderHistory( + topY: topY, + triggerArmed: olderHistoryTriggerArmed, + loading: olderHistoryLoadInFlight, + hasError: olderHistoryLoadError != nil, + hasBufferedEntries: hiddenTimelineCount > 0, + hasHostHistory: canRequestOlderTranscriptHistory + ) else { return } + olderHistoryTriggerArmed = false + requestEarlierTimelineEntries(automatically: true) + } .onChange(of: timeline.count) { oldCount, newCount in let previousTailId = lastTimelineTailId lastTimelineTailId = timeline.last?.id @@ -1069,6 +1154,12 @@ struct WorkChatSessionView: View { recoverEmptyTimelineSnapshotIfNeeded() } .onDisappear { + olderHistoryLoadTask?.cancel() + olderHistoryLoadTask = nil + olderHistoryLoadInFlight = false + olderHistoryLoadError = nil + olderHistoryAutomaticContinuationPending = false + olderHistoryTriggerArmed = true cancelScheduledTimelineSnapshotRebuild() } .onChange(of: chatSummaryTimelineKey) { _, _ in @@ -1431,6 +1522,14 @@ private struct WorkChatContentBottomPreferenceKey: PreferenceKey { } } +private struct WorkChatContentTopPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = -CGFloat.greatestFiniteMagnitude + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = max(value, nextValue()) + } +} + /// Flat transcript canvas. Desktop parity: a single dark #0f0f11 fill behind /// the agent prose — no card, no gradient. Light mode keeps the app's warm /// paper tone so the chat doesn't look out of place there. @@ -1545,6 +1644,13 @@ private func makeWorkTimelinePresentation( ) } +func workTimelineVisibleCountAfterHistoryPrepend( + currentVisibleCount: Int, + prependedCount: Int +) -> Int { + max(0, currentVisibleCount) + min(max(0, prependedCount), workTimelinePageSize) +} + private func workTimelinePresentationSignature( timelineCount: Int, timelineFirstId: String?, diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 746d4cc05..d3fd14fbd 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -7,6 +7,12 @@ enum WorkSessionNavigationChrome { case embedded } +private enum WorkOlderHistoryPageResult { + case unsupported + case loaded(addedTimelineEntries: Bool) + case failed +} + extension WorkSessionNavigationChrome: Equatable {} let workSessionEdgeSwipeActivationWidth: CGFloat = 36 @@ -409,6 +415,24 @@ func workChatSnapshotOlderHistoryCursor( return tailStartOffset } +func workChatOlderTranscriptPageAdvances( + beforeOffset: Int, + nextCursor: Int? +) -> Bool { + guard beforeOffset > 0 else { return false } + guard let nextCursor else { return true } + return nextCursor >= 0 && nextCursor < beforeOffset +} + +func workChatHasOlderTranscriptHistory( + chatEventCursor: Int?, + canonicalTranscriptCursor: Int?, + allowsCanonicalFallback: Bool +) -> Bool { + if (chatEventCursor ?? 0) > 0 { return true } + return allowsCanonicalFallback && (canonicalTranscriptCursor ?? 0) > 0 +} + private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !raw.isEmpty else { return nil } @@ -1389,11 +1413,13 @@ struct WorkSessionDestinationView: View { dispatchSteerInterruptAction = nil } let resolvedSessionStatus: String? = viewingSubagent ? "ended" : sessionStatus - let loadOlderTranscriptAction: (@MainActor () async -> Void)? - if viewingSubagent || isCrossProject { - // Cross-project quick looks show the subscribed tail only: the paged - // history command routes through the project scope registry and would - // boot the foreign runtime for a read. + let loadOlderTranscriptAction: (@MainActor () async -> WorkChatOlderHistoryLoadResult)? + if viewingSubagent { + loadOlderTranscriptAction = nil + } else 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. loadOlderTranscriptAction = nil } else { loadOlderTranscriptAction = { await loadOlderTranscriptEntries() } @@ -1833,9 +1859,13 @@ struct WorkSessionDestinationView: View { _ = try? await syncService.subscribeToChatEvents(sessionId: sessionId) } - // Quick looks stay on the chat_subscribe snapshot/tail — the canonical - // history commands route through the project scope registry and would - // boot the foreign runtime for a read (the cost this mode exists to avoid). + if let subscribedCursor = syncService.chatOlderHistoryCursor(sessionId: sessionId) { + updateOlderChatEventHistoryCursor(subscribedCursor) + } + + // Quick looks seed their byte cursor from chat_subscribe and page through + // the scoped chat_history envelope. Canonical history commands remain + // disabled here because they would boot the foreign runtime for a read. let shouldHydrateCanonicalEventTail = !isCrossProject && !reducedActiveLiveStream && ( @@ -2073,15 +2103,26 @@ struct WorkSessionDestinationView: View { @MainActor func seedOlderChatEventHistoryCursor(from page: AgentChatEventHistoryPage) { + guard page.unavailable != true, + page.sessionId == sessionId + else { return } guard page.sessionFound else { olderChatEventHistoryCursor = 0 return } + guard page.startOffset >= 0, + (!page.hasMore || page.startOffset > 0) + else { return } updateOlderChatEventHistoryCursor(page.hasMore ? page.startOffset : nil) } @MainActor - func updateOlderChatEventHistoryCursor(_ cursor: Int?) { + func updateOlderChatEventHistoryCursor(_ cursor: Int?, authoritative: Bool = false) { + let normalizedCursor = cursor.flatMap { $0 > 0 ? $0 : nil } ?? 0 + if authoritative { + olderChatEventHistoryCursor = normalizedCursor + return + } if let existing = olderChatEventHistoryCursor { // Zero is an explicit exhausted sentinel. Periodic tail refreshes must // not re-seed already-consumed history and download the same pages again. @@ -2093,7 +2134,7 @@ struct WorkSessionDestinationView: View { olderChatEventHistoryCursor = min(existing, cursor) return } - olderChatEventHistoryCursor = cursor.flatMap { $0 > 0 ? $0 : nil } ?? 0 + olderChatEventHistoryCursor = normalizedCursor } @MainActor @@ -2102,27 +2143,64 @@ struct WorkSessionDestinationView: View { } var hasOlderTranscriptHistory: Bool { - (olderChatEventHistoryCursor ?? 0) > 0 || (olderTranscriptCursor ?? 0) > 0 + workChatHasOlderTranscriptHistory( + chatEventCursor: olderChatEventHistoryCursor, + canonicalTranscriptCursor: olderTranscriptCursor, + allowsCanonicalFallback: !isCrossProject + ) } /// Fetch the next strictly-older transcript page from the host and prepend /// it to the fallback entries that feed the chat timeline. @MainActor - func loadOlderTranscriptEntries() async { - guard !olderTranscriptLoading else { return } + func loadOlderTranscriptEntries() async -> WorkChatOlderHistoryLoadResult { + guard !olderTranscriptLoading else { + return .loaded(hasMoreHistory: hasOlderTranscriptHistory, addedTimelineEntries: false) + } olderTranscriptLoading = true defer { olderTranscriptLoading = false } - if await loadOlderChatEventHistoryPageIfPossible() { - return + switch await loadOlderChatEventHistoryPageIfPossible() { + case .loaded(let addedTimelineEntries): + return .loaded( + hasMoreHistory: hasOlderTranscriptHistory, + addedTimelineEntries: addedTimelineEntries + ) + case .failed: + return .failed + case .unsupported: + break + } + // Cross-project quick looks must never fall through to the canonical + // command path: that route activates the foreign runtime just to read + // history. A modern subscribe cursor will re-enable the scoped event-page + // path as soon as its ack arrives. + guard !isCrossProject else { return .failed } + guard let cursor = olderTranscriptCursor, cursor > 0 else { + return .loaded(hasMoreHistory: false, addedTimelineEntries: false) + } + var loadedPage: SyncService.AgentChatTranscriptPage? + for retry in 0..<3 { + if retry > 0 { + try? await Task.sleep(nanoseconds: retry == 1 ? 250_000_000 : 750_000_000) + } + guard !Task.isCancelled else { return .failed } + if let page = try? await syncService.fetchChatTranscriptPage( + sessionId: sessionId, + cursor: cursor + ) { + loadedPage = page + break + } } - guard let cursor = olderTranscriptCursor, cursor > 0 else { return } - guard let page = try? await syncService.fetchChatTranscriptPage( - sessionId: sessionId, - cursor: cursor - ) else { return } + guard let page = loadedPage else { return .failed } + guard workChatOlderTranscriptPageAdvances( + beforeOffset: cursor, + nextCursor: page.nextCursor + ) else { return .failed } recordTranscriptPage(page, before: cursor) let combined = combinedTranscriptEntries() - if !combined.isEmpty, combined != fallbackEntries { + let fallbackChanged = !combined.isEmpty && combined != fallbackEntries + if fallbackChanged { setFallbackEntries(combined) } // fallbackEntries only feed the timeline while `transcript` is empty @@ -2133,33 +2211,61 @@ struct WorkSessionDestinationView: View { // events are not duplicated. let olderTranscript = makeWorkChatTranscript(from: combined, sessionId: sessionId) let merged = preferredWorkTranscript(current: [], fallback: olderTranscript, eventTranscript: transcript) - if !merged.isEmpty, merged != transcript { + let transcriptChanged = !merged.isEmpty && merged != transcript + if transcriptChanged { setTranscript(merged) } + return .loaded( + hasMoreHistory: hasOlderTranscriptHistory, + addedTimelineEntries: fallbackChanged || transcriptChanged + ) } @MainActor - func loadOlderChatEventHistoryPageIfPossible() async -> Bool { - guard syncService.supportsChatRemoteAction("chat.getChatEventHistoryPage", sessionId: sessionId), + private func loadOlderChatEventHistoryPageIfPossible() async -> WorkOlderHistoryPageResult { + guard ( + syncService.supportsSubscribedChatHistory(sessionId: sessionId) + || syncService.supportsChatRemoteAction("chat.getChatEventHistoryPage", sessionId: sessionId) + ), var cursor = olderChatEventHistoryCursor, cursor > 0 - else { return false } + else { return .unsupported } for _ in 0..<6 { guard cursor > 0 else { break } - guard let page = try? await syncService.fetchChatEventHistoryPage( - sessionId: sessionId, - beforeOffset: cursor - ) else { - return false + var loadedPage: AgentChatEventHistoryPage? + for retry in 0..<3 { + if retry > 0 { + try? await Task.sleep(nanoseconds: retry == 1 ? 250_000_000 : 750_000_000) + } + guard !Task.isCancelled else { return .failed } + if let page = try? await syncService.fetchChatEventHistoryPage( + sessionId: sessionId, + beforeOffset: cursor, + maxBytes: 256 * 1024 + ), page.unavailable != true { + loadedPage = page + break + } + } + guard let page = loadedPage else { return .failed } + guard page.unavailable != true, + page.sessionId == sessionId + else { + return .failed } guard page.sessionFound else { olderChatEventHistoryCursor = 0 - return true + return .loaded(addedTimelineEntries: false) } - guard page.startOffset < cursor else { - olderChatEventHistoryCursor = 0 - return true + guard + workChatOlderTranscriptPageAdvances( + beforeOffset: cursor, + nextCursor: page.startOffset + ), + (!page.hasMore || page.startOffset > 0) + else { + return .failed } olderChatEventHistoryCursor = page.hasMore && page.startOffset > 0 ? page.startOffset : 0 cursor = page.startOffset @@ -2170,13 +2276,14 @@ struct WorkSessionDestinationView: View { let merged = pruneResolvedQueuedSteerEnvelopes( mergeWorkChatTranscripts(base: olderTranscript, live: transcript) ) - if !merged.isEmpty, merged != transcript { + let transcriptChanged = !merged.isEmpty && merged != transcript + if transcriptChanged { setTranscript(merged) } - return true + return .loaded(addedTimelineEntries: transcriptChanged) } - return true + return .loaded(addedTimelineEntries: false) } /// Re-read this session's row from the phone's local replicated DB. Cheap @@ -2392,6 +2499,12 @@ struct WorkSessionDestinationView: View { @MainActor func syncTranscriptFromLiveEvents() { + if let subscribedCursor = syncService.chatOlderHistoryCursorState(sessionId: sessionId) { + updateOlderChatEventHistoryCursor( + subscribedCursor > 0 ? subscribedCursor : nil, + authoritative: true + ) + } let liveTranscript = liveTranscriptCache.transcript( for: sessionId, events: syncService.chatEventHistory(sessionId: sessionId) diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 0da11fe8a..ecfeca974 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -2831,9 +2831,9 @@ private func eventCard( metadata: ["Tasks · \(progressLabel)"] ) case .subagentStarted, .subagentProgress, .subagentResult: - // Subagent lifecycle is represented by WorkSubagentStrip, the composer - // badge, and the Subagents drawer. Rendering every lifecycle envelope as - // a normal event card makes mobile chats look much longer than desktop. + // Subagent lifecycle is represented by compact timeline boundaries, the + // composer badge, and Chat Info. Rendering every lifecycle envelope as a + // normal event card makes mobile chats look much longer than desktop. return nil case .scheduledWorkUpdate(_, let kind, let status, _, let title, let summary, let prompt, let reason, let cron, let nextRunAt, _, _, _, _, _, _, _, let turnId, let error): // Background shell commands are owned by the Chat Info pane's Background diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index b79999c78..794fac300 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1294,6 +1294,7 @@ final class ADETests: XCTestCase { "terminal_resize", "chat_subscribe", "chat_unsubscribe", + "chat_history", ] for type in projectScopedTypes { @@ -4557,7 +4558,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.subscribedChatSessionIds, Set(["session-1", "session-2"])) XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["sessionId"] as? String }.sorted(), ["session-1", "session-2"]) - XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["maxBytes"] as? Int }, [2_000_000, 2_000_000]) + XCTAssertEqual(service.chatSubscriptionPayloads().compactMap { $0["maxBytes"] as? Int }, [262_144, 262_144]) service.disconnect(clearCredentials: false) @@ -4607,6 +4608,53 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.capturedOutboundEnvelopeCountForTesting(type: "chat_subscribe"), 1) } + @MainActor + func testSubscribedChatHistoryPagingStaysGatedForLegacyHosts() async throws { + let legacyService = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + try legacyService.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-legacy", + "deviceName": "Mac Studio", + ], + "features": [ + "chatStreaming": ["enabled": true], + ], + ]) + legacyService.configureConnectedTransportForTesting() + legacyService.beginOutboundEnvelopeCaptureForTesting() + defer { legacyService.endOutboundEnvelopeCaptureForTesting() } + + _ = try await legacyService.subscribeToChatEvents(sessionId: "chat-legacy") + XCTAssertFalse(legacyService.supportsSubscribedChatHistory(sessionId: "chat-legacy")) + XCTAssertEqual( + legacyService.capturedOutboundEnvelopeCountForTesting(type: "chat_subscribe"), + 1 + ) + + let modernService = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + try modernService.applyHelloPayloadForTesting([ + "brain": [ + "deviceId": "host-modern", + "deviceName": "Mac Studio", + ], + "features": [ + "chatStreaming": ["enabled": true], + "chatHistoryPaging": ["enabled": true], + ], + ]) + modernService.configureConnectedTransportForTesting() + modernService.beginOutboundEnvelopeCaptureForTesting() + defer { modernService.endOutboundEnvelopeCaptureForTesting() } + + XCTAssertFalse(modernService.supportsSubscribedChatHistory(sessionId: "chat-modern")) + _ = try await modernService.subscribeToChatEvents(sessionId: "chat-modern") + XCTAssertTrue(modernService.supportsSubscribedChatHistory(sessionId: "chat-modern")) + XCTAssertEqual( + modernService.capturedOutboundEnvelopeCountForTesting(type: "chat_subscribe"), + 1 + ) + } + func testOpeningSnapshotRequestDoesNotRepeatAfterDispatch() { XCTAssertTrue(workChatShouldRequestOpeningSnapshot( alreadySubscribed: false, @@ -13447,6 +13495,318 @@ final class ADETests: XCTestCase { ) } + @MainActor + func testMobileHistoryRoutingRequiresScopedProgressingCursors() { + XCTAssertTrue(workChatOlderTranscriptPageAdvances( + beforeOffset: 4_096, + nextCursor: 2_048 + )) + XCTAssertTrue(workChatOlderTranscriptPageAdvances( + beforeOffset: 4_096, + nextCursor: nil + )) + XCTAssertFalse(workChatOlderTranscriptPageAdvances( + beforeOffset: 4_096, + nextCursor: 4_096 + )) + XCTAssertFalse(workChatOlderTranscriptPageAdvances( + beforeOffset: 4_096, + nextCursor: -1 + )) + XCTAssertFalse(workChatOlderTranscriptPageAdvances( + beforeOffset: 4_096, + nextCursor: 8_192 + )) + XCTAssertTrue(workChatHasOlderTranscriptHistory( + chatEventCursor: 2_048, + canonicalTranscriptCursor: nil, + allowsCanonicalFallback: false + )) + XCTAssertFalse(workChatHasOlderTranscriptHistory( + chatEventCursor: nil, + canonicalTranscriptCursor: 2_048, + allowsCanonicalFallback: false + )) + XCTAssertTrue(workChatHasOlderTranscriptHistory( + chatEventCursor: nil, + canonicalTranscriptCursor: 2_048, + allowsCanonicalFallback: true + )) + + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + service.seedChatHistoryCursorForTesting(sessionId: "chat-1", cursor: 4_096) + let firstPage = AgentChatEventHistoryPage( + sessionId: "chat-1", + events: [], + startOffset: 2_048, + hasMore: true, + sessionFound: true, + unavailable: false + ) + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 4_096, + page: firstPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + // A delayed ordinary subscribe ack cannot resurrect a consumed page. + service.seedChatHistoryCursorForTesting( + sessionId: "chat-1", + cursor: 4_096, + allowForward: false + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + // A duplicate response for the consumed cursor cannot repeat the page. + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 4_096, + page: firstPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + var unavailablePage = firstPage + unavailablePage.startOffset = 1_024 + unavailablePage.unavailable = true + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 2_048, + page: unavailablePage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + var mismatchedPage = firstPage + mismatchedPage.sessionId = "different-chat" + mismatchedPage.startOffset = 1_024 + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 2_048, + page: mismatchedPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + var nonProgressingPage = firstPage + nonProgressingPage.startOffset = 4_096 + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 2_048, + page: nonProgressingPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + // A live event rebuild reads this service cursor into the destination. + // Recording the event must not restore the original subscribe cursor. + service.recordChatEventEnvelope(AgentChatEventEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-28T10:00:00.000Z", + event: .text(text: "live", messageId: "message-1", turnId: "turn-1", itemId: "item-1"), + sequence: 1 + )) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 2_048) + + let secondPage = AgentChatEventHistoryPage( + sessionId: "chat-1", + events: [], + startOffset: 1_024, + hasMore: false, + sessionFound: true, + unavailable: false + ) + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 2_048, + page: secondPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 0) + + // A later authoritative full snapshot may legitimately re-arm paging after + // a previously small transcript grows beyond the bounded tail. + service.seedChatHistoryCursorForTesting(sessionId: "chat-1", cursor: 8_192) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 8_192) + + let missingPage = AgentChatEventHistoryPage( + sessionId: "chat-1", + events: [], + startOffset: 8_192, + hasMore: false, + sessionFound: false, + unavailable: false + ) + service.recordChatHistoryPageCursorForTesting( + requestedSessionId: "chat-1", + beforeOffset: 8_192, + page: missingPage + ) + XCTAssertEqual(service.chatOlderHistoryCursorState(sessionId: "chat-1"), 0) + } + + func testMobileHistoryPayloadsDecodeCursorAndUnavailableState() throws { + let json = """ + { + "sessionId": "chat-1", + "events": [], + "startOffset": 4096, + "hasMore": true, + "sessionFound": false, + "unavailable": true + } + """ + let page = try JSONDecoder().decode( + AgentChatEventHistoryPage.self, + from: Data(json.utf8) + ) + XCTAssertEqual(page.startOffset, 4096) + XCTAssertEqual(page.hasMore, true) + XCTAssertEqual(page.sessionFound, false) + XCTAssertEqual(page.unavailable, true) + + let snapshotJSON = """ + { + "sessionId": "chat-1", + "capturedAt": "2026-07-28T10:00:00.000Z", + "truncated": true, + "tailStartOffset": 2048, + "hasOlderHistory": true, + "cursorKind": "byte", + "events": [] + } + """ + let snapshot = try JSONDecoder().decode( + SyncChatSubscribeSnapshotPayload.self, + from: Data(snapshotJSON.utf8) + ) + XCTAssertEqual(snapshot.tailStartOffset, 2048) + XCTAssertEqual(snapshot.hasOlderHistory, true) + XCTAssertEqual(snapshot.cursorKind, "byte") + } + + func testChatSubscribeCursorPreservesLegacyUnknownAndOnlyExhaustsExplicitly() { + XCTAssertNil(syncChatSubscribeHistoryCursor( + hasOlderHistory: nil, + tailStartOffset: nil + )) + XCTAssertNil(syncChatSubscribeHistoryCursor( + hasOlderHistory: true, + tailStartOffset: nil + )) + XCTAssertEqual(syncChatSubscribeHistoryCursor( + hasOlderHistory: nil, + tailStartOffset: 4_096 + ), 4_096) + XCTAssertNil(syncChatSubscribeHistoryCursor( + hasOlderHistory: true, + tailStartOffset: 4_096, + cursorKind: "row" + )) + XCTAssertEqual(syncChatSubscribeHistoryCursor( + hasOlderHistory: false, + tailStartOffset: 4_096, + cursorKind: "row" + ), 0) + } + + func testMobileChatHistoryTriggerAndRenderCapStayBounded() { + XCTAssertTrue(workChatShouldRequestOlderHistory( + topY: -120, + triggerArmed: true, + loading: false, + hasError: false, + hasBufferedEntries: false, + hasHostHistory: true + )) + XCTAssertFalse(workChatShouldRequestOlderHistory( + topY: -500, + triggerArmed: true, + loading: false, + hasError: false, + hasBufferedEntries: true, + hasHostHistory: false + )) + XCTAssertFalse(workChatShouldRequestOlderHistory( + topY: 0, + triggerArmed: false, + loading: false, + hasError: false, + hasBufferedEntries: true, + hasHostHistory: false + )) + XCTAssertFalse(workChatShouldRequestOlderHistory( + topY: 0, + triggerArmed: true, + loading: true, + hasError: false, + hasBufferedEntries: false, + hasHostHistory: true + )) + XCTAssertFalse(workChatShouldRequestOlderHistory( + topY: 0, + triggerArmed: true, + loading: false, + hasError: true, + hasBufferedEntries: false, + hasHostHistory: true + )) + + XCTAssertTrue(workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: 0, + loading: false, + hasError: false, + hasBufferedEntries: false, + hasHostHistory: true + )) + XCTAssertTrue(workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: 0, + loading: false, + hasError: false, + hasBufferedEntries: true, + hasHostHistory: false + )) + XCTAssertFalse(workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: 80, + loading: false, + hasError: false, + hasBufferedEntries: false, + hasHostHistory: true + )) + XCTAssertFalse(workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: 0, + loading: false, + hasError: true, + hasBufferedEntries: false, + hasHostHistory: true + )) + XCTAssertFalse(workChatShouldContinueAutomaticOlderHistory( + distanceFromBottom: 0, + loading: false, + hasError: false, + hasBufferedEntries: false, + hasHostHistory: false + )) + + XCTAssertEqual( + workTimelineVisibleCountAfterHistoryPrepend( + currentVisibleCount: workTimelinePageSize, + prependedCount: 2_000 + ), + workTimelinePageSize * 2 + ) + XCTAssertEqual( + workTimelineVisibleCountAfterHistoryPrepend( + currentVisibleCount: workTimelinePageSize, + prependedCount: 5 + ), + workTimelinePageSize + 5 + ) + XCTAssertEqual( + workTimelineVisibleCountAfterHistoryPrepend( + currentVisibleCount: workTimelinePageSize, + prependedCount: -1 + ), + workTimelinePageSize + ) + } + func testWorkSubagentSnapshotsKeepHistoricalRemoteRosterAndMergeLocalDetails() { let remote = [ WorkSubagentSnapshot( diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 236572b7f..1c1e6e59f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1171,7 +1171,7 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s batch through the existing chunked envelope transport. - `hello_ok` can include the host's mobile project catalog and project-action feature flag. The iOS app shows a native project hub until an active project is selected, can browse/open/create/clone projects on the paired machine when project actions are available, then drives `project_switch_request` / `project_switch_result`; the port stays stable across switches. - Bidirectional sync continues; inbound processing (envelope parse, gunzip, chunk reassembly, changeset decode + apply) runs off the main actor. On disconnect: a fast exponential-backoff burst, then an indefinite ~30 s slow-heartbeat retry — the phone never permanently gives up. `reconnectIfPossible` is guarded against overlapping runs. -- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. `seq` is a resume cursor, not an event identity — it is unique only within one runtime lifetime, while the transcript it numbers is durable and keeps being appended across restarts, so a client that keys identity or dedupe on `sessionId + seq` alone will silently drop real events as phantom replays (see [features/chat/composer-and-ui.md](./features/chat/composer-and-ui.md#fragile-and-tricky-wiring)). Rehydrated sessions seed their counter from the transcript's maximum so numbering stays strictly increasing, and clients pair `seq` with the event timestamp (or, for blocking gates, the host-assigned `itemId`). A per-session hydration barrier holds the live broadcaster and transcript pump until the snapshot ack, then resumes from the pre-capture logical byte offset so concurrent appends cannot overtake or fall between snapshot and stream. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) so a phone subscribing mid-turn renders streaming/stop affordances immediately even when the byte-capped snapshot tail dropped the turn's start event. `chat.getTranscript` pages older history via an opaque cursor; full runtimes advertise append-stable `cursorKind: "byte"` offsets and the minimal headless fallback advertises `cursorKind: "index"`. When the host advertises the `crossProjectChat` feature flag, `chat_subscribe` can also name a foreign (non-active) project via `projectId`/`projectRootPath`; the host streams that project's transcript read-only straight off its `.ade` transcript files, so the all-projects Hub can open any project's chat without a project switch or runtime boot. Personal subscriptions instead send `chatScope: "personal"`; the host resolves the durable transcript and active-turn state through `PersonalChatScope` with no project id. +- Chat streaming resumes by sequence: each `chat_event` carries a host-assigned per-session `seq` backed by a replay buffer; `chat_subscribe` passes `sinceSeq` so reconnects replay only the missed events. `seq` is a resume cursor, not an event identity — it is unique only within one runtime lifetime, while the transcript it numbers is durable and keeps being appended across restarts, so a client that keys identity or dedupe on `sessionId + seq` alone will silently drop real events as phantom replays (see [features/chat/composer-and-ui.md](./features/chat/composer-and-ui.md#fragile-and-tricky-wiring)). Rehydrated sessions seed their counter from the transcript's maximum so numbering stays strictly increasing, and clients pair `seq` with the event timestamp (or, for blocking gates, the host-assigned `itemId`). A per-session hydration barrier holds the live broadcaster and transcript pump until the snapshot ack, then resumes from the pre-capture logical byte offset so concurrent appends cannot overtake or fall between snapshot and stream. The subscribe ack also carries `turnActive` (live turn state from the agent chat service) plus `cursorKind: "byte"`, `tailStartOffset`, and `hasOlderHistory`. Hosts advertising `chatHistoryPaging` accept 256 KiB `chat_history` pages only for the already-authorized subscription and matching project/personal/foreign scope, so mobile scrollback never needs a project switch or runtime boot and preserves its cursor across transient `unavailable` responses. `chat.getTranscript` remains the legacy opaque-cursor fallback; full runtimes advertise append-stable `cursorKind: "byte"` offsets and the minimal headless fallback advertises `cursorKind: "index"`. When the host advertises the `crossProjectChat` feature flag, `chat_subscribe` can also name a foreign (non-active) project via `projectId`/`projectRootPath`; the host streams that project's transcript read-only straight off its `.ade` transcript files, so the all-projects Hub can open any project's chat without a project switch or runtime boot. Personal subscriptions instead send `chatScope: "personal"`; the host resolves the durable transcript and active-turn state through `PersonalChatScope` with no project id. - User-message delivery is durable across that stream: accepted messages retain processed/unprocessed state, and unprocessed rows expose Run next / Edit / Dismiss through idempotent `chat.resolveUnprocessedMessage`. Turn stalls and diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 9be0116af..6a63096f7 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -24,7 +24,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/cli.ts` | Resolves the built or source TUI entry and forwards the parsed launch context to `runAdeCodeCli`. | | `apps/ade-cli/src/adeRpcServer.ts` | Runtime JSON-RPC and ADE action dispatcher used by the TUI/CLI. After a successful user-issued meaningful mutation it records one local usage event, attributing `ade-code` / `ade-cli` clients to `tui`; agent-owned run/step/chat calls and read-only actions are excluded. | | `apps/ade-cli/src/tuiClient/cli.tsx` | TUI entry: argv parsing, project discovery, connection bootstrap, Ink mount. Built to `apps/ade-cli/dist/tuiClient/cli.mjs`. | -| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. It joins chat/terminal lists with `session.list` lifecycle fields and dispatches `/chat ask`, `/chat note`, `/chat settle`, and `/chat unsettle` through the session action domain; settling a row that is awaiting input or explicitly requesting attention asks the backend to dismiss that pending input in the same settlement transaction. The target-addressable `/session snooze` / `wake` / `settle` / `unsettle` / `keep-active` commands are parsed in `sessionLifecycle.ts` and dispatched from here, with `components/Drawer.tsx` and `components/RightPane.tsx` rendering the resulting snooze/woke row markers. Owns startup reconnect/retry UI, the debounced/cached `@` mention loader, cursor-relative `/command` + `@file` trigger detection via the shared `apps/desktop/src/shared/composerTriggers.ts` module (mid-sentence slash completion on Tab/Enter, colored `@file`/`/command` chip tokens painted into the prompt rows through `segmentPromptLineText` + `findConfirmedComposerTokens`), smart-link prompt styling/summary strips, terminal mode restoration on exit/heartbeat shutdown, and the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | +| `apps/ade-cli/src/tuiClient/app.tsx` | Primary Ink/React surface: navigation, composer, drawers, right pane, session lifecycle, slash command dispatch. It joins chat/terminal lists with `session.list` lifecycle fields and dispatches `/chat ask`, `/chat note`, `/chat settle`, and `/chat unsettle` through the session action domain; settling a row that is awaiting input or explicitly requesting attention asks the backend to dismiss that pending input in the same settlement transaction. The target-addressable `/session snooze` / `wake` / `settle` / `unsettle` / `keep-active` commands are parsed in `sessionLifecycle.ts` and dispatched from here, with `components/Drawer.tsx` and `components/RightPane.tsx` rendering the resulting snooze/woke row markers. Owns startup reconnect/retry UI, the debounced/cached `@` mention loader, cursor-relative `/command` + `@file` trigger detection via the shared `apps/desktop/src/shared/composerTriggers.ts` module (mid-sentence slash completion on Tab/Enter, colored `@file`/`/command` chip tokens painted into the prompt rows through `segmentPromptLineText` + `findConfirmedComposerTokens`), smart-link prompt styling/summary strips, terminal mode restoration on exit/heartbeat shutdown, and the `Ctrl+Y` "copy ADE deeplink" handler which resolves the focused lane / PR row through `buildDeeplinkForRow` and copies the canonical `ade://...` URL to the system clipboard. It also owns cache-first chat revisits and the two-stage older-history path: drain the already-hydrated local snapshot buffer, then request byte-cursor pages with bounded retry while preserving the cursor on transient failure. Also backs `/skills` by listing Agent Skill roots from project, user, inherited, and bundled ADE locations, independent of the active provider. | | `apps/ade-cli/src/tuiClient/promptSmartLinks.ts` | ADE Code's capability-adapted smart-link helpers. Formats a one-row violet provider/label strip from the shared `smartLinks.ts` catalog and makes character Backspace/Delete remove the whole URL when the cursor intersects it; the prompt still contains and sends the canonical raw URL. | | `apps/ade-cli/src/tuiClient/productAnalytics.ts` | Pure TUI screen normalization plus runtime `analytics.capture` calls. `app.tsx` records a deduplicated open and normalized screen changes; it never owns a PostHog client, reads terminal/chat content, or emits per-render/poll events. Accepted events share the machine runtime's consent and 200-event daily budget. See [logging and product analytics](../../logging.md). | | `apps/ade-cli/src/tuiClient/externalSessionBrowser.ts` | Pure state/actions for the provider-native session browser. Filters and clamps rows, consumes the shared Continue/Copy policy, puts `Open existing ADE session` first for imported rows, and exposes only Copy actions after it so Enter never re-imports the original session. | @@ -49,7 +49,8 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/closedCliSessions.ts` | Converts live or ended tracked CLI terminal sessions into chat-like summaries, retains the persisted settled/status/attention/failure fields, filters ended rows out of open chat lists, derives resumability/provider metadata, projects the scheduler-backed pause/jobs/next-wake state fetched by the Ink root, and maps user-initiated closes (0/130/143) to the neutral idle glyph instead of a failure state. | | `apps/ade-cli/src/tuiClient/sessionLifecycle.ts` | ADE Code's half of the session-lifecycle surface: argument parsing for the `/session …` slash commands plus the text-only row markers the drawer and the right-pane chat list render. Everything semantic is imported, never re-derived — `isSessionSnoozed` / `isSessionFiledAsSnoozed` from `apps/desktop/src/shared/sessionCanonicalState.ts`, wake-label copy (`snoozeWakeLabel` → "wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now") and woke-reason copy (`sessionWokeMarker` → "needs approval" / "errored" / "turn finished") from `apps/desktop/src/renderer/lib/sessionSnooze`, and duration grammar from `sessionSnoozeDuration.ts`. Snooze stays a visibility overlay: nothing in the module reads or writes a canonical phase. `SNOOZE_CHOICES` / `resolveSnoozeChoice` / `resolveSnoozeFreeText` back the duration picker. | | `apps/ade-cli/src/sessionSnoozeDuration.ts` | Snooze duration parsing shared by the `ade session snooze` planner in `cli.ts` and the TUI's `/session snooze`. Extracted rather than duplicated so there is exactly one answer to "what does `1.5h` mean" and exactly one cap (`MAX_SNOOZE_MS`, 30 days — beyond that it is almost certainly a typo, and no scheduler exists that could walk the deadline back). Grammar: an integer or one-decimal amount plus a unit suffix (`30m`, `1h`, `1.5h`, `4h`, `1d`, `1w`); a bare number reads as minutes. It returns a result union (`{ ok: true, ms }` \| `{ ok: false, code: "invalid" \| "too-short" \| "too-long", message }`) instead of throwing, so each surface dresses the failure in its own voice: `cli.ts` re-throws a `CliUsageError` with the flag-worded `message`, while the TUI switches on `code` to write terminal copy that never mentions a flag the user did not type. | -| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over the runtime action domains used by the Ink root, including the session lifecycle calls `snoozeSession`, `wakeSession`, `setSessionSettleOverride`, and `clearSessionWokeMarker` (all mapping onto the `session` action domain) and the `TuiSessionLifecycleFields` type. `enrichChatSessionsWithLifecycle` / `enrichTerminalSessionsWithLifecycle` carry `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` onto enriched rows so the drawer and right pane render markers without a second read. | +| `apps/ade-cli/src/tuiClient/adeApi.ts` | Typed wrappers over the runtime action domains used by the Ink root, including the session lifecycle calls `snoozeSession`, `wakeSession`, `setSessionSettleOverride`, and `clearSessionWokeMarker` (all mapping onto the `session` action domain) and the `TuiSessionLifecycleFields` type. `enrichChatSessionsWithLifecycle` / `enrichTerminalSessionsWithLifecycle` carry `settleOverride`, `snoozedUntil`, `snoozedAt`, `wokeAt`, and `wokeReason` onto enriched rows so the drawer and right pane render markers without a second read. Chat hydration requests a 1,000-event / 256 KiB recent window and exposes the append-stable `getChatEventHistoryPage` byte-cursor wrapper for older pages. | +| `apps/ade-cli/src/tuiClient/olderHistory.ts` | Bounded transcript-window policy for ADE Code. It initially paints the newest 500 snapshot events, drains the contiguous local remainder before network paging, dedupes page seams, and keeps at most 60,000 resident events. At the cap, scrollback becomes a sliding window that retains the newly requested older side and marks the view detached; `End` rehydrates the authoritative recent tail and folds in buffered live events. The cursor stays retryable on `unavailable`, and the underfill/near-top policy triggers loading without requiring an extra scroll event. | | `apps/ade-cli/src/tuiClient/drawerSelection.ts` | Pure selectors for the lane / chat drawer (active row, expanded groups, keyboard navigation). | | `apps/ade-cli/src/tuiClient/drawerLayout.ts` | Single source of truth for drawer row layout: `computeDrawerLayout` (expanded chat block, closed-CLI group rows, and compact per-lane chat previews under a height budget) and `drawerMouseHitForLayout`, shared by the Drawer renderer and the app's mouse hit-testing so the two cannot drift. | | `apps/ade-cli/src/tuiClient/newLaneForm.ts` | Pure model for the `/new lane` form: start-from modes (primary / child / import), Linear issue + setup-template fields, per-mode field lists, and `buildNewLaneSubmission` mapping form values onto `lane.create` / `lane.createChild` / `lane.importBranch` payloads. | @@ -151,7 +152,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Header** — project name, active lane, branch, the terminal client frame, and the shared machine account state. ADE Code reads account status once while the TUI surface is active; it does not add a poll loop. `ade login` remains the canonical sign-in command. - **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. The TUI enriches both chat and tracked-CLI rows from `session.list`: explicit asks render the blocking question in amber, status notes render inline (`done: …` when settled), and a sanitized last-output preview is the first fallback before summary or goal. Settled rows dim into the quiet glyph tier, and last-turn failures render as failures. Snooze is a second, independent quiet tier: a snoozed row carries a text-only `z` marker plus its wake label ("wakes in 3h" / "wakes tomorrow" / "wakes when asked" / "wakes now"), and a row that woke early carries a `*` marker naming the reason ("needs approval" / "errored" / "turn finished") until it is visited, at which point the marker is cleared. Because snooze is a visibility overlay rather than a phase, a snoozed row that is blocking on the user stays in its normal place — `isSessionFiledAsSnoozed` yields to a `needs_you` phase — while `isSessionSnoozed` remains the raw column read used for row chrome. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Continuation forwards the stored model, reasoning, Fast Mode, permission mode, and exact Codex approval/sandbox/config controls through the shared launch-field mapper. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. -- **ChatView** — the main transcript. Renders user, assistant, file-change, and system events from `chat/event` notifications while normalized tool telemetry stays behind the active activity/status row or the completed turn's `Ran for` row. Codex and most providers label the live row `model working`; Claude keeps its existing provider-specific live presentation and adds only a compact actions disclosure when tools are available. Expanding either status reveals one line per tool (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`); MCP events prefer the app/plugin/server name plus action instead of a generic `mcp` label, and expanded `web_search` actions include the first provider action query/title/URL when available plus up to three Codex structured `title — domain` previews with a `+N more` tail. Generated/viewed-image lifecycle updates still collapse to one concise notice per item, and provider-specific narration, reasoning, subagent/activity cards, and notices remain in their existing positions. File-change groups remain chronological, collapse to one summary row, and expand to typed file rows whose `diff` action opens the turn diff in the right pane. Every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. +- **ChatView** — the main transcript. Renders user, assistant, file-change, and system events from `chat/event` notifications while normalized tool telemetry stays behind the active activity/status row or the completed turn's `Ran for` row. Codex and most providers label the live row `model working`; Claude keeps its existing provider-specific live presentation and adds only a compact actions disclosure when tools are available. Expanding either status reveals one line per tool (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`); MCP events prefer the app/plugin/server name plus action instead of a generic `mcp` label, and expanded `web_search` actions include the first provider action query/title/URL when available plus up to three Codex structured `title — domain` previews with a `+N more` tail. Generated/viewed-image lifecycle updates still collapse to one concise notice per item, and provider-specific narration, reasoning, subagent/activity cards, and notices remain in their existing positions. File-change groups remain chronological, collapse to one summary row, and expand to typed file rows whose `diff` action opens the turn diff in the right pane. Every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. Near the top, both scroll and underfilled viewports silently request more history; the stable first row reads `↑ older messages`, changes in place to `↑ loading earlier…`, and exposes `Ctrl+R` only after all automatic retries fail. Paging continues beyond the 60,000-event resident ceiling by sliding the window toward the transcript head; live events are buffered while detached, and `End` restores the latest bounded tail. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. URLs detected by shared `smartLinks.ts` also paint violet and add a compact `links [provider label]` row above the raw prompt; GitHub, Linear, ADE, and generic web labels are deterministic and do not require metadata fetching in the terminal. Character Backspace/Delete removes the whole intersected URL, while the canonical URL remains the submitted prompt text. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — **TASKS** (latest `todo_update` snapshot, desktop ChatTasksPanel parity), **SCHEDULE** (Claude wakeups/cron/`/loop` from `scheduled_work_update` via `deriveScheduleItems`, desktop Chat Info parity, plus `⏰ next wake ` from the active session summary), **BACKGROUND** (`background_task` work from `scheduled_work_update` via `deriveBackgroundItems`, each rendered as a `$