diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index f7e9b05b8..6196d6a9d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -763,6 +763,9 @@ app.whenReady().then(async () => { const setActiveProject = (projectRoot: string | null): void => { activeProjectRoot = projectRoot ? normalizeProjectRoot(projectRoot) : null; + for (const [root, ctx] of projectContexts) { + ctx.syncService?.setHostDiscoveryEnabled?.(activeProjectRoot != null && root === activeProjectRoot); + } if (activeProjectRoot) { projectLastActivatedAt.set(activeProjectRoot, Date.now()); try { @@ -2435,6 +2438,7 @@ app.whenReady().then(async () => { db, logger, projectRoot, + localDeviceIdPath: path.join(app.getPath("userData"), "sync-device-id"), fileService, laneService, gitService, @@ -2466,6 +2470,7 @@ app.whenReady().then(async () => { getLinearSyncService: () => linearSyncServiceRef, processService, hostStartupEnabled: process.env.ADE_DISABLE_SYNC_HOST !== "1", + hostDiscoveryEnabled: activeProjectRoot != null && normalizeProjectRoot(projectRoot) === activeProjectRoot, notificationEventBus, projectCatalogProvider: { listProjects: listMobileSyncProjects, diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index c4895f0d1..58c153a1f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4488,6 +4488,138 @@ describe("createAgentChatService", () => { }); }); + describe("getChatEventHistory", () => { + it("returns an empty history for an unknown session", async () => { + const { service } = createService(); + const history = service.getChatEventHistory("unknown-session"); + expect(history.events).toEqual([]); + expect(history.truncated).toBe(false); + }); + + it("hydrates history from the on-disk transcript on first read", async () => { + // This is the core contract that fixes chat-history-loss on project + // switch / tab switch: a late subscriber that missed the live broadcast + // still sees the full history, because getChatEventHistory hydrates + // itself from the transcript the first time the session is queried. + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const envelope1: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: new Date().toISOString(), + event: { type: "text", text: "persisted-1" }, + sequence: 1, + }; + const envelope2: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: new Date().toISOString(), + event: { type: "text", text: "persisted-2" }, + sequence: 2, + }; + + // Seed the transcript file at the path managed.transcriptPath points + // to (set by createSession → managedSessions → row.transcriptPath). + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope1)}\n${JSON.stringify(envelope2)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope1, envelope2]); + + const history = service.getChatEventHistory(session.id); + expect(history.sessionId).toBe(session.id); + expect(history.events).toHaveLength(2); + expect(history.events.map((envelope) => + envelope.event.type === "text" ? envelope.event.text : "", + )).toEqual(["persisted-1", "persisted-2"]); + }); + + it("keeps Claude streaming fragments that share a timestamp when hydrating", async () => { + // Claude V2 emits multiple text deltas inside tight streaming loops, + // so two legitimate envelopes with type:"text" can land on the same + // millisecond. A naive timestamp+type dedup key would collapse these; + // the cross-run-safe dedup must keep distinct payloads separate. + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const sharedTimestamp = new Date().toISOString(); + const envelope1: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: sharedTimestamp, + event: { type: "text", text: "fragment-a" }, + sequence: 1, + }; + const envelope2: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: sharedTimestamp, + event: { type: "text", text: "fragment-b" }, + sequence: 2, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope1)}\n${JSON.stringify(envelope2)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope1, envelope2]); + + const history = service.getChatEventHistory(session.id); + expect(history.events).toHaveLength(2); + expect(history.events.map((e) => e.event.type === "text" ? e.event.text : "")).toEqual([ + "fragment-a", + "fragment-b", + ]); + }); + + it("drops history when the underlying session is deleted", async () => { + // We don't rely on sendMessage emitting events (mock streams vary across + // providers), so we seed the transcript directly to verify the cleanup + // path. deleteSession must remove both the in-memory ring buffer and + // any hydrated-from-disk state so a subsequently-created session with + // the same id doesn't inherit stale events. + const emitted: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => emitted.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + // Seed the transcript on disk and populate the hydrated-from-disk cache + // BEFORE deleting, so a regression where deleteSession fails to clear + // the cache would actually be caught (an empty history trivially stays + // empty). + const envelope: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: new Date().toISOString(), + event: { type: "text", text: "before-delete" }, + sequence: 1, + }; + // createSession assigns managed.transcriptPath under `transcriptsDir`, + // so the hydration read is served from there (ahead of the + // chatTranscriptsDir fallback). + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.mkdirSync(path.dirname(transcriptFile), { recursive: true }); + fs.writeFileSync(transcriptFile, `${JSON.stringify(envelope)}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([envelope]); + const beforeDelete = service.getChatEventHistory(session.id); + expect(beforeDelete.events).toHaveLength(1); + + await service.deleteSession({ sessionId: session.id }); + + // The transcript-returning parser mock is still wired up, so if + // deleteSession fails to clear the cache / on-disk file, the next read + // would still surface envelopes. An empty result proves both the + // in-memory ring buffer and the hydrated state were cleared. + const afterDelete = service.getChatEventHistory(session.id); + expect(afterDelete.events).toEqual([]); + expect(afterDelete.truncated).toBe(false); + }); + }); + // -------------------------------------------------------------------------- // Session creation edge cases // -------------------------------------------------------------------------- @@ -5168,7 +5300,7 @@ describe("createAgentChatService", () => { } }); - it("tears down idle Claude runtimes after the inactivity ttl", async () => { + it("tears down idle Claude runtimes after the inactivity ttl without losing resume state", async () => { vi.useFakeTimers(); try { const close = vi.fn(); @@ -5231,6 +5363,169 @@ describe("createAgentChatService", () => { await vi.advanceTimersByTimeAsync(6 * 60_000); expect(close).toHaveBeenCalledTimes(1); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-idle-ttl"); + expect(persistedAfterIdle.lastLaneDirectiveKey).toEqual(expect.any(String)); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Follow up with the previous context", + timeoutMs: 15_000, + }); + + expect(unstable_v2_resumeSession).toHaveBeenCalledWith("sdk-session-idle-ttl", expect.any(Object)); + expect(unstable_v2_createSession).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(3); + expect(String(send.mock.calls[2]?.[0] ?? "")).toContain("Follow up with the previous context"); + } finally { + vi.useRealTimers(); + } + }); + + it("preserves Claude resume metadata across idle_ttl followed by shutdown", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-preserve", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + yield { + type: "assistant", + session_id: "sdk-session-preserve", + message: { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close, + sessionId: "sdk-session-preserve", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Say hi", + timeoutMs: 15_000, + }); + + // Idle-ttl teardown persists sdkSessionId + laneDirectiveKey. + await vi.advanceTimersByTimeAsync(6 * 60_000); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-preserve"); + const preservedLaneDirective = persistedAfterIdle.lastLaneDirectiveKey; + expect(preservedLaneDirective).toEqual(expect.any(String)); + + // Shutdown re-enters teardownRuntime with runtime already null. Must + // NOT clobber the preserved sdkSessionId/laneDirectiveKey. + service.forceDisposeAll(); + + const persistedAfterShutdown = readPersistedChatState(session.id); + expect(persistedAfterShutdown.sdkSessionId).toBe("sdk-session-preserve"); + expect(persistedAfterShutdown.lastLaneDirectiveKey).toBe(preservedLaneDirective); + } finally { + vi.useRealTimers(); + } + }); + + it("clears Claude resume metadata when a terminal teardown runs after idle_ttl", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-terminal", + slash_commands: [], + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "assistant", + session_id: "sdk-session-terminal", + message: { + content: [{ type: "text", text: "Done." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close, + sessionId: "sdk-session-terminal", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Say hi", + timeoutMs: 15_000, + }); + + // idle_ttl preserves sdkSessionId/laneDirectiveKey. + await vi.advanceTimersByTimeAsync(6 * 60_000); + const persistedAfterIdle = readPersistedChatState(session.id); + expect(persistedAfterIdle.sdkSessionId).toBe("sdk-session-terminal"); + expect(persistedAfterIdle.lastLaneDirectiveKey).toEqual(expect.any(String)); + + // Terminal teardown (user closes the chat) runs teardownRuntime with + // reason "ended_session" and runtime already null. Must still clear + // the preserved lane directive so a future resume of a different + // chat can't reattach to this ended session's lane context. + // dispose → finishSession → teardownRuntime("ended_session") without + // deleting the persisted state file. + await service.dispose({ sessionId: session.id }); + + const persistedAfterDispose = readPersistedChatState(session.id); + expect(persistedAfterDispose.lastLaneDirectiveKey ?? null).toBeNull(); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ba5ac8f7e..4015d6f4f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2522,6 +2522,26 @@ export function createAgentChatService(args: { const eventSubscribers = new Set<(event: AgentChatEventEnvelope) => void>(); + // In-memory ring buffer of recent chat events per session. Populated on every + // emitted event (see emitChatEvent → commitChatEvent), and also lazily hydrated + // from the on-disk transcript when a snapshot is requested. This is the canonical + // source of truth for "what should the renderer see right now" on resubscribe, + // remount, or project switch — persisted-transcript reads can miss events that + // were emitted while fs.appendFile was still in flight, and a project-switch + // gap drops all IPC deliveries until the user returns. + const CHAT_EVENT_HISTORY_MAX_PER_SESSION = 2_000; + const eventHistoryBySession = new Map(); + const eventHistoryHydratedSessionIds = new Set(); + + const recordChatEventInHistory = (envelope: AgentChatEventEnvelope): void => { + const current = eventHistoryBySession.get(envelope.sessionId) ?? []; + current.push(envelope); + if (current.length > CHAT_EVENT_HISTORY_MAX_PER_SESSION) { + current.splice(0, current.length - CHAT_EVENT_HISTORY_MAX_PER_SESSION); + } + eventHistoryBySession.set(envelope.sessionId, current); + }; + let computerUseArtifactBrokerRef = computerUseArtifactBrokerService ?? null; const layout = resolveAdeLayout(projectRoot); @@ -3665,6 +3685,132 @@ export function createAgentChatService(args: { } }; + // Read the full on-disk transcript for a session without requiring an active + // ManagedChatSession. Used by getChatEventHistory to hydrate the in-memory + // ring buffer on first read, even for sessions that haven't been resumed yet + // (e.g. a chat whose runtime was torn down by idle_ttl / budget_eviction). + const readTranscriptEnvelopesForSessionId = (sessionId: string): AgentChatEventEnvelope[] => { + const managed = managedSessions.get(sessionId); + if (managed?.transcriptPath) { + try { + return parseAgentChatTranscript(fs.readFileSync(managed.transcriptPath, "utf8")) + .filter((entry) => entry.sessionId === sessionId); + } catch { + return []; + } + } + // Fall back to the known transcript layout so sessions that were never + // ensured into managedSessions (e.g. because they were torn down and + // haven't been reopened yet) still surface their history. + const candidates = [ + path.join(transcriptsDir, `${sessionId}.chat.jsonl`), + path.join(chatTranscriptsDir, `${sessionId}.jsonl`), + ]; + for (const candidatePath of candidates) { + try { + if (!fs.existsSync(candidatePath)) continue; + const raw = fs.readFileSync(candidatePath, "utf8"); + return parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + } catch { + // try next candidate + } + } + return []; + }; + + const envelopeDedupKey = (entry: AgentChatEventEnvelope): string => { + // Cross-run-safe key: two envelopes are true duplicates iff timestamp, + // type, AND payload all match. Sequence numbers can't be trusted (they + // restart per run), and Claude streaming emits multiple text/reasoning + // fragments within the same millisecond + type — timestamp+type alone + // would wrongly collapse those into one. JSON.stringify is fine at our + // scale (≤2000 events, events typically <1KB). + return `${entry.timestamp}#${entry.event.type}#${JSON.stringify(entry.event)}`; + }; + + const mergeEnvelopeStreams = ( + base: AgentChatEventEnvelope[], + tail: AgentChatEventEnvelope[], + ): AgentChatEventEnvelope[] => { + if (!base.length) return tail.slice(); + if (!tail.length) return base.slice(); + const baseKeys = new Set(base.map(envelopeDedupKey)); + const merged = base.slice(); + for (const entry of tail) { + if (baseKeys.has(envelopeDedupKey(entry))) continue; + merged.push(entry); + } + merged.sort((left, right) => { + // Timestamp is cross-run consistent; sequence is only a tiebreak + // within the same run. + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if (leftTime !== rightTime) return leftTime - rightTime; + if (typeof left.sequence === "number" && typeof right.sequence === "number") { + return left.sequence - right.sequence; + } + return 0; + }); + return merged; + }; + + /** + * Return the complete, ordered event history for a chat session. + * + * On first call (or any call that can tolerate a larger read), we merge the + * on-disk transcript with the in-memory ring buffer so that: + * - events that were emitted while the renderer was on a different project + * (and therefore dropped by emitProjectEvent) are still recovered; + * - events that are still in fs.appendFile flight but already recorded in + * the buffer are still delivered; + * - truncating the persistent transcript for size does not lose recent + * events that the buffer still has. + * + * This is the canonical snapshot path for renderer resubscribe / remount. + */ + const getChatEventHistory = ( + sessionId: string, + options?: { maxEvents?: number }, + ): { sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean } => { + const trimmedId = sessionId.trim(); + if (!trimmedId.length) { + return { sessionId: trimmedId, events: [], truncated: false }; + } + // Validate the session belongs to an agent chat before reading any + // transcript path — this function is reachable via IPC and builds + // filesystem paths from `trimmedId` downstream. + const row = sessionService.get(trimmedId); + if (!row || !isChatToolType(row.toolType)) { + return { sessionId: trimmedId, events: [], truncated: false }; + } + const maxEvents = Math.max( + 1, + Math.min(CHAT_EVENT_HISTORY_MAX_PER_SESSION, Math.floor(options?.maxEvents ?? CHAT_EVENT_HISTORY_MAX_PER_SESSION)), + ); + + // Hydrate the in-memory buffer from disk the first time we see a session, + // so a resubscribe after project switch or app restart has both the + // persisted history and any live events that arrived afterwards. + const bufferExisting = eventHistoryBySession.get(trimmedId) ?? []; + let merged: AgentChatEventEnvelope[]; + if (!eventHistoryHydratedSessionIds.has(trimmedId)) { + const diskEnvelopes = readTranscriptEnvelopesForSessionId(trimmedId); + merged = mergeEnvelopeStreams(diskEnvelopes, bufferExisting); + // Cap after hydration so subsequent writes don't drift past the limit. + if (merged.length > CHAT_EVENT_HISTORY_MAX_PER_SESSION) { + merged = merged.slice(-CHAT_EVENT_HISTORY_MAX_PER_SESSION); + } + eventHistoryBySession.set(trimmedId, merged.slice()); + eventHistoryHydratedSessionIds.add(trimmedId); + } else { + merged = bufferExisting.slice(); + } + + const truncated = merged.length > maxEvents; + const windowed = truncated ? merged.slice(-maxEvents) : merged; + return { sessionId: trimmedId, events: windowed, truncated }; + }; + const deriveTranscriptTurnActive = (entries: AgentChatEventEnvelope[]): boolean => { let turnActive = false; for (const entry of entries) { @@ -5105,6 +5251,7 @@ export function createAgentChatService(args: { }; writeTranscript(managed, envelope); + recordChatEventInHistory(envelope); onEvent?.(envelope); for (const subscriber of eventSubscribers) { try { @@ -5524,6 +5671,31 @@ export function createAgentChatService(args: { ): void => { flushBufferedReasoning(managed); flushBufferedText(managed); + + const reasonAllowsPreservation = + openCodeReason === "idle_ttl" + || openCodeReason === "budget_eviction" + || openCodeReason === "pool_compaction" + || openCodeReason === "paused_run" + || openCodeReason === "project_close" + || openCodeReason === "shutdown"; + + // If a prior teardown (e.g., idle_ttl) already released the runtime: + // - Non-terminal reasons keep the prior teardown's preserved resume + // metadata on disk (bail). + // - Terminal reasons (handle_close, ended_session, model_switch) must + // still invalidate so a future resume can't reattach to a session + // the user actually closed. + if (!managed.runtime) { + if (!reasonAllowsPreservation) { + managed.runtimeInvalidated = true; + clearLaneDirectiveKey(managed); + } + return; + } + + const preserveClaudeResumeState = + managed.runtime.kind === "claude" && reasonAllowsPreservation; if (managed.runtime?.kind === "codex") { managed.runtime.suppressExitError = true; try { managed.runtime.reader.close(); } catch { /* ignore */ } @@ -5538,6 +5710,10 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "claude") { // Mark interrupted so the streaming catch block takes the graceful path managed.runtime.interrupted = true; + if (preserveClaudeResumeState) { + managed.runtime.sdkSessionId = managed.runtime.sdkSessionId ?? managed.runtime.v2Session?.sessionId ?? null; + persistChatState(managed); + } cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.v2Session?.close(); } catch { /* ignore */ } managed.runtime.v2Session = null; @@ -5579,8 +5755,10 @@ export function createAgentChatService(args: { if (rt.pooled) releaseCursorAcpConnection(rt.poolKey); managed.runtime = null; } - managed.runtimeInvalidated = true; - clearLaneDirectiveKey(managed); + managed.runtimeInvalidated = !preserveClaudeResumeState; + if (!preserveClaudeResumeState) { + clearLaneDirectiveKey(managed); + } }; const keepChatSessionOpen = ( @@ -12991,6 +13169,8 @@ export function createAgentChatService(args: { } else { clearSubagentSnapshots(trimmedSessionId); } + eventHistoryBySession.delete(trimmedSessionId); + eventHistoryHydratedSessionIds.delete(trimmedSessionId); const persistedMetadataPath = metadataPathFor(trimmedSessionId); const dedicatedTranscriptPath = path.join(chatTranscriptsDir, `${trimmedSessionId}.jsonl`); @@ -13024,7 +13204,6 @@ export function createAgentChatService(args: { } for (const [sessionId, managed] of managedSessions) { try { - managed.deleted = true; clearSubagentSnapshots(sessionId); for (const pending of managed.localPendingInputs.values()) { pending.resolve({ decision: "cancel" }); @@ -13033,7 +13212,10 @@ export function createAgentChatService(args: { managed.closed = true; managed.endedNotified = true; managed.ctoSessionStartedAt = null; + // teardownRuntime must run before `deleted = true` so its persistChatState() + // call can write the preserved Claude resume metadata for "shutdown". teardownRuntime(managed, "shutdown"); + managed.deleted = true; } catch { // ignore emergency shutdown failures } @@ -13801,6 +13983,7 @@ export function createAgentChatService(args: { listSessions, getSessionSummary, getChatTranscript, + getChatEventHistory, ensureIdentitySession, approveToolUse, respondToInput, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 549e65250..c2225c8ec 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -177,6 +177,7 @@ import type { AgentChatDeleteArgs, AgentChatDisposeArgs, AgentChatGetSummaryArgs, + AgentChatEventEnvelope, AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, @@ -4408,6 +4409,24 @@ export function registerIpc({ }; }); + ipcMain.handle(IPC.agentChatGetEventHistory, async ( + _event, + arg: { sessionId?: string; maxEvents?: number }, + ): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => { + const ctx = getCtx(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) return { sessionId: "", events: [], truncated: false }; + // Only forward maxEvents when it is a finite positive number; the service + // layer applies its own clamp but guarding here avoids ambiguous NaN/0 + // inputs from untrusted renderer IPC. + const rawMaxEvents = typeof arg?.maxEvents === "number" ? arg.maxEvents : undefined; + const maxEvents = + rawMaxEvents != null && Number.isFinite(rawMaxEvents) && rawMaxEvents > 0 + ? rawMaxEvents + : undefined; + return ctx.agentChatService.getChatEventHistory(sessionId, maxEvents != null ? { maxEvents } : undefined); + }); + ipcMain.handle(IPC.computerUseListArtifacts, async (_event, arg: ComputerUseArtifactListArgs = {}): Promise => { const ctx = ensureComputerUseBroker(); return ctx.computerUseArtifactBrokerService.listArtifacts(arg); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts index 544ecc913..0ece472f6 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.test.ts @@ -62,6 +62,58 @@ describe("deviceRegistryService", () => { db2.close(); }); + it("can share a desktop device identity across project registries", async () => { + const projectRootA = makeProjectRoot("ade-device-registry-global-a-"); + const projectRootB = makeProjectRoot("ade-device-registry-global-b-"); + const globalDeviceIdPath = path.join(os.tmpdir(), `ade-global-device-${Date.now()}-${Math.random()}`, "sync-device-id"); + + const dbA = await openKvDb(path.join(projectRootA, ".ade", "ade.db"), createLogger() as any); + const dbB = await openKvDb(path.join(projectRootB, ".ade", "ade.db"), createLogger() as any); + const registryA = createDeviceRegistryService({ + db: dbA, + logger: createLogger() as any, + projectRoot: projectRootA, + localDeviceIdPath: globalDeviceIdPath, + }); + const registryB = createDeviceRegistryService({ + db: dbB, + logger: createLogger() as any, + projectRoot: projectRootB, + localDeviceIdPath: globalDeviceIdPath, + }); + + const localA = registryA.ensureLocalDevice(); + const localB = registryB.ensureLocalDevice(); + + expect(localB.deviceId).toBe(localA.deviceId); + expect(localB.siteId).not.toBe(localA.siteId); + + dbA.close(); + dbB.close(); + }); + + it("migrates the legacy project device identity into the shared desktop identity file", async () => { + const projectRoot = makeProjectRoot("ade-device-registry-global-migrate-"); + const legacyDeviceId = "legacy-project-device-id"; + const legacyDeviceIdPath = path.join(projectRoot, ".ade", "secrets", "sync-device-id"); + const globalDeviceIdPath = path.join(os.tmpdir(), `ade-global-device-migrate-${Date.now()}-${Math.random()}`, "sync-device-id"); + fs.mkdirSync(path.dirname(legacyDeviceIdPath), { recursive: true }); + fs.writeFileSync(legacyDeviceIdPath, `${legacyDeviceId}\n`); + + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), createLogger() as any); + const registry = createDeviceRegistryService({ + db, + logger: createLogger() as any, + projectRoot, + localDeviceIdPath: globalDeviceIdPath, + }); + + expect(registry.ensureLocalDevice().deviceId).toBe(legacyDeviceId); + expect(fs.readFileSync(globalDeviceIdPath, "utf8").trim()).toBe(legacyDeviceId); + + db.close(); + }); + it("persists notification preferences in device metadata across registry restarts", async () => { const projectRoot = makeProjectRoot("ade-device-registry-prefs-"); const dbPath = path.join(projectRoot, ".ade", "ade.db"); diff --git a/apps/desktop/src/main/services/sync/deviceRegistryService.ts b/apps/desktop/src/main/services/sync/deviceRegistryService.ts index 0c6c56e67..df3149c18 100644 --- a/apps/desktop/src/main/services/sync/deviceRegistryService.ts +++ b/apps/desktop/src/main/services/sync/deviceRegistryService.ts @@ -22,6 +22,7 @@ type DeviceRegistryServiceArgs = { db: AdeDb; logger: Logger; projectRoot: string; + localDeviceIdPath?: string; }; type DeviceRow = { @@ -138,12 +139,28 @@ function firstPreferredHost(ipAddresses: string[]): string { export function createDeviceRegistryService(args: DeviceRegistryServiceArgs) { const layout = resolveAdeLayout(args.projectRoot); - const deviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); + const deviceIdPath = args.localDeviceIdPath ?? path.join(layout.secretsDir, DEVICE_ID_FILE); + const legacyProjectDeviceIdPath = path.join(layout.secretsDir, DEVICE_ID_FILE); fs.mkdirSync(path.dirname(deviceIdPath), { recursive: true }); const readOrCreateLocalDeviceId = (): string => { - const existing = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; - if (existing.length > 0) return existing; + const shared = fs.existsSync(deviceIdPath) ? fs.readFileSync(deviceIdPath, "utf8").trim() : ""; + const legacy = deviceIdPath !== legacyProjectDeviceIdPath && fs.existsSync(legacyProjectDeviceIdPath) + ? fs.readFileSync(legacyProjectDeviceIdPath, "utf8").trim() + : ""; + // If this project has a legacy per-project sync-device-id, always prefer + // it so existing iOS pairings and `sync_cluster_state.brain_device_id` + // references stay valid. The shared file is only written when it would + // agree (empty or already matching) — never overridden with a different + // value, so opening project B after A no longer flips B's identity to + // A's ID and drops B into viewer mode. + if (legacy.length > 0) { + if (shared.length === 0 || shared === legacy) { + writeTextAtomic(deviceIdPath, `${legacy}\n`); + } + return legacy; + } + if (shared.length > 0) return shared; const created = randomUUID(); writeTextAtomic(deviceIdPath, `${created}\n`); return created; diff --git a/apps/desktop/src/main/services/sync/syncHostService.ts b/apps/desktop/src/main/services/sync/syncHostService.ts index 679209515..69c95a433 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.ts @@ -173,6 +173,7 @@ type SyncHostServiceArgs = { pinStore: SyncPinStore; bootstrapTokenPath?: string; port?: number; + discoveryEnabled?: boolean; heartbeatIntervalMs?: number; pollIntervalMs?: number; brainStatusIntervalMs?: number; @@ -699,13 +700,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let tailnetServeSignature: string | null = null; let tailnetServePublishSequence = 0; let tailnetServeActivePublishToken = 0; + let discoveryEnabled = args.discoveryEnabled !== false; let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { - state: shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", + state: !discoveryEnabled + ? "disabled" + : shouldAttemptTailnetServiceAdvertise() ? "disabled" : "unavailable", serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, target: null, updatedAt: null, - error: shouldAttemptTailnetServiceAdvertise() + error: !discoveryEnabled + ? "Tailnet discovery is disabled for this background project context." + : shouldAttemptTailnetServiceAdvertise() ? "Tailnet discovery has not been published yet." : "Tailscale Serve discovery is not available in this desktop process.", stderr: null, @@ -828,6 +834,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const publishLanDiscovery = (port: number): void => { if (disposed) return; + if (!discoveryEnabled) { + unpublishLanDiscovery(); + return; + } const localDevice = args.deviceRegistryService?.ensureLocalDevice() ?? null; const hostName = localDevice?.name ?? os.hostname(); const ipAddresses = uniqueStrings([ @@ -880,6 +890,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); }; + const unpublishLanDiscovery = (): void => { + if (!bonjourAnnouncement) return; + try { + bonjourAnnouncement.stop?.(); + } catch { + // ignore cleanup failures + } + bonjourAnnouncement = null; + bonjourPort = null; + bonjourSignature = null; + }; + const updateTailnetDiscoveryStatus = ( next: SyncTailnetDiscoveryStatus, ): void => { @@ -894,6 +916,19 @@ export function createSyncHostService(args: SyncHostServiceArgs) { options?: { force?: boolean }, ): void => { if (disposed) return; + if (!discoveryEnabled) { + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } if (!shouldAttemptTailnetServiceAdvertise()) { updateTailnetDiscoveryStatus({ state: "unavailable", @@ -2018,6 +2053,30 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } }, + setDiscoveryEnabled(enabled: boolean): void { + if (discoveryEnabled === enabled) return; + discoveryEnabled = enabled; + const address = server.address(); + if (!enabled) { + unpublishLanDiscovery(); + void unpublishTailnetDiscovery(); + updateTailnetDiscoveryStatus({ + state: "disabled", + serviceName: SYNC_TAILNET_DISCOVERY_SERVICE_NAME, + servicePort: SYNC_TAILNET_DISCOVERY_SERVICE_PORT, + target: null, + updatedAt: nowIso(), + error: "Tailnet discovery is disabled for this background project context.", + stderr: null, + }); + return; + } + if (typeof address === "object" && address) { + publishLanDiscovery(address.port); + publishTailnetDiscovery(address.port, { force: true }); + } + }, + revokePairedDevice(deviceId: string): void { pairingStore.revoke(deviceId); let revokedConnectedPeer = false; @@ -2127,6 +2186,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { clearInterval(pollTimer); clearInterval(heartbeatTimer); clearInterval(brainStatusTimer); + unpublishLanDiscovery(); try { await unpublishTailnetDiscovery(); } catch { diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index bdd07cca9..91237674d 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -37,6 +37,7 @@ const { createSyncHostServiceMock } = vi.hoisted(() => ({ }, handlePtyData() {}, handlePtyExit() {}, + setDiscoveryEnabled: vi.fn(), async dispose() {}, })), })); @@ -541,6 +542,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { }, handlePtyData() {}, handlePtyExit() {}, + setDiscoveryEnabled: vi.fn(), dispose: attemptedPort === 8787 ? disposeFirstAttempt : disposeSecondAttempt, }; }) as any); diff --git a/apps/desktop/src/main/services/sync/syncService.ts b/apps/desktop/src/main/services/sync/syncService.ts index b78d3b56d..173a3b66f 100644 --- a/apps/desktop/src/main/services/sync/syncService.ts +++ b/apps/desktop/src/main/services/sync/syncService.ts @@ -64,6 +64,7 @@ type SyncServiceArgs = { db: AdeDb; logger: Logger; projectRoot: string; + localDeviceIdPath?: string; fileService: ReturnType; laneService: ReturnType; gitService?: ReturnType; @@ -104,6 +105,7 @@ type SyncServiceArgs = { getLinearSyncService?: () => ReturnType | null; processService: ReturnType; hostStartupEnabled?: boolean; + hostDiscoveryEnabled?: boolean; onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; /** * Optional notification bus forwarded to the sync host. The host publishes @@ -278,6 +280,7 @@ export function createSyncService(args: SyncServiceArgs) { db: args.db, logger: args.logger, projectRoot: args.projectRoot, + localDeviceIdPath: args.localDeviceIdPath, }); let hostService: SyncHostService | null = null; @@ -285,6 +288,7 @@ export function createSyncService(args: SyncServiceArgs) { let refreshQueued = false; let disposed = false; const hostStartupEnabled = args.hostStartupEnabled !== false; + let hostDiscoveryEnabled = args.hostDiscoveryEnabled !== false; let activeLocalLanePresenceIds: string[] = []; const localLanePresenceHeartbeatTimer = setInterval(() => { if (disposed || !hostService || activeLocalLanePresenceIds.length === 0) return; @@ -432,6 +436,7 @@ export function createSyncService(args: SyncServiceArgs) { pinStore, bootstrapTokenPath: tokenPath, port: attemptedPort, + discoveryEnabled: hostDiscoveryEnabled, deviceRegistryService, notificationEventBus: args.notificationEventBus ?? null, projectCatalogProvider: args.projectCatalogProvider, @@ -718,6 +723,12 @@ export function createSyncService(args: SyncServiceArgs) { return snapshot; }, + setHostDiscoveryEnabled(enabled: boolean): void { + hostDiscoveryEnabled = enabled; + hostService?.setDiscoveryEnabled(enabled); + void emitStatus(); + }, + async updateLocalDevice(argsIn: { name?: string; deviceType?: "desktop" | "phone" | "vps" | "unknown"; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index ff2a13989..a411e212e 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -1145,6 +1145,14 @@ declare global { data: string; filename: string; }) => Promise<{ path: string }>; + getEventHistory: (args: { + sessionId: string; + maxEvents?: number; + }) => Promise<{ + sessionId: string; + events: AgentChatEventEnvelope[]; + truncated: boolean; + }>; }; computerUse: { listArtifacts: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 32a4a5664..9c7652af0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1624,6 +1624,11 @@ contextBridge.exposeInMainWorld("ade", { filename: string; }): Promise<{ path: string }> => ipcRenderer.invoke(IPC.agentChatSaveTempAttachment, args), + getEventHistory: async (args: { + sessionId: string; + maxEvents?: number; + }): Promise<{ sessionId: string; events: AgentChatEventEnvelope[]; truncated: boolean }> => + ipcRenderer.invoke(IPC.agentChatGetEventHistory, args), }, computerUse: { listArtifacts: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 4cc0108c6..c93f3d586 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -2382,6 +2382,11 @@ if (typeof window !== "undefined" && !(window as any).ade) { onEvent: noop, slashCommands: resolvedArg([]), fileSearch: resolvedArg([]), + getEventHistory: async (arg: { sessionId: string; maxEvents?: number }) => ({ + sessionId: typeof arg?.sessionId === "string" ? arg.sessionId : "", + events: [], + truncated: false, + }), }, cto: { getState: resolvedArg({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index fb2e5e72d..14683b24d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1434,19 +1434,52 @@ export function AgentChatPane({ loadedHistoryRef.current.add(sessionId); try { - const summary = await window.ade.sessions.get(sessionId); - if (!summary || !isChatToolType(summary.toolType)) return; - const raw = await window.ade.sessions.readTranscriptTail({ - sessionId, - maxBytes: CHAT_HISTORY_READ_MAX_BYTES, - raw: true - }); - const parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + // Prefer the main-process snapshot API which merges the in-memory event + // ring buffer with the on-disk transcript. This recovers events that + // were emitted while the user was on a different project (IPC dropped), + // events that were still in fs.appendFile flight when a previous load + // ran, and the full history even when the transcript has been truncated + // for size. Fall back to the disk-only readTranscriptTail path if the + // snapshot call fails or the desktop app is running against an older + // main-process build that lacks the handler. + let parsed: AgentChatEventEnvelope[] = []; + let usedSnapshotPath = false; + try { + if (typeof window.ade.agentChat.getEventHistory === "function") { + const snapshot = await window.ade.agentChat.getEventHistory({ + sessionId, + maxEvents: MAX_SELECTED_CHAT_SESSION_EVENTS, + }); + if (snapshot?.events?.length || snapshot?.sessionId === sessionId) { + parsed = (snapshot.events ?? []).filter((entry) => entry.sessionId === sessionId); + usedSnapshotPath = true; + } + } + } catch { + usedSnapshotPath = false; + } + if (!usedSnapshotPath) { + const summary = await window.ade.sessions.get(sessionId); + 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 + // propagated on project switch) would leave the UI permanently + // unable to hydrate history. Mirrors the catch-block recovery below. + loadedHistoryRef.current.delete(sessionId); + return; + } + const raw = await window.ade.sessions.readTranscriptTail({ + sessionId, + maxBytes: CHAT_HISTORY_READ_MAX_BYTES, + raw: true + }); + parsed = parseAgentChatTranscript(raw).filter((entry) => entry.sessionId === sessionId); + } // If real-time events have already been received for this session - // (via flushQueuedEvents), the on-disk transcript may be stale. - // Merge: use the loaded history as a base but keep any real-time - // events that arrived after the last event in the transcript. + // (via flushQueuedEvents), the snapshot may be stale by a few events. + // Merge: use the snapshot as a base but keep any real-time events that + // arrived after the last snapshot entry. const existing = eventsBySessionRef.current[sessionId] ?? []; let merged: AgentChatEventEnvelope[]; if (existing.length && parsed.length) { @@ -1486,7 +1519,11 @@ export function AgentChatPane({ setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: derived.pendingSteers })); } catch { - // Ignore transcript history failures. + // Clear the loaded flag so the caller can retry on next remount or tab + // switch — otherwise a transient failure leaves the UI stuck with no + // events. Without this clearSessionView, a failed initial load + // permanently blocked re-entry until the chat received a new event. + loadedHistoryRef.current.delete(sessionId); } }, [initialSessionSummary, lockSessionId]); @@ -1691,8 +1728,13 @@ export function AgentChatPane({ void loadHistory(selectedSessionId, { force: true }); return; } + // Locked-single-session mode (Work tab tile). Force-reload on every mount + // so that when the pane is unmounted and remounted (tab switch, project + // switch, session tile activation) we always pull the freshest snapshot + // rather than short-circuiting on a stale loadedHistoryRef from the + // previous component instance. const handle = window.setTimeout(() => { - void loadHistory(selectedSessionId); + void loadHistory(selectedSessionId, { force: true }); }, 120); return () => window.clearTimeout(handle); }, [loadHistory, lockedSingleSessionMode, selectedSessionId]); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx new file mode 100644 index 000000000..7ca259a91 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.test.tsx @@ -0,0 +1,112 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary, TerminalSessionSummary } from "../../../shared/types"; +import { SessionListPane } from "./SessionListPane"; + +vi.mock("./useSessionDelta", () => ({ + useSessionDelta: () => null, +})); + +vi.mock("./ToolLogos", () => ({ + ToolLogo: () => , +})); + +function makeLane(overrides: Partial = {}): LaneSummary { + return { + id: "lane-known", + name: "Known Lane", + laneType: "worktree", + baseRef: "main", + branchRef: "known-lane", + worktreePath: "/tmp/known-lane", + parentLaneId: null, + childCount: 0, + stackDepth: 0, + parentStatus: null, + isEditProtected: false, + status: { dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false }, + color: null, + icon: null, + tags: [], + createdAt: "2026-04-22T10:00:00.000Z", + ...overrides, + }; +} + +function makeSession(overrides: Partial = {}): TerminalSessionSummary { + return { + id: "session-mobile", + laneId: "lane-mobile", + laneName: "Mobile-created lane", + ptyId: null, + tracked: true, + pinned: false, + manuallyNamed: false, + goal: null, + toolType: "codex-chat", + title: "Mobile Tool Streaming UI", + status: "running", + startedAt: "2026-04-22T22:13:02.691Z", + endedAt: null, + exitCode: null, + transcriptPath: ".ade/transcripts/session-mobile.chat.jsonl", + headShaStart: null, + headShaEnd: null, + lastOutputPreview: null, + summary: null, + runtimeState: "running", + resumeCommand: null, + ...overrides, + }; +} + +function renderPane(props: Partial> = {}) { + const session = makeSession(); + return render( + + + , + ); +} + +describe("SessionListPane", () => { + it("renders by-lane sessions whose lane is missing from the cached lane list", () => { + renderPane(); + + expect(screen.getAllByText("Mobile-created lane")).toHaveLength(2); + expect(screen.getByText("Mobile Tool Streaming UI")).toBeTruthy(); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx index 48c88e52b..759fdf96a 100644 --- a/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx +++ b/apps/desktop/src/renderer/components/terminals/SessionListPane.tsx @@ -151,6 +151,30 @@ export const SessionListPane = React.memo(function SessionListPane({ for (const lane of lanes) map.set(lane.id, lane); return map; }, [lanes]); + const missingLaneSessionGroups = useMemo(() => { + if (!sessionsGroupedByLane) return []; + const knownLaneIds = new Set(lanes.map((lane) => lane.id)); + const latestStartedAt = (sessions: TerminalSessionSummary[]): number => { + const times = sessions + .map((session) => new Date(session.startedAt).getTime()) + .filter(Number.isFinite); + return times.length > 0 ? Math.max(...times) : -Infinity; + }; + const orphanLabel = (name: string | null | undefined, fallback: string): string => { + const trimmed = (name ?? "").trim(); + return trimmed.length > 0 ? trimmed : fallback; + }; + return [...sessionsGroupedByLane.entries()] + .filter(([laneId, sessions]) => !knownLaneIds.has(laneId) && sessions.length > 0) + .sort(([leftLaneId, leftSessions], [rightLaneId, rightSessions]) => { + const leftLatest = latestStartedAt(leftSessions); + const rightLatest = latestStartedAt(rightSessions); + if (leftLatest !== rightLatest) return rightLatest - leftLatest; + const leftName = orphanLabel(leftSessions[0]?.laneName, leftLaneId); + const rightName = orphanLabel(rightSessions[0]?.laneName, rightLaneId); + return leftName.localeCompare(rightName); + }); + }, [lanes, sessionsGroupedByLane]); // First-rendered card carries `data-tour="work.sessionItem"` so the Work // tab tour can anchor at a real session. We track whether we've already @@ -245,6 +269,24 @@ export const SessionListPane = React.memo(function SessionListPane({ ); })} + {missingLaneSessionGroups.map(([laneId, list]) => { + const collapsed = workCollapsedLaneIds.includes(laneId); + const trimmedLaneName = (list[0]?.laneName ?? "").trim(); + const label = trimmedLaneName.length > 0 ? trimmedLaneName : laneId; + return ( + } + label={label} + count={list.length} + collapsed={collapsed} + onToggleCollapsed={() => toggleWorkLaneCollapsed(laneId)} + > + {renderCards(list)} + + ); + })} ); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 86e5e4692..f96736444 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -148,6 +148,7 @@ export const IPC = { agentChatListSubagents: "ade.agentChat.listSubagents", agentChatGetSessionCapabilities: "ade.agentChat.getSessionCapabilities", agentChatGetTurnFileDiff: "ade.agentChat.getTurnFileDiff", + agentChatGetEventHistory: "ade.agentChat.getEventHistory", computerUseListArtifacts: "ade.computerUse.listArtifacts", computerUseGetOwnerSnapshot: "ade.computerUse.getOwnerSnapshot", computerUseRouteArtifact: "ade.computerUse.routeArtifact", diff --git a/apps/ios/ADE/Info.plist b/apps/ios/ADE/Info.plist index 496c50627..52268c632 100644 --- a/apps/ios/ADE/Info.plist +++ b/apps/ios/ADE/Info.plist @@ -37,6 +37,21 @@ NSAllowsLocalNetworking + NSExceptionDomains + + ade-sync + + NSExceptionAllowsInsecureHTTPLoads + + + ts.net + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + NSBonjourServices diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 8769538d1..58ed9de3f 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -166,7 +166,9 @@ enum SyncRequestTimeout { } private let syncTerminalSubscriptionMaxBytes = 80_000 +private let syncChatSubscriptionMaxBytes = 2_000_000 private let syncTerminalBufferMaxCharacters = 80_000 +private let chatEventHistoryMaxEvents = 1_000 enum SyncBonjourTiming { static let searchRetryNanoseconds: UInt64 = 2_000_000_000 @@ -188,7 +190,6 @@ enum SyncTailnetDiscoveryTiming { enum SyncTailnetDiscovery { static let hostCandidates = [ "ade-sync", - "ade-desktop", ] static let portCandidates = [ 8787, @@ -215,6 +216,35 @@ func syncIsTailscaleIPv4Address(_ host: String) -> Bool { return first == 100 && (64...127).contains(second) } +func syncNormalizedRouteHost(_ address: String) -> String { + var host = address.trimmingCharacters(in: .whitespacesAndNewlines) + if let schemeRange = host.range(of: "://") { + host = String(host[schemeRange.upperBound...]) + } + if let slash = host.firstIndex(of: "/") { host = String(host[.. Bool { + let host = syncNormalizedRouteHost(address) + if host.isEmpty { return false } + return syncIsTailscaleIPv4Address(host) + || syncIsTailnetDiscoveryHost(host) + || host.hasSuffix(".ts.net") +} + struct SyncReconnectState { private(set) var attempts = 0 @@ -871,9 +901,9 @@ final class SyncService: ObservableObject { discoveredLanAddresses: addressCandidates.filter { host in guard !host.contains(":") else { return false } guard host != "127.0.0.1" else { return false } - return !syncIsTailscaleIPv4Address(host) + return !syncIsTailscaleRoute(host) }, - tailscaleAddress: addressCandidates.first(where: syncIsTailscaleIPv4Address) + tailscaleAddress: addressCandidates.first(where: syncIsTailscaleRoute) ) let previousActiveProjectId = activeProjectId @@ -1214,14 +1244,14 @@ final class SyncService: ObservableObject { } let tailscaleAddress = profile.tailscaleAddress - ?? profile.savedAddressCandidates.first(where: syncIsTailscaleIPv4Address) - ?? profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? $0 : nil } - let lanAddresses = profile.discoveredLanAddresses.filter { !syncIsTailscaleIPv4Address($0) } - let savedLanAddresses = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + ?? profile.savedAddressCandidates.first(where: syncIsTailscaleRoute) + ?? profile.lastSuccessfulAddress.flatMap { syncIsTailscaleRoute($0) ? $0 : nil } + let lanAddresses = profile.discoveredLanAddresses.filter { !syncIsTailscaleRoute($0) } + let savedLanAddresses = profile.savedAddressCandidates.filter { !syncIsTailscaleRoute($0) } let addresses = deduplicatedAddresses( lanAddresses + savedLanAddresses - + (profile.lastSuccessfulAddress.flatMap { syncIsTailscaleIPv4Address($0) ? nil : $0 }.map { [$0] } ?? []) + + (profile.lastSuccessfulAddress.flatMap { syncIsTailscaleRoute($0) ? nil : $0 }.map { [$0] } ?? []) ) guard tailscaleAddress != nil || !addresses.isEmpty else { return nil } let identity = profile.hostIdentity?.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1313,7 +1343,7 @@ final class SyncService: ObservableObject { return } - let connectedOverTailnet = currentAddress.map(syncIsTailscaleIPv4Address) ?? false + let connectedOverTailnet = currentAddress.map(syncIsTailscaleRoute) ?? false let shouldRoamToTailnet = !connectedOverTailnet && profileHasTailnetRoute(profile) @@ -1487,16 +1517,9 @@ final class SyncService: ObservableObject { discoveredLanAddresses: addressCandidates.filter { host in guard !host.contains(":") else { return false } if host == "127.0.0.1" { return false } - let octets = host.split(separator: ".") - guard octets.count == 4 else { return false } - guard let first = octets.first.flatMap({ Int($0) }), - let second = octets.dropFirst().first.flatMap({ Int($0) }) else { - return false - } - let isTailscale = first == 100 && (64...127).contains(second) - return !isTailscale + return !syncIsTailscaleRoute(host) }, - tailscaleAddress: tailscaleAddress + tailscaleAddress: tailscaleAddress ?? addressCandidates.first(where: syncIsTailscaleRoute) ) saveProfile(profile) currentAddress = preferredAddress @@ -3199,35 +3222,20 @@ final class SyncService: ObservableObject { private func syncCanAttemptPlaintextWebSocket(_ address: String) -> Bool { let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return false } - - // Strip a leading scheme so raw hosts like "192.168.1.10:7878" and - // "ws://192.168.1.10:7878" both flow through the same path. - var host = trimmed - if let schemeRange = host.range(of: "://") { - let scheme = host[.. Bool { - if profile.tailscaleAddress.map(syncIsTailscaleIPv4Address) == true { return true } - if profile.lastSuccessfulAddress.map(syncIsTailscaleIPv4Address) == true { return true } - return profile.savedAddressCandidates.contains(where: syncIsTailscaleIPv4Address) + if profile.tailscaleAddress.map(syncIsTailscaleRoute) == true { return true } + if profile.lastSuccessfulAddress.map(syncIsTailscaleRoute) == true { return true } + return profile.savedAddressCandidates.contains(where: syncIsTailscaleRoute) } private func preferTailnetForUpcomingReconnect() { @@ -3406,8 +3414,8 @@ final class SyncService: ObservableObject { let liveLastSuccessful = profile.lastSuccessfulAddress.flatMap { address in liveSet.contains(address) ? [address] : nil } ?? [] - let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleIPv4Address) - let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleIPv4Address($0) } + let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleRoute) + let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleRoute($0) } // Prefer addresses we see RIGHT NOW on the network over anything we have // cached from previous sessions. If the user changed subnets, stale // entries would otherwise consume the first few attempts (each with its @@ -3416,8 +3424,8 @@ final class SyncService: ObservableObject { let prioritizedLive = preferTailnet ? liveLastSuccessfulTailnet + liveTailscale + liveLastSuccessfulLan + liveLan : liveLastSuccessful + liveLan + liveTailscale - let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) - let savedLan = profile.savedAddressCandidates.filter { !syncIsTailscaleIPv4Address($0) } + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleRoute) + let savedLan = profile.savedAddressCandidates.filter { !syncIsTailscaleRoute($0) } let fallbackLastSuccessful = liveLastSuccessful.isEmpty ? (profile.lastSuccessfulAddress.map { [$0] } ?? []) : [] let savedProfileTailnet = profile.tailscaleAddress.map { [$0] } ?? [] let fallbackSaved: [String] @@ -3443,9 +3451,9 @@ final class SyncService: ObservableObject { matchesDiscoveredHost(host, profile: profile) } guard !matchingDiscovery.isEmpty else { - let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleIPv4Address) + let savedTailnet = profile.savedAddressCandidates.filter(syncIsTailscaleRoute) let lastSuccessfulTailnet = profile.lastSuccessfulAddress.flatMap { address in - syncIsTailscaleIPv4Address(address) ? [address] : nil + syncIsTailscaleRoute(address) ? [address] : nil } ?? [] return deduplicatedAddresses( (preferTailnet ? [] : lastSuccessfulTailnet) @@ -3461,8 +3469,8 @@ final class SyncService: ObservableObject { let liveLastSuccessful = profile.lastSuccessfulAddress.flatMap { address in liveSet.contains(address) ? [address] : nil } ?? [] - let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleIPv4Address) - let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleIPv4Address($0) } + let liveLastSuccessfulTailnet = liveLastSuccessful.filter(syncIsTailscaleRoute) + let liveLastSuccessfulLan = liveLastSuccessful.filter { !syncIsTailscaleRoute($0) } let prioritizedLive = preferTailnet ? liveLastSuccessfulTailnet + liveTailscale + liveLastSuccessfulLan + liveLan @@ -3869,7 +3877,9 @@ final class SyncService: ObservableObject { saveRemoteCommandDescriptors(commandDescriptors) let matchingDiscovery = discoveredHosts.first { discovered in - discovered.hostIdentity == remoteHostIdentity || discovered.addresses.contains(connectedHost) + discovered.hostIdentity == remoteHostIdentity + || discovered.addresses.contains(connectedHost) + || discovered.tailscaleAddress == connectedHost } // Cap saved candidates to avoid unbounded growth when the user moves // between networks. Put the currently-connected host first, then any @@ -3878,6 +3888,7 @@ final class SyncService: ObservableObject { let savedCandidatesUncapped = deduplicatedAddresses( [connectedHost] + (matchingDiscovery?.addresses ?? []) + + (matchingDiscovery?.tailscaleAddress.map { [$0] } ?? []) + (activeHostProfile?.savedAddressCandidates ?? []) ) let savedCandidates = Array(savedCandidatesUncapped.prefix(6)) @@ -3896,7 +3907,9 @@ final class SyncService: ObservableObject { lastSuccessfulAddress: connectedHost, savedAddressCandidates: savedCandidates, discoveredLanAddresses: discoveredLan, - tailscaleAddress: matchingDiscovery?.tailscaleAddress ?? activeHostProfile?.tailscaleAddress + tailscaleAddress: matchingDiscovery?.tailscaleAddress + ?? (syncIsTailscaleRoute(connectedHost) ? connectedHost : nil) + ?? activeHostProfile?.tailscaleAddress ) saveProfile(profile) startRelayLoop() @@ -4057,7 +4070,11 @@ final class SyncService: ObservableObject { if supportsChatStreaming, let dict = payload as? [String: Any], let snapshot = try? decode(dict, as: SyncChatSubscribeSnapshotPayload.self) { - replaceChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + if snapshot.truncated { + mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } else { + replaceChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } } case "chat_event": if supportsChatStreaming, @@ -4474,7 +4491,10 @@ final class SyncService: ObservableObject { } private func chatSubscriptionPayload(sessionId: String) -> [String: Any] { - ["sessionId": sessionId] + [ + "sessionId": sessionId, + "maxBytes": syncChatSubscriptionMaxBytes, + ] } private func restoreChatEventSubscriptions() { @@ -4487,9 +4507,24 @@ final class SyncService: ObservableObject { func recordChatEventEnvelope(_ envelope: AgentChatEventEnvelope) { var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] guard !events.contains(where: { $0.id == envelope.id }) else { return } - events.append(envelope) - if events.count > 500 { - events.removeFirst(events.count - 500) + // Fast path: arrival-order appends stay sorted when timestamps are + // monotonically non-decreasing — common for live streaming. Out-of-order + // deliveries (e.g., a delayed tool_result arriving after a later text + // fragment, or a merge with a historical snapshot) fall through to the + // full dedup/sort in deduplicatedChatEventHistory so bubble order matches + // the replace/merge paths. + let canAppendInOrder: Bool = { + guard let last = events.last else { return true } + let lastDate = Self.parseIso8601(last.timestamp) + let envelopeDate = Self.parseIso8601(envelope.timestamp) + if let lhs = envelopeDate, let rhs = lastDate { return lhs >= rhs } + return envelope.timestamp >= last.timestamp + }() + if canAppendInOrder { + events.append(envelope) + events = trimChatEventHistory(events) + } else { + events = deduplicatedChatEventHistory(events + [envelope]) } chatEventEnvelopesBySession[envelope.sessionId] = events chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 @@ -4498,15 +4533,53 @@ final class SyncService: ObservableObject { } func replaceChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { + chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(events) + chatEventRevisionsBySession[sessionId, default: 0] += 1 + lastSyncAt = Date() + markChatEventsChanged(immediate: true) + } + + func mergeChatEventHistory(sessionId: String, events: [AgentChatEventEnvelope]) { + let current = chatEventEnvelopesBySession[sessionId] ?? [] + chatEventEnvelopesBySession[sessionId] = deduplicatedChatEventHistory(current + events) + chatEventRevisionsBySession[sessionId, default: 0] += 1 + lastSyncAt = Date() + markChatEventsChanged(immediate: true) + } + + private func deduplicatedChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { var seen = Set() - chatEventEnvelopesBySession[sessionId] = events.filter { event in + let unique = events.filter { event in guard !seen.contains(event.id) else { return false } seen.insert(event.id) return true } - chatEventRevisionsBySession[sessionId, default: 0] += 1 - lastSyncAt = Date() - markChatEventsChanged(immediate: true) + .sorted { lhs, rhs in + // Parse timestamps to Date before comparing — a lexicographic compare + // misorders mixed ISO-8601 variants (e.g., "…56.500Z" sorts before + // "…56Z" because "." < "Z" in ASCII, even though chronologically it's + // half a second later). + let lhsDate = Self.parseIso8601(lhs.timestamp) + let rhsDate = Self.parseIso8601(rhs.timestamp) + if lhsDate == rhsDate { + if lhs.timestamp == rhs.timestamp { + return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) + } + return lhs.timestamp < rhs.timestamp + } + switch (lhsDate, rhsDate) { + case (let l?, let r?): return l < r + case (nil, _?): return true + case (_?, nil): return false + case (nil, nil): return lhs.timestamp < rhs.timestamp + } + } + return trimChatEventHistory(unique) + } + + private func trimChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { + guard events.count > chatEventHistoryMaxEvents else { return events } + return Array(events.suffix(chatEventHistoryMaxEvents)) } private func trimmedTerminalBuffer(_ buffer: String) -> String { diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index 1bebb8af2..5da3e3a11 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -342,13 +342,17 @@ private struct DiscoveredHostRow: View { } private var primaryRoute: String { - host.addresses.first { address in - !isLoopback(address) && !syncIsTailscaleIPv4Address(address) + if let tailscaleAddress = host.tailscaleAddress, + detailPrefix?.localizedCaseInsensitiveContains("tailscale") == true { + return tailscaleAddress + } + return host.addresses.first { address in + !isLoopback(address) && !syncIsTailscaleRoute(address) } ?? host.tailscaleAddress ?? host.addresses.first ?? "No route" } private func inferredRoutePrefix(for route: String) -> String? { - if syncIsTailscaleIPv4Address(route) { + if syncIsTailscaleRoute(route) { return "Tailscale" } return nil diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 8f4bc817e..1a86d7675 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -26,10 +26,17 @@ func errorPresentation(for category: String) -> WorkErrorPresentation { func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMessage] { var messages: [WorkChatMessage] = [] let metadataByTurn = workTurnModelMetadataByTurn(from: transcript) + // Tracks whether the previous envelope was assistantText so nil-itemId + // streaming fragments can merge into it. MUST be reset to false on every + // non-assistantText branch below — otherwise a subsequent nil-itemId + // fragment could wrongly merge across an intervening tool call or user + // message. Any new `WorkChatEvent` case added here must preserve that reset. + var previousEnvelopeWasAssistantText = false for envelope in transcript { switch envelope.event { case .userMessage(let text, let turnId, let steerId, let deliveryState, let processed): + previousEnvelopeWasAssistantText = false // Queued steers render as inline cards above the composer, not in the message stream. if deliveryState == "queued", steerId != nil { continue @@ -63,10 +70,12 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess let metadata = turnId .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .flatMap { metadataByTurn[$0] } + let canMergeWithPreviousAssistant = itemId != nil || previousEnvelopeWasAssistantText if let lastIndex = messages.indices.last, messages[lastIndex].role == "assistant", messages[lastIndex].turnId == turnId, - messages[lastIndex].itemId == itemId { + messages[lastIndex].itemId == itemId, + canMergeWithPreviousAssistant { messages[lastIndex].markdown += text } else { messages.append(WorkChatMessage( @@ -80,7 +89,9 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess turnModelId: metadata?.modelId )) } + previousEnvelopeWasAssistantText = true default: + previousEnvelopeWasAssistantText = false continue } } diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 3211a4a31..816ee1800 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -35,8 +35,13 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { switch event { case .userMessage(let text, _, let turnId, let steerId, let deliveryState, let processed): return .userMessage(text: text, turnId: turnId, steerId: steerId, deliveryState: deliveryState, processed: processed) - case .text(let text, _, let turnId, let itemId): - return .assistantText(text: text, turnId: turnId, itemId: itemId) + case .text(let text, let messageId, let turnId, let itemId): + let normalizedMessageId = messageId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) + let stableItemId = normalizedItemId?.isEmpty == false + ? normalizedItemId + : (normalizedMessageId?.isEmpty == false ? normalizedMessageId : nil) + return .assistantText(text: text, turnId: turnId, itemId: stableItemId) case .toolCall(let tool, let args, let itemId, let logicalItemId, let parentItemId, let turnId): return .toolCall( tool: tool, diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index ff13bfa86..d6e908003 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -126,15 +126,40 @@ func workSessionGroupsByLane( } var groups: [WorkSessionGroup] = [] + let knownLaneIds = Set(orderedLanes.map(\.id)) for lane in orderedLanes { guard let list = byLaneId[lane.id], !list.isEmpty else { continue } groups.append(WorkSessionGroup(id: "lane:\(lane.id)", label: lane.name, icon: .laneBranch, tint: ADEColor.textSecondary, sessions: list)) } - // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) at the end. - let accounted = Set(groups.flatMap { $0.sessions.map(\.id) }) - let orphans = sessions.filter { !accounted.contains($0.id) } - if !orphans.isEmpty { - groups.append(WorkSessionGroup(id: "lane:_orphans", label: "Other", icon: .laneBranch, tint: ADEColor.textMuted, sessions: orphans)) + // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) + // as their own per-lane groups so users still recognize which branch each belongs to. + let iso = ISO8601DateFormatter() + iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let isoFallback = ISO8601DateFormatter() + isoFallback.formatOptions = [.withInternetDateTime] + func latestStartedAt(_ list: [TerminalSessionSummary]) -> Date { + list.reduce(.distantPast) { acc, session in + let parsed = iso.date(from: session.startedAt) ?? isoFallback.date(from: session.startedAt) ?? .distantPast + return parsed > acc ? parsed : acc + } + } + func orphanLabel(_ name: String?, fallback: String) -> String { + let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? fallback : trimmed + } + let orphanEntries = byLaneId + .filter { laneId, list in !knownLaneIds.contains(laneId) && !list.isEmpty } + .sorted { left, right in + let leftLatest = latestStartedAt(left.value) + let rightLatest = latestStartedAt(right.value) + if leftLatest != rightLatest { return leftLatest > rightLatest } + let leftName = orphanLabel(left.value.first?.laneName, fallback: left.key) + let rightName = orphanLabel(right.value.first?.laneName, fallback: right.key) + return leftName.localizedCaseInsensitiveCompare(rightName) == .orderedAscending + } + for (laneId, list) in orphanEntries { + let label = orphanLabel(list.first?.laneName, fallback: laneId) + groups.append(WorkSessionGroup(id: "lane:\(laneId)", label: label, icon: .laneBranch, tint: ADEColor.textMuted, sessions: list)) } return groups } diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index a1aa74d96..2788013a1 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -44,7 +44,11 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { processed: eventDict["processed"] as? Bool ) case "text": - event = .assistantText(text: stringValue(eventDict["text"]), turnId: turnId, itemId: itemId) + event = .assistantText( + text: stringValue(eventDict["text"]), + turnId: turnId, + itemId: optionalString(eventDict["itemId"]) ?? optionalString(eventDict["messageId"]) + ) case "tool_call": event = .toolCall( tool: stringValue(eventDict["tool"]), diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 989934958..1558fc3c8 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -138,6 +138,18 @@ final class ADETests: XCTestCase { XCTAssertFalse(syncIsTailscaleIPv4Address("127.0.0.1")) } + func testSyncRecognizesTailscaleRoutes() { + XCTAssertTrue(syncIsTailscaleRoute("100.117.237.95")) + XCTAssertTrue(syncIsTailscaleRoute("ws://100.117.237.95:8787")) + XCTAssertTrue(syncIsTailscaleRoute("HTTPS://ADE-SYNC:8787/sync?source=settings")) + XCTAssertTrue(syncIsTailscaleRoute("ade-sync")) + XCTAssertTrue(syncIsTailscaleRoute("macbook.tailnet.ts.net")) + XCTAssertEqual(syncNormalizedRouteHost("ws://MACBOOK.tailnet.ts.net:8787/sync"), "macbook.tailnet.ts.net") + XCTAssertFalse(syncIsTailscaleRoute("192.168.68.102")) + XCTAssertFalse(syncIsTailscaleRoute("mac.local")) + XCTAssertFalse(syncIsTailscaleRoute("not-ts.net.example.com")) + } + func testSyncBonjourTimingMatchesReliabilityRequirements() { XCTAssertEqual(SyncBonjourTiming.searchRetryNanoseconds, 2_000_000_000) XCTAssertEqual(SyncBonjourTiming.resolveRetryNanoseconds, 2_000_000_000) @@ -324,6 +336,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]) service.disconnect(clearCredentials: false) @@ -367,6 +380,115 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventRevision(for: "session-1"), 1) } + @MainActor + func testTruncatedChatSubscribeSnapshotMergesWithExistingHistory() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let original = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .userMessage(text: "Start here", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let tail = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .text(text: "Still working", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.recordChatEventEnvelope(original) + service.mergeChatEventHistory(sessionId: "session-1", events: [original, tail]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [original, tail]) + } + + @MainActor + func testCompleteChatSubscribeSnapshotReplacesExistingHistory() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let old = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:00.000Z", + event: .userMessage(text: "Old event", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let fresh = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .text(text: "Fresh snapshot", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.recordChatEventEnvelope(old) + service.replaceChatEventHistory(sessionId: "session-1", events: [fresh]) + + XCTAssertEqual(service.chatEventHistory(sessionId: "session-1"), [fresh]) + } + + @MainActor + func testChatEventHistoryOrdersByParsedTimestampAcrossMixedFractionalVariants() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + // Lexicographic compare misorders these: "…56Z" > "…56.500Z" because + // "Z" (0x5A) > "." (0x2E) in ASCII. Chronologically "…56Z" comes first. + let noFractional = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:56Z", + event: .userMessage(text: "first", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let withFractional = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:56.500Z", + event: .text(text: "second", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.replaceChatEventHistory(sessionId: "session-1", events: [withFractional, noFractional]) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.map(\.id), [noFractional.id, withFractional.id]) + } + + @MainActor + func testRecordChatEventEnvelopeSortsWhenLiveEventArrivesOutOfOrder() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + let earlier = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.000Z", + event: .userMessage(text: "first", attachments: [], turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil), + sequence: 1, + provenance: nil + ) + let later = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:02.000Z", + event: .text(text: "second", messageId: "msg-1", turnId: "turn-1", itemId: "item-1"), + sequence: 2, + provenance: nil + ) + + service.mergeChatEventHistory(sessionId: "session-1", events: [earlier, later]) + // Live envelope arrives out of order (delayed tool_result that predates the + // already-merged later envelope). Must be inserted in chronological order + // rather than appended to the end. + let delayedInsert = AgentChatEventEnvelope( + sessionId: "session-1", + timestamp: "2026-03-17T00:00:01.500Z", + event: .toolResult(tool: "fs_read", result: .string("ok"), itemId: "tool-1", logicalItemId: "tool-1", parentItemId: nil, turnId: "turn-1", status: "completed"), + sequence: 3, + provenance: nil + ) + service.recordChatEventEnvelope(delayedInsert) + + let history = service.chatEventHistory(sessionId: "session-1") + XCTAssertEqual(history.map(\.id), [earlier.id, delayedInsert.id, later.id]) + } + func testChatCommandRequestPayloadsEncodeExpectedShapes() throws { let subscribe = try jsonDictionary(from: AgentChatSubscriptionRequest(sessionId: "session-1")) XCTAssertEqual(subscribe["sessionId"] as? String, "session-1") @@ -3180,6 +3302,153 @@ final class ADETests: XCTestCase { XCTAssertEqual(activeAgents.first?.toolName, "functions.Read") } + func testWorkChatTranscriptUsesMessageIdToSplitAssistantMessages() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:00.000Z","sequence":1,"event":{"type":"user_message","text":"Rebase Windows Port","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:01.000Z","sequence":2,"event":{"type":"text","text":"I will check the branch.","messageId":"msg-progress","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:02.000Z","sequence":3,"event":{"type":"tool_call","tool":"Bash","args":{"command":"git status"},"itemId":"tool-1","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.000Z","sequence":4,"event":{"type":"text","text":"Merge complete.","messageId":"msg-final","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.500Z","sequence":5,"event":{"type":"text","text":" I did not push.","messageId":"msg-final","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:03.500Z","sequence":6,"event":{"type":"tool_result","tool":"Bash","result":{"synthetic":true,"source":"claude_turn_finalization"},"itemId":"tool-1","turnId":"turn-1","status":"completed"}} + """ + + let transcript = parseWorkChatTranscript(raw) + let messages = buildWorkChatMessages(from: transcript) + let assistantMessages = messages.filter { $0.role == "assistant" } + + XCTAssertEqual(assistantMessages.count, 2) + XCTAssertEqual(assistantMessages.map(\.itemId), ["msg-progress", "msg-final"]) + XCTAssertEqual(assistantMessages.map(\.markdown), [ + "I will check the branch.", + "Merge complete. I did not push.", + ]) + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let visibleKinds = snapshot.timeline.compactMap { entry -> String? in + switch entry.payload { + case .message(let message) where message.role == "user": + return "user" + case .message(let message) where message.role == "assistant": + return "assistant:\(message.itemId ?? "")" + case .toolCard(let card): + return "tool:\(card.id)" + default: + return nil + } + } + + XCTAssertEqual(visibleKinds, [ + "user", + "assistant:msg-progress", + "tool:tool-1", + "assistant:msg-final", + ]) + } + + func testWorkChatMessagesDoNotMergeUnidentifiedAssistantTextAcrossTools() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:01.000Z", + sequence: 1, + event: .assistantText(text: "Before tools.", turnId: "turn-1", itemId: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:02.000Z", + sequence: 2, + event: .toolCall(tool: "Bash", argsText: "{}", itemId: "tool-1", parentItemId: nil, turnId: "turn-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:03.000Z", + sequence: 3, + event: .assistantText(text: "After tools.", turnId: "turn-1", itemId: nil) + ), + ] + + let assistantMessages = buildWorkChatMessages(from: transcript) + .filter { $0.role == "assistant" } + + XCTAssertEqual(assistantMessages.map(\.markdown), [ + "Before tools.", + "After tools.", + ]) + } + + func testWorkSessionGroupsByLaneSurfacesOrphanLanesPerLaneId() { + let knownLane = LaneSummary( + id: "lane-primary", + name: "Primary", + description: nil, + laneType: "primary", + baseRef: "main", + branchRef: "main", + worktreePath: "/tmp/project", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: true, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: nil, + icon: nil, + tags: [], + folder: nil, + createdAt: "2026-03-17T00:00:00.000Z", + archivedAt: nil + ) + let primarySession = makeTerminalSessionSummary( + id: "session-primary", + laneId: "lane-primary", + laneName: "Primary", + toolType: "codex-chat", + startedAt: "2026-03-25T12:00:00.000Z" + ) + // Two distinct soft-deleted lanes — each should render as its own group. + // `lane-deleted-a` wins the orphan sort because its latest session started + // more recently than the latest on `lane-deleted-b`. + let orphanOldSession = makeTerminalSessionSummary( + id: "session-orphan-old", + laneId: "lane-deleted-a", + laneName: "feature/cleanup", + toolType: "codex-chat", + startedAt: "2026-03-25T11:30:00.000Z" + ) + let orphanNewSession = makeTerminalSessionSummary( + id: "session-orphan-new", + laneId: "lane-deleted-b", + laneName: "feature/recent", + toolType: "codex-chat", + startedAt: "2026-03-25T10:45:00.000Z" + ) + // Same orphan lane appearing twice — must merge into the same group. + // This sibling is older than `orphanOldSession` so the ordering assertion + // below exercises the latest-startedAt-per-lane comparison. + let orphanNewSessionSibling = makeTerminalSessionSummary( + id: "session-orphan-new-sibling", + laneId: "lane-deleted-b", + laneName: "feature/recent", + toolType: "codex-chat", + startedAt: "2026-03-25T10:30:00.000Z" + ) + + let groups = workSessionGroupsByLane( + sessions: [primarySession, orphanOldSession, orphanNewSession, orphanNewSessionSibling], + orderedLanes: [knownLane] + ) + + XCTAssertEqual(groups.map(\.id), ["lane:lane-primary", "lane:lane-deleted-a", "lane:lane-deleted-b"]) + XCTAssertEqual(groups.map(\.label), ["Primary", "feature/cleanup", "feature/recent"]) + XCTAssertEqual(groups.last?.sessions.count, 2) + } + func testWorkChatTranscriptPreservesReasoningIdentity() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-04-22T21:11:58.154Z","sequence":6,"event":{"type":"reasoning","text":"The user wants","turnId":"turn-1","itemId":"claude-thinking:turn-1:0","summaryIndex":0}} @@ -5536,7 +5805,8 @@ final class ADETests: XCTestCase { runtimeState: String = "running", status: String = "running", title: String = "Codex chat", - lastOutputPreview: String? = nil + lastOutputPreview: String? = nil, + startedAt: String = "2026-03-25T00:00:00.000Z" ) -> TerminalSessionSummary { TerminalSessionSummary( id: id, @@ -5550,7 +5820,7 @@ final class ADETests: XCTestCase { toolType: toolType, title: title, status: status, - startedAt: "2026-03-25T00:00:00.000Z", + startedAt: startedAt, endedAt: nil, exitCode: nil, transcriptPath: "", diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 53977ffdb..505325e2f 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -89,6 +89,20 @@ input, and has exceeded its provider-specific inactivity window: free the underlying server sooner). Teardown routes through `teardownRuntime(managed, "idle_ttl")`. +`teardownRuntime` distinguishes **terminal** close reasons +(`handle_close`, `ended_session`, `model_switch`) from **non-terminal** +ones (`idle_ttl`, `budget_eviction`, `pool_compaction`, `paused_run`, +`project_close`, `shutdown`). For Claude runtimes only, a non-terminal +teardown preserves resume state: the service pins +`runtime.sdkSessionId` to the last known V2 session id before releasing +the session, persists chat state immediately, and skips the usual +`runtimeInvalidated = true` + `clearLaneDirectiveKey` cleanup. The next +turn on that chat can therefore rehydrate the same Claude V2 session +instead of creating a fresh one, even though the SDK process was +released to reclaim budget or compact the pool. Terminal closes still +run the full invalidation path so "End chat" and explicit model +switches don't leave stale resume pointers behind. + On app shutdown the service exposes `forceDisposeAll()` — called from `runImmediateProcessCleanup()` in `main.ts`. It stops the cleanup timer, rejects every outstanding `sessionTurnCollector` with a "closed during diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index bc1b2de30..1b1ca18db 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -603,6 +603,31 @@ reflected in the phone's UI on the next descriptor read. runs its polling path on reconnect / catchup to fill any gap; the phone de-duplicates per-event keys so a push and a catchup poll covering the same event produce one rendered message. +- **Chat subscribe requests a 2 MB snapshot window.** The phone sends + `chat_subscribe` with `maxBytes: 2_000_000` + (`syncChatSubscriptionMaxBytes`) so the initial snapshot can carry + long transcripts without the host truncating prematurely. When the + host still responds with `truncated: true`, the phone calls + `mergeChatEventHistory` instead of `replaceChatEventHistory`: the + existing cached events are unioned with the truncated snapshot, + deduplicated by `id`, and re-sorted by `(timestamp, sequence)`. + Non-truncated snapshots take the replace path. Both paths run through + `deduplicatedChatEventHistory` and then through `trimChatEventHistory`, + which caps retained events at `chatEventHistoryMaxEvents = 1_000` + (up from the previous 500-event cap) so very long chats don't evict + their own recent turns on reconnect. +- **Work transcript parser uses `messageId` as a fallback item id.** + `makeWorkChatEvent` (`WorkEventMapping.swift`) and + `parseWorkChatTranscript` (`WorkTranscriptParser.swift`) now fall back + to the `messageId` from `chat_event` when no `itemId` is present, so + streaming assistant-text fragments merge into the same transcript row + even when the host only surfaces a `messageId`. `buildWorkChatMessages` + (`WorkErrorAndMessageHelpers.swift`) tracks a + `previousEnvelopeWasAssistantText` flag and allows merging into the + previous assistant bubble when either (a) the text event has an + `itemId` or (b) the immediately preceding envelope was also assistant + text. This keeps the iOS Work chat from fanning a single assistant + turn into many tiny rows. - **Lane presence is best-effort with a TTL.** The phone re-announces on a 30 s cadence; the host prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 64402698c..646f88b95 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -34,6 +34,18 @@ Lists sessions grouped by one of three modes (controlled by Each group uses a `StickyGroupHeader` with collapsed-state persistence via `workCollapsedLaneIds` / `workCollapsedSectionIds`. +In `by-lane` mode, any session whose `laneId` is not in the current +lanes list is still rendered under its own sticky "orphan lane" group +below the active lane groups. The list is built from +`missingLaneSessionGroups`: every `laneId` from `sessionsGroupedByLane` +that's absent from the `lanes` set becomes a group, labelled with the +session's `laneName` (falling back to the raw `laneId`) and sorted by +most-recent `startedAt`, with ties broken alphabetically. These groups +reuse the same `workCollapsedLaneIds` persistence, so a user who +collapses an orphan group sees it stay collapsed on reload. This keeps +sessions reachable when their lane has been archived, deleted, or not +yet loaded, instead of quietly dropping them from the sidebar. + Also renders: - draft-kind switcher (chat vs terminal) at the top