From bbc88df5b116b80421dcad1001f9c8f291ae2290 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:11:30 -0400 Subject: [PATCH 1/8] Mobile hub/chat overhaul + cross-project chat quick-look + Lanes tab perf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS hub: inline keyboard composer replaces the sheet drawer (draft/settings survive collapse; scrim + swipe/tap-outside dismiss), project-card badges and lane-row noise removed, hub chat-open now waits for real project hydration (no more blank transcripts / "Session unavailable"). Chat: file-change approvals get an actionable badge above the composer, question/approval/plan cards redesigned (provider-logo headers, option rows, compact resolved chips, plan preview + full-plan sheet), status-based composer locks removed, chat-event snapshot decode hardened (@ADELossyArray + notice kinds) so one unknown event can't wipe a transcript, plan event mapping fixed. Cross-project chat quick-look: chat_subscribe/history accept a project override (capability-gated, hello feature flag); the brain streams a foreign lane's transcript read-only without booting its runtime; phone opens hub chats without switching projects, falling back to activation on older brains. Codex plan approval now hands the session straight to full access. Lanes tab: rebase tag off list cards, collapsible Files/Stashes/History, wrapping header/chips, commit row split, manage-sheet desktop parity (+adopt-attached); perf — no-op lane_state_snapshots writes suppressed (conditional CRR upsert), signature/ifNoneMatch conditional lane list/detail responses, per-lane detail invalidation, per-row render signatures. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 2 + apps/ade-cli/src/cli.ts | 7 +- .../src/services/sync/rosterBuilder.test.ts | 22 + .../src/services/sync/rosterBuilder.ts | 48 ++ .../src/services/sync/syncHostService.test.ts | 2 + .../src/services/sync/syncHostService.ts | 160 ++++++- .../sync/syncRemoteCommandService.test.ts | 55 +++ .../services/sync/syncRemoteCommandService.ts | 51 +- apps/ade-cli/src/services/sync/syncService.ts | 3 + .../services/chat/agentChatService.test.ts | 4 +- .../main/services/chat/agentChatService.ts | 7 +- .../main/services/lanes/laneService.test.ts | 66 +++ .../src/main/services/lanes/laneService.ts | 10 +- apps/desktop/src/shared/types/lanes.ts | 2 + apps/desktop/src/shared/types/sync.ts | 23 + apps/ios/ADE/Models/RemoteModels.swift | 71 ++- apps/ios/ADE/Services/Database.swift | 24 +- apps/ios/ADE/Services/SyncService.swift | 439 ++++++++++++++++-- apps/ios/ADE/Views/Hub/HubComponents.swift | 127 +---- .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 328 +++++++------ .../Views/Hub/HubScreen+ChatNavigation.swift | 142 +++++- apps/ios/ADE/Views/Hub/HubScreen.swift | 36 +- apps/ios/ADE/Views/Lanes/LaneComponents.swift | 172 +++---- .../Lanes/LaneDetailGitActionsPane.swift | 278 ++++++----- .../ADE/Views/Lanes/LaneDetailScreen.swift | 8 +- .../Views/Lanes/LaneDetailSectionChrome.swift | 80 ++++ apps/ios/ADE/Views/Lanes/LaneHelpers.swift | 10 - .../ios/ADE/Views/Lanes/LaneManageSheet.swift | 38 ++ .../Work/WorkChatComposerAndInputViews.swift | 70 --- .../Views/Work/WorkChatRichCardViews.swift | 427 +++++++++++++++++ .../Work/WorkChatSessionView+Timeline.swift | 6 + .../ADE/Views/Work/WorkChatSessionView.swift | 89 ++-- .../ios/ADE/Views/Work/WorkEventMapping.swift | 6 +- apps/ios/ADE/Views/Work/WorkModels.swift | 21 +- .../Views/Work/WorkPlanComposerViews.swift | 251 ++++++++++ .../Work/WorkSessionDestinationView.swift | 64 ++- .../Work/WorkStatusAndFormattingHelpers.swift | 28 +- .../ADE/Views/Work/WorkTimelineHelpers.swift | 160 ++++--- apps/ios/ADETests/ADETests.swift | 388 +++++++++++----- docs/ARCHITECTURE.md | 6 +- docs/features/chat/agent-routing.md | 6 +- docs/features/lanes/README.md | 2 +- docs/features/sync-and-multi-device/README.md | 22 +- .../sync-and-multi-device/ios-companion.md | 61 ++- .../sync-and-multi-device/remote-commands.md | 13 + 45 files changed, 2875 insertions(+), 960 deletions(-) diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index c4623dc8a..13da60913 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -158,6 +158,7 @@ export type AdeRuntimeSyncOptions = { phonePairingStateDir?: string; projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; rosterProvider?: Parameters[0]["rosterProvider"]; + foreignChatProvider?: Parameters[0]["foreignChatProvider"]; remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; /** * Brain-level websocket listener shared by every project scope's sync host @@ -1239,6 +1240,7 @@ export async function createAdeRuntime(args: { forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? false, projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, rosterProvider: resolvedArgs.syncRuntime.rosterProvider, + foreignChatProvider: resolvedArgs.syncRuntime.foreignChatProvider, remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor, getModelPickerStore: () => getSharedModelPickerStore(db), onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }), diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 2f8e57f68..88b7fdb59 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -13205,7 +13205,7 @@ async function runServe( { createSharedSyncListener }, { resolveMobileProjectIconDataUrl }, { createBrainProjectActionsSyncHandler }, - { buildRosterSnapshot }, + { buildRosterSnapshot, createForeignChatTranscriptResolver }, ] = await Promise.all([ import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), @@ -13585,6 +13585,11 @@ async function runServe( logger: headlessProjectLogger, }), }, + // Cross-project chat "quick look": lets the phone stream a foreign + // project's chat transcript read-only without a project switch. Reads + // straight off that project's `.ade` transcripts dir (registry-validated, + // no runtime boot) — the counterpart to the roster feed above. + foreignChatProvider: createForeignChatTranscriptResolver({ projectRegistry }), }, }); const previousRole = process.env.ADE_DEFAULT_ROLE; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts index 076248638..b2ff62d27 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -6,6 +6,7 @@ import { createRequire } from "node:module"; import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; import { buildRosterSnapshot, + createForeignChatTranscriptResolver, type RosterBootedScope, type RosterLiveSession, type RosterScopeRegistry, @@ -188,6 +189,27 @@ describe("buildRosterSnapshot", () => { expect(project.runningCount).toBe(1); }); + it("resolves a registered foreign chat transcript path and rejects unsafe input", () => { + const resolver = createForeignChatTranscriptResolver({ projectRegistry }); + const expectedDir = path.join(projectRoot, ".ade", "transcripts", "chat"); + + // Registered project + safe session id → path inside the transcripts dir. + expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "chat-run" })) + .toBe(path.join(expectedDir, "chat-run.jsonl")); + // Resolvable by rootPath too. + expect(resolver.resolveTranscriptPath({ projectRootPath: projectRoot, sessionId: "chat-run" })) + .toBe(path.join(expectedDir, "chat-run.jsonl")); + + // Unknown project → null (not registered). + expect(resolver.resolveTranscriptPath({ projectId: "project_unknown", sessionId: "chat-run" })).toBeNull(); + // No project reference → null. + expect(resolver.resolveTranscriptPath({ sessionId: "chat-run" })).toBeNull(); + // Path-traversal / unsafe session ids → null (never touches the filesystem). + expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "../../etc/passwd" })).toBeNull(); + expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "a/b" })).toBeNull(); + expect(resolver.resolveTranscriptPath({ projectId: PROJECT_ID, sessionId: "" })).toBeNull(); + }); + it("tolerates a project with no ADE database (empty lanes/chats)", async () => { const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-roster-empty-")); try { diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index ca67e7eb6..116626b9b 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -10,6 +10,7 @@ import type { SyncRosterProject, } from "../../../../desktop/src/shared/types"; import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; +import type { SyncForeignChatTranscriptResolver } from "./syncHostService"; // Anchor builtin resolution to the active runtime, mirroring kvDb / the cheap // cross-project read in recentProjectSummary.ts. The roster opens each @@ -395,6 +396,53 @@ async function buildRosterProject( }; } +// --- Cross-project "quick look" transcript resolver -------------------------- + +export type ForeignChatProjectRegistry = { + list(): { projectId: string; rootPath: string }[]; +}; + +// A chat session id is a filename segment: reject anything that could escape +// the transcripts dir (path separators, `..`, nul) before it ever touches the +// filesystem. Real ids are UUID/nanoid-shaped. +const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,128}$/; + +/** + * Builds a resolver that maps (registered foreign project, sessionId) to that + * session's on-disk chat transcript JSONL path — the security boundary for + * cross-project chat streaming. It ONLY resolves projects present in the + * registry and ONLY returns paths confined to that project's `.ade` + * transcripts dir; everything else yields null. Never boots a runtime. + */ +export function createForeignChatTranscriptResolver(args: { + projectRegistry: ForeignChatProjectRegistry; +}): SyncForeignChatTranscriptResolver { + return { + resolveTranscriptPath: ({ projectId, projectRootPath, sessionId }) => { + const safeSessionId = typeof sessionId === "string" ? sessionId.trim() : ""; + if (!SAFE_SESSION_ID.test(safeSessionId)) return null; + + const requestedProjectId = typeof projectId === "string" ? projectId.trim() : ""; + const requestedRootPath = normalizePath(projectRootPath); + const records = args.projectRegistry.list(); + const record = records.find((entry) => { + if (requestedProjectId && entry.projectId === requestedProjectId) return true; + if (requestedRootPath && normalizePath(entry.rootPath) === requestedRootPath) return true; + return false; + }); + if (!record) return null; + + const transcriptsDir = path.resolve(resolveAdeLayout(record.rootPath).chatTranscriptsDir); + const filePath = path.resolve(path.join(transcriptsDir, `${safeSessionId}.jsonl`)); + // Defense in depth: a validated session id already can't traverse, but + // confirm the resolved path stays inside the transcripts dir. + if (filePath !== path.join(transcriptsDir, `${safeSessionId}.jsonl`)) return null; + if (!filePath.startsWith(transcriptsDir + path.sep)) return null; + return filePath; + }, + }; +} + /** * Build the all-projects chat roster: every registered project's lanes + chat * sessions, sourced cheaply from disk, with live status overlaid for any diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index a29585809..79bfc193a 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -199,6 +199,7 @@ describe("buildSyncHostHelloOkPayload", () => { projectCatalog: { projects: [project] }, projectCatalogEnabled: true, projectActionsEnabled: false, + crossProjectChatEnabled: true, remoteCommandSupportedActions: [remoteCommand.action], remoteCommandDescriptors: [remoteCommand], localCommandDescriptors: [localPresenceCommand], @@ -211,6 +212,7 @@ describe("buildSyncHostHelloOkPayload", () => { expect(payload.projects).toEqual([project]); expect(payload.features.projectCatalog).toEqual({ enabled: true }); expect(payload.features.projectActions).toEqual({ enabled: false }); + expect(payload.features.crossProjectChat).toEqual({ enabled: true }); expect(payload.features.fileAccess).toBe(true); expect(payload.features.terminalStreaming).toBe(true); expect(payload.features.chatStreaming).toEqual({ enabled: true }); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index a48d9770f..7984de79b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -283,6 +283,12 @@ type PeerState = { subscribedChatSessionIds: Set; chatTranscriptOffsets: Map; chatEventIdsSent: Map>; + // Cross-project "quick look" subscriptions: sessionId -> resolved foreign + // transcript path. Present only for sessions in a project OTHER than this + // socket's active one; the chat pump tails these paths directly (the local + // sessionService has no row for them). Empty in the common single-project + // case, so it adds no cost when the feature is unused. + foreignChatTranscriptPaths: Map; pendingChangesetBatch: PendingChangesetBatch | null; // All-projects roster (mobile hub): whether this peer is subscribed, the // monotonic seq last sent to THIS peer (per-peer so a peer that skips a @@ -449,6 +455,27 @@ export type SyncRosterProvider = { buildSnapshot: () => Promise; }; +/** + * Resolves the on-disk chat transcript path for a session in a REGISTERED + * FOREIGN project so the host can stream a cross-project "quick look" without + * switching the socket's active project or booting that project's runtime. + * Lives where the project registry is in scope (ade-cli brain / multi-project + * server). Optional: a host without a foreign-chat provider (e.g. single + * project desktop) simply never advertises `crossProjectChat`, so the phone + * falls back to a full project activation. + * + * The resolver is the SECURITY BOUNDARY: it must validate that the requested + * project is registered and only return paths inside that project's `.ade` + * transcripts dir, returning null for anything unknown or unsafe. + */ +export type SyncForeignChatTranscriptResolver = { + resolveTranscriptPath: (args: { + projectId?: string | null; + projectRootPath?: string | null; + sessionId: string; + }) => string | null; +}; + type SyncHostServiceArgs = { db: AdeDb; logger: Logger; @@ -513,6 +540,7 @@ type SyncHostServiceArgs = { deviceRegistryService?: DeviceRegistryService; projectCatalogProvider?: SyncProjectCatalogProvider; rosterProvider?: SyncRosterProvider; + foreignChatProvider?: SyncForeignChatTranscriptResolver; onStateChanged?: () => void; remoteCommandService?: SyncRemoteCommandService; remoteCommandExecutor?: Pick; @@ -743,6 +771,7 @@ export function buildSyncHostHelloOkPayload(args: { projectCatalog: SyncProjectCatalogPayload; projectCatalogEnabled: boolean; projectActionsEnabled: boolean; + crossProjectChatEnabled: boolean; remoteCommandSupportedActions: string[]; remoteCommandDescriptors: SyncRemoteCommandDescriptor[]; localCommandDescriptors: SyncRemoteCommandDescriptor[]; @@ -767,6 +796,9 @@ export function buildSyncHostHelloOkPayload(args: { chatStreaming: { enabled: true, }, + crossProjectChat: { + enabled: args.crossProjectChatEnabled, + }, projectCatalog: { enabled: args.projectCatalogEnabled, }, @@ -1724,6 +1756,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { subscribedChatSessionIds: new Set(), chatTranscriptOffsets: new Map(), chatEventIdsSent: new Map(), + foreignChatTranscriptPaths: new Map(), pendingChangesetBatch: null, rosterSubscribed: false, rosterSeq: 0, @@ -3084,6 +3117,71 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } } + // Reads a byte-capped TAIL snapshot of a chat transcript straight off disk — + // the cross-project analogue of agentChatService.getChatEventHistory, used + // when the session lives in a foreign project this host has no runtime for. + // Reuses the same tail-truncation semantics as a local snapshot (a leading + // partial line at the cut point is dropped by the JSONL parser). + async function readForeignChatSnapshot( + transcriptPath: string, + maxBytes: number, + ): Promise<{ events: AgentChatEventEnvelope[]; transcriptSize: number; truncated: boolean }> { + let fh: fs.promises.FileHandle | null = null; + try { + fh = await fs.promises.open(transcriptPath, "r"); + const stat = await fh.stat(); + const size = stat.size; + const start = Math.max(0, size - Math.max(1_024, maxBytes)); + if (size <= start) { + return { events: [], transcriptSize: size, truncated: false }; + } + const out = Buffer.alloc(size - start); + await fh.read(out, 0, out.length, start); + // Drop a leading partial line when starting mid-file so the parser never + // sees a truncated JSON object as the first record. + 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"); + return { + events: parseAgentChatTranscript(raw), + transcriptSize: size, + truncated: start > 0, + }; + } catch { + return { events: [], transcriptSize: 0, truncated: false }; + } finally { + await fh?.close().catch(() => {}); + } + } + + // 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). Returns null when the + // payload targets this host's own project (caller uses the local path) or + // when the provider rejects/omits it. + function resolveForeignChatTranscriptPath( + payload: { projectId?: string | null; projectRootPath?: string | null } | null, + sessionId: string, + ): string | null { + if (!args.foreignChatProvider) return null; + const requestedProjectId = toOptionalString(payload?.projectId); + const requestedRootPath = toOptionalString(payload?.projectRootPath); + if (!requestedProjectId && !requestedRootPath) return null; + // A payload that names THIS host's project is an ordinary subscribe — let + // the local sessionService path serve it. + if (requestedProjectId && projectIdMatchesHost(requestedProjectId, args.projectId, hostProjectIdAliases)) { + return null; + } + return args.foreignChatProvider.resolveTranscriptPath({ + projectId: requestedProjectId, + projectRootPath: requestedRootPath, + sessionId, + }); + } + // Per-session replay buffers for resumable chat event streams. Map insertion // order doubles as the LRU order — recordChatEventSeq re-inserts on touch. const chatEventReplayBuffers = new Map(); @@ -3132,11 +3230,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; if (isPeerBackpressured(peer)) continue; for (const sessionId of peer.subscribedChatSessionIds) { - const session = args.sessionService.get(sessionId); - if (!session?.transcriptPath) continue; + // A foreign quick-look session has no local row; tail its resolved + // transcript path directly. Local sessions resolve via sessionService. + const foreignTranscriptPath = peer.foreignChatTranscriptPaths.get(sessionId); + const transcriptPath = foreignTranscriptPath ?? args.sessionService.get(sessionId)?.transcriptPath; + if (!transcriptPath) continue; const startOffset = peer.chatTranscriptOffsets.get(sessionId) ?? 0; - const { events, nextOffset } = await readChatTranscriptEventsSince(session.transcriptPath, startOffset); + const { events, nextOffset } = await readChatTranscriptEventsSince(transcriptPath, startOffset); if (nextOffset !== startOffset) { peer.chatTranscriptOffsets.set(sessionId, nextOffset); } @@ -3939,6 +4040,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { projectCatalog, projectCatalogEnabled: Boolean(args.projectCatalogProvider), projectActionsEnabled, + crossProjectChatEnabled: Boolean(args.foreignChatProvider), remoteCommandSupportedActions: remoteCommandService.getSupportedActions(), remoteCommandDescriptors: remoteCommandService.getDescriptors(), localCommandDescriptors: localPresenceCommandDescriptors, @@ -4290,7 +4392,20 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!sessionId) break; peer.subscribedChatSessionIds.add(sessionId); - const session = args.sessionService.get(sessionId); + // Cross-project "quick look": a payload targeting a registered FOREIGN + // project is served read-only from that project's `.ade` transcript + // JSONL — no local session row, no runtime boot. The pump tails the + // same path for live events. The provider is the security boundary + // (validates the project, sandboxes the path). + const foreignTranscriptPath = resolveForeignChatTranscriptPath(payload, sessionId); + if (foreignTranscriptPath) { + peer.foreignChatTranscriptPaths.set(sessionId, foreignTranscriptPath); + } else { + peer.foreignChatTranscriptPaths.delete(sessionId); + } + + const session = foreignTranscriptPath ? null : args.sessionService.get(sessionId); + const transcriptPath = foreignTranscriptPath ?? session?.transcriptPath ?? null; // Snapshots are byte-capped transcript tails — a long-running turn's // `status: started` event can sit outside the tail, leaving a client // that subscribes mid-turn unable to tell the session is streaming. @@ -4299,7 +4414,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // immediately before each send (getSessionSummary is microtask-only): // computing it earlier leaves an I/O window (readTranscriptTail) where // a terminal chat_event could overtake a stale `turnActive: true`. + // Foreign quick-looks have no live agent chat service here, so they + // derive turn state from the streamed status events instead. const resolveLiveStatusFields = async (): Promise<{ turnActive?: boolean }> => { + if (foreignTranscriptPath) return {}; const liveSummary = await args.agentChatService?.getSessionSummary(sessionId).catch(() => null); return liveSummary ? { turnActive: liveSummary.status === "active" } : {}; }; @@ -4309,8 +4427,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // snapshot, fast-forward the transcript pump past content the // replay already carries, and re-send just the missed events as // ordinary chat_event envelopes (in order, after the ack). - const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) - ? fs.statSync(session.transcriptPath).size + const transcriptSize = transcriptPath && fs.existsSync(transcriptPath) + ? fs.statSync(transcriptPath).size : 0; peer.chatTranscriptOffsets.set(sessionId, transcriptSize); const resumeAck: SyncChatSubscribeSnapshotPayload = { @@ -4339,19 +4457,30 @@ export function createSyncHostService(args: SyncHostServiceArgs) { 1_024, Math.min(2_000_000, Math.floor(typeof payload?.maxBytes === "number" ? payload.maxBytes : DEFAULT_TERMINAL_SNAPSHOT_BYTES)), ); - const history: AgentChatEventHistorySnapshot | null = args.agentChatService?.getChatEventHistory(sessionId, { - maxEvents: CHAT_EVENT_REPLAY_MAX_EVENTS, - maxBytes, - }) ?? null; - const events = history?.events ?? []; - const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) - ? fs.statSync(session.transcriptPath).size - : 0; + let events: AgentChatEventEnvelope[]; + let truncated: boolean; + let transcriptSize: number; + if (foreignTranscriptPath) { + const foreignSnapshot = await readForeignChatSnapshot(foreignTranscriptPath, maxBytes); + events = foreignSnapshot.events; + truncated = foreignSnapshot.truncated; + transcriptSize = foreignSnapshot.transcriptSize; + } else { + const history: AgentChatEventHistorySnapshot | null = args.agentChatService?.getChatEventHistory(sessionId, { + maxEvents: CHAT_EVENT_REPLAY_MAX_EVENTS, + maxBytes, + }) ?? null; + events = history?.events ?? []; + transcriptSize = transcriptPath && fs.existsSync(transcriptPath) + ? fs.statSync(transcriptPath).size + : 0; + truncated = history?.truncated ?? (transcriptSize > maxBytes); + } peer.chatTranscriptOffsets.set(sessionId, transcriptSize); const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), - truncated: history?.truncated ?? (transcriptSize > maxBytes), + truncated, events, ...(await resolveLiveStatusFields()), }; @@ -4368,6 +4497,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { peer.subscribedChatSessionIds.delete(sessionId); peer.chatTranscriptOffsets.delete(sessionId); peer.chatEventIdsSent.delete(sessionId); + peer.foreignChatTranscriptPaths.delete(sessionId); } break; } diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 595658616..0c17519a2 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -375,3 +375,58 @@ describe("lanes.suggestName", () => { ).rejects.toThrow("lanes.suggestName requires prompt."); }); }); + +describe("lanes.refreshSnapshots conditional responses", () => { + function createLaneListService() { + const lanes = [{ id: "lane-1", name: "Lane one", status: { dirty: false, ahead: 0, behind: 0 } }]; + const laneService = { + refreshSnapshots: vi.fn().mockResolvedValue({ refreshedCount: 1, lanes }), + listStateSnapshots: vi.fn().mockReturnValue([]), + }; + const sessionService = { list: vi.fn().mockReturnValue([]) }; + const logger = { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }; + const service = createSyncRemoteCommandService({ + laneService, + prService: {}, + ptyService: {}, + sessionService, + fileService: {}, + logger, + } as any); + return { service, laneService }; + } + + it("returns the full payload with a signature, then notModified for a matching ifNoneMatch", async () => { + const { service } = createLaneListService(); + + const first = (await service.execute(makePayload("lanes.refreshSnapshots"))) as { + lanes: unknown[]; + snapshots: unknown[] | null; + signature: string; + notModified: boolean; + }; + expect(first.notModified).toBe(false); + expect(first.signature).toMatch(/^[0-9a-f]{64}$/); + expect(first.lanes).toHaveLength(1); + expect(first.snapshots).toHaveLength(1); + + const second = (await service.execute( + makePayload("lanes.refreshSnapshots", { ifNoneMatch: first.signature }), + )) as { lanes: unknown[]; snapshots: unknown[] | null; signature: string; notModified: boolean }; + expect(second.notModified).toBe(true); + expect(second.signature).toBe(first.signature); + expect(second.lanes).toEqual([]); + expect(second.snapshots).toBeNull(); + }); + + it("returns the full payload again when ifNoneMatch is stale", async () => { + const { service } = createLaneListService(); + + const result = (await service.execute( + makePayload("lanes.refreshSnapshots", { ifNoneMatch: "0".repeat(64) }), + )) as { lanes: unknown[]; signature: string; notModified: boolean }; + expect(result.notModified).toBe(false); + expect(result.lanes).toHaveLength(1); + expect(result.signature).not.toBe("0".repeat(64)); + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 4567b8da5..380f5a601 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1,4 +1,4 @@ -import { randomUUID } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; import type { AgentChatCreateArgs, AgentChatArchiveArgs, @@ -270,6 +270,29 @@ function asOptionalNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } +function payloadSignature(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +/** + * Conditional-response envelope shared by the lane list/detail commands: when + * the caller's ifNoneMatch equals the current payload signature, return the + * lightweight notModified shell instead of the full payload. The full payload + * is still computed (the signature comes from it), so this saves transport and + * client decode/DB work, not host compute. + */ +function respondWithSignature( + response: T, + ifNoneMatch: string | null | undefined, + emptyResponse: E, +): (T | E) & { signature: string; notModified: boolean } { + const signature = payloadSignature(response); + if (ifNoneMatch && ifNoneMatch === signature) { + return { ...emptyResponse, signature, notModified: true }; + } + return { ...response, signature, notModified: false }; +} + function asConfidenceThreshold(value: unknown): number | undefined { const numeric = asOptionalNumber(value); if (numeric == null) return undefined; @@ -1931,6 +1954,18 @@ async function buildLaneListSnapshots( })); } +type LaneDetailRequestArgs = { + laneId: string; + ifNoneMatch?: string; +}; + +function parseLaneDetailRequestArgs(value: Record): LaneDetailRequestArgs { + return { + laneId: requireString(value.laneId, "lanes.getDetail requires laneId."), + ...(asTrimmedString(value.ifNoneMatch) ? { ifNoneMatch: asTrimmedString(value.ifNoneMatch)! } : {}), + }; +} + async function buildLaneDetailPayload(args: SyncRemoteCommandServiceArgs, laneId: string): Promise { const lane = await args.laneService.getSummary(laneId, { includeStatus: true }); if (!lane) throw new Error(`Lane not found: ${laneId}`); @@ -2011,13 +2046,21 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio register("lanes.refreshSnapshots", { viewerAllowed: true }, async (payload) => { const listArgs = parseListLanesArgs(payload); const refreshed = await args.laneService.refreshSnapshots(listArgs); - return { + const response = { ...refreshed, snapshots: await buildLaneListSnapshots(args, refreshed.lanes, listArgs), }; + return respondWithSignature(response, asTrimmedString(payload.ifNoneMatch), { + refreshedCount: 0, + lanes: [], + snapshots: null, + }); + }); + register("lanes.getDetail", { viewerAllowed: true }, async (payload) => { + const detailArgs = parseLaneDetailRequestArgs(payload); + const response = await buildLaneDetailPayload(args, detailArgs.laneId); + return respondWithSignature(response, detailArgs.ifNoneMatch, {}); }); - register("lanes.getDetail", { viewerAllowed: true }, async (payload) => - buildLaneDetailPayload(args, requireString(payload.laneId, "lanes.getDetail requires laneId."))); register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); // Background lane naming for mobile auto-create. Deliberately NOT queueable: // when the phone is offline we want the call to fail fast so the client uses diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index f23404574..b7814d6a9 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -52,6 +52,7 @@ import { type SyncHostService, type SyncProjectCatalogProvider, type SyncRosterProvider, + type SyncForeignChatTranscriptResolver, type SyncRuntimeKind, } from "./syncHostService"; import { createSyncPairingStore } from "./syncPairingStore"; @@ -129,6 +130,7 @@ type SyncServiceArgs = { onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; projectCatalogProvider?: SyncProjectCatalogProvider; rosterProvider?: SyncRosterProvider; + foreignChatProvider?: SyncForeignChatTranscriptResolver; remoteCommandExecutor?: Pick; /** * Lazy accessor for the model picker store. iOS uses the `modelPicker.*` @@ -729,6 +731,7 @@ export function createSyncService(args: SyncServiceArgs) { deviceRegistryService, projectCatalogProvider: args.projectCatalogProvider, rosterProvider: args.rosterProvider, + foreignChatProvider: args.foreignChatProvider, remoteCommandService, remoteCommandExecutor: args.remoteCommandExecutor, onStateChanged: () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 0a67db126..8a6fa13f9 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -13452,7 +13452,9 @@ describe("createAgentChatService", () => { expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start").length) .toBeGreaterThan(turnStartCountBeforeApproval); }); - expect((await service.getSessionSummary(session.id))?.permissionMode).toBe("edit"); + // An approved plan hands the session straight to full access — the user + // already reviewed exactly what will happen. + expect((await service.getSessionSummary(session.id))?.permissionMode).toBe("full-auto"); }); it("emits a terminal event when a streamed native Codex plan item completes", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 465e9a9d3..423bc9898 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -15591,8 +15591,11 @@ export function createAgentChatService(args: { ): void => { const approved = args.decision === "accept" || args.decision === "accept_for_session"; if (approved) { - managed.session.permissionMode = "edit"; - applyLegacyPermissionModeToNativeControls(managed.session, "edit"); + // An approved plan hands the session straight to full access — the user + // already reviewed exactly what will happen, so gating every file change + // behind another approval round just relitigates the plan. + managed.session.permissionMode = "full-auto"; + applyLegacyPermissionModeToNativeControls(managed.session, "full-auto"); managed.session.interactionMode = "default"; runtime.threadResumed = false; runtime.canAttachResumedTurnStart = false; diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index f9bb49344..0facd5b00 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -212,6 +212,72 @@ describe("laneService createFromUnstaged", () => { } }); + it("skips lane_state_snapshots writes when the lane status is unchanged", async () => { + // Regression: unchanged status polls used to rewrite updated_at on every + // summary read, replicating a CRDT row change to phones every poll and + // hammering the mobile Lanes list with no-op invalidations. + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-noop-snapshot-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + try { + await seedProjectAndStack(db, { projectId: "proj-noop-snapshot", repoRoot }); + + let statusStdout = ""; + vi.mocked(runGit).mockImplementation(async (args: string[], opts?: { cwd?: string }) => { + const laneBranchGitStub = defaultLaneBranchGitStub(args); + if (laneBranchGitStub) return laneBranchGitStub; + const cwd = opts?.cwd ?? repoRoot; + if (args[0] === "worktree" && args[1] === "list") return { exitCode: 0, stdout: "", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args[2] === "HEAD") { + return { exitCode: 0, stdout: "main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--show-toplevel") { + return { exitCode: 0, stdout: `${cwd}\n`, stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify") return { exitCode: 1, stdout: "", stderr: "" }; + if (args[0] === "status") return { exitCode: 0, stdout: statusStdout, stderr: "" }; + if (args[0] === "rev-list" && args[1] === "--left-right") return { exitCode: 0, stdout: "0\t0\n", stderr: "" }; + if (args[0] === "rev-parse" && args[1] === "--abbrev-ref" && args.includes("@{upstream}")) { + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--path-format=absolute" && args[2] === "--git-dir") { + return { exitCode: 1, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-noop-snapshot", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + await service.getSummary("lane-child", { includeStatus: true }); + const row = () => + db.get<{ dirty: number; updated_at: string }>( + "select dirty, updated_at from lane_state_snapshots where lane_id = ?", + ["lane-child"], + ); + expect(row()?.dirty).toBe(0); + + // Pin updated_at to a sentinel; an unchanged-status read must not touch it. + const sentinel = "2026-03-11T12:34:56.000Z"; + db.run("update lane_state_snapshots set updated_at = ? where lane_id = ?", [sentinel, "lane-child"]); + await service.getSummary("lane-child", { includeStatus: true }); + expect(row()).toEqual({ dirty: 0, updated_at: sentinel }); + + // A real status change still writes. + statusStdout = " M file.txt\n"; + await service.getSummary("lane-child", { includeStatus: true }); + expect(row()?.dirty).toBe(1); + expect(row()?.updated_at).not.toBe(sentinel); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); + it("recreates the primary lane when the only stored primary lane is archived", async () => { const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-archived-")); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 9a0e17a26..bf8928adc 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -1081,6 +1081,7 @@ export function createLaneService({ updatedAt?: string; }): void => { const shouldUpdateAgentSummary = Object.prototype.hasOwnProperty.call(args, "agentSummary"); + const agentSummaryJson = args.agentSummary == null ? null : JSON.stringify(args.agentSummary); db.run( ` insert into lane_state_snapshots( @@ -1098,6 +1099,12 @@ export function createLaneService({ else lane_state_snapshots.agent_summary_json end, updated_at = excluded.updated_at + where lane_state_snapshots.dirty is not excluded.dirty + or lane_state_snapshots.ahead is not excluded.ahead + or lane_state_snapshots.behind is not excluded.behind + or lane_state_snapshots.remote_behind is not excluded.remote_behind + or lane_state_snapshots.rebase_in_progress is not excluded.rebase_in_progress + or (? = 1 and lane_state_snapshots.agent_summary_json is not excluded.agent_summary_json) `, [ args.laneId, @@ -1106,9 +1113,10 @@ export function createLaneService({ args.status.behind, args.status.remoteBehind, args.status.rebaseInProgress ? 1 : 0, - args.agentSummary == null ? null : JSON.stringify(args.agentSummary), + agentSummaryJson, args.updatedAt ?? new Date().toISOString(), shouldUpdateAgentSummary ? 1 : 0, + shouldUpdateAgentSummary ? 1 : 0, ], ); }; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index dd3ad00d5..e138061ad 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -203,6 +203,8 @@ export type LaneDetailPayload = { envInitProgress: LaneEnvInitProgress | null; sessions: TerminalSessionSummary[]; chatSessions: AgentChatSessionSummary[]; + signature?: string; + notModified?: boolean; }; export type LaneIcon = "star" | "flag" | "bolt" | "shield" | "tag" | null; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 00648b2b9..5c911adbc 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -239,6 +239,16 @@ export type SyncFeatureFlags = { chatStreaming: { enabled: true; }; + /** + * Cross-project chat "quick look": when enabled, the host honors a + * `projectId`/`projectRootPath` override on `chat_subscribe` and streams a + * foreign (non-active) project's chat transcript + live events read-only, + * without switching the socket's active project. Absent on hosts that + * predate the feature — clients must fall back to a full project activation. + */ + crossProjectChat?: { + enabled: boolean; + }; projectCatalog: { enabled: boolean; }; @@ -681,6 +691,16 @@ export type SyncChatSubscribePayload = { * falls back to the regular maxBytes-capped snapshot. */ sinceSeq?: number; + /** + * Cross-project "quick look" override. When present and identifying a + * registered project OTHER than the one this sync socket is scoped to, the + * host serves this session's transcript + live events from that foreign + * project read-only (no project switch, no runtime boot) — see the + * `crossProjectChat` feature flag. Absent for ordinary same-project + * subscribes, which stay scoped to the socket's active project. + */ + projectId?: string; + projectRootPath?: string; }; export type SyncChatSubscribeSnapshotPayload = { @@ -709,6 +729,9 @@ export type SyncChatSubscribeSnapshotPayload = { export type SyncChatUnsubscribePayload = { sessionId: string; + /** Cross-project override, mirrors SyncChatSubscribePayload.projectId. */ + projectId?: string; + projectRootPath?: string; }; /** diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index ee7db2e5f..8a96f9dc4 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -637,11 +637,6 @@ struct LaneBranchSwitchPreview: Codable, Equatable { var targetProfile: LaneBranchProfile? } -struct GitGenerateCommitMessageResult: Codable, Equatable { - var message: String - var model: String? -} - struct FileChange: Codable, Identifiable, Equatable { var id: String { path } var path: String @@ -1766,6 +1761,21 @@ enum AgentChatNoticeKind: String, Codable, Equatable { case info case providerHealth = "provider_health" case threadError = "thread_error" + case warning + case error + case config + + // The host's noticeKind union (see apps/desktop/src/shared/types/chat.ts) grows + // over time. `system_notice.noticeKind` is a required, non-optional decode, so an + // unrecognized value would throw — and because chat-event snapshots decode as a + // single `[AgentChatEventEnvelope]` array, one bad notice discards the WHOLE + // history, stranding pending-input cards (plan approvals, questions) behind the + // plain-text fallback. Fall back to `.info` for unknown kinds so future additions + // degrade to a generic notice instead of nuking the transcript. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = AgentChatNoticeKind(rawValue: raw) ?? .info + } } enum AgentChatApprovalRequestKind: String, Codable, Equatable { @@ -1915,9 +1925,41 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { var provenance: AgentChatEventProvenance? } +/// Decodes an array element-by-element, dropping elements that fail to decode +/// instead of failing the whole array. Chat-event history arrives as one big +/// snapshot array; a single envelope the phone can't decode (e.g. a host that +/// has since grown a new enum value somewhere inside an event payload) must +/// degrade to "that one event is missing", never "the entire transcript is +/// gone" — the latter strands pending-input cards behind the plain-text +/// fallback and locks the composer. +@propertyWrapper +struct ADELossyArray: Decodable, Equatable { + var wrappedValue: [Element] + + init(wrappedValue: [Element]) { + self.wrappedValue = wrappedValue + } + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + var elements: [Element] = [] + while !container.isAtEnd { + if let element = try? container.decode(Element.self) { + elements.append(element) + } else if (try? container.decode(RemoteJSONValue.self)) == nil { + // A failed element decode does not advance the container; consume the + // raw value to move past it. RemoteJSONValue accepts any JSON, so this + // only fails on a corrupt stream — bail rather than loop forever. + break + } + } + wrappedValue = elements + } +} + struct AgentChatEventHistorySnapshot: Decodable, Equatable { var sessionId: String - var events: [AgentChatEventEnvelope] + @ADELossyArray var events: [AgentChatEventEnvelope] var truncated: Bool var transcriptTruncated: Bool? var windowTruncated: Bool? @@ -1927,7 +1969,7 @@ struct AgentChatEventHistorySnapshot: Decodable, Equatable { struct AgentChatEventHistoryPage: Decodable, Equatable { var sessionId: String - var events: [AgentChatEventEnvelope] + @ADELossyArray var events: [AgentChatEventEnvelope] var startOffset: Int var hasMore: Bool var sessionFound: Bool @@ -2357,7 +2399,7 @@ struct SyncChatSubscribeSnapshotPayload: Decodable, Equatable { var sessionId: String var capturedAt: String var truncated: Bool - var events: [AgentChatEventEnvelope] + @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 /// `status: started` event can fall outside the tail; this flag is fresher @@ -2611,12 +2653,25 @@ struct LaneDetailPayload: Codable, Equatable { var envInitProgress: LaneEnvInitProgress? var sessions: [TerminalSessionSummary] var chatSessions: [AgentChatSessionSummary] + var signature: String? = nil + var notModified: Bool? = nil } struct LaneRefreshPayload: Codable, Equatable { var refreshedCount: Int var lanes: [LaneSummary] var snapshots: [LaneListSnapshot]? + var signature: String? = nil + var notModified: Bool? = nil +} + +/// The host's conditional-response shell for signature-checked lane commands. +/// A notModified response carries ONLY these fields (no payload body), so it +/// cannot decode as the full payload type — decode this first and fall through +/// to the full decode when `notModified` isn't true. +struct LaneNotModifiedEnvelope: Codable, Equatable { + var signature: String? = nil + var notModified: Bool? = nil } struct LaneEnvInitStep: Codable, Equatable, Identifiable { diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index b5468f8bf..75c618247 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -12,6 +12,10 @@ extension Notification.Name { enum ADEDatabaseChangeNotification { static let touchedTablesUserInfoKey = "touchedTables" + /// Lane ids whose local `lane_detail_snapshots` cache row changed, when known. + /// Only the phone-local detail write path can attribute a change to a lane, so + /// this lets subscribers invalidate a single lane detail instead of all. + static let laneDetailIdsUserInfoKey = "laneDetailIds" } final class DatabaseService { @@ -842,7 +846,7 @@ final class DatabaseService { try bindText(encodedDetail, to: statement, index: 2) try bindText(updatedAt, to: statement, index: 3) } - notifyDidChange(touchedTables: ["lane_detail_snapshots"]) + notifyDidChange(touchedTables: ["lane_detail_snapshots"], laneDetailIds: [detail.lane.id]) } private func laneSnapshotHydrationMatchesExisting(_ snapshots: [LaneListSnapshot]) -> Bool { @@ -3403,16 +3407,24 @@ final class DatabaseService { } } - private func notifyDidChange(touchedTables: Set = []) { + private func notifyDidChange(touchedTables: Set = [], laneDetailIds: Set = []) { let normalizedTables = touchedTables .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } .sorted() - let userInfo: [AnyHashable: Any]? = normalizedTables.isEmpty - ? nil - : [ADEDatabaseChangeNotification.touchedTablesUserInfoKey: normalizedTables] + let normalizedLaneDetailIds = laneDetailIds + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + var userInfo: [AnyHashable: Any] = [:] + if !normalizedTables.isEmpty { + userInfo[ADEDatabaseChangeNotification.touchedTablesUserInfoKey] = normalizedTables + } + if !normalizedLaneDetailIds.isEmpty { + userInfo[ADEDatabaseChangeNotification.laneDetailIdsUserInfoKey] = normalizedLaneDetailIds + } + let payload: [AnyHashable: Any]? = userInfo.isEmpty ? nil : userInfo DispatchQueue.main.async { - NotificationCenter.default.post(name: .adeDatabaseDidChange, object: nil, userInfo: userInfo) + NotificationCenter.default.post(name: .adeDatabaseDidChange, object: nil, userInfo: payload) } } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 70ba9064d..8155b8bc9 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1235,6 +1235,15 @@ func syncOutboundEnvelopeProjectId(type: String, activeProjectId: String?) -> St return syncNormalizedCommandScopeValue(activeProjectId) } +/// The foreign project a cross-project chat "quick look" streams from. The +/// envelope stays stamped with the active project (host-scoped); this override +/// rides inside the chat_subscribe payload / command args, mirroring how a +/// foreign `command` carries its target. +struct SyncCrossProjectChatScope: Equatable { + let projectId: String? + let projectRootPath: String? +} + /// Delivery events for the full-screen terminal. The active screen attaches a /// handler per session id and receives hydration snapshots, ordered live /// chunks, and process exit without polling `terminalBuffers`. @@ -1279,6 +1288,12 @@ final class SyncService: ObservableObject { @Published private(set) var localStateRevision = 0 @Published private(set) var lanesProjectionRevision = 0 @Published private(set) var laneDetailProjectionRevision = 0 + /// Per-lane detail revisions bumped only for local `lane_detail_snapshots` + /// writes with a known lane id, so an open lane detail stops reloading when an + /// unrelated lane's cached detail changes. Broad triggers (CRR-synced lanes / + /// PR / linear rows, or project-scope changes) still bump + /// `laneDetailProjectionRevision`, which every open detail also observes. + @Published private(set) var laneDetailRevisions: [String: Int] = [:] @Published private(set) var workProjectionRevision = 0 @Published private(set) var filesProjectionRevision = 0 @Published private(set) var prsProjectionRevision = 0 @@ -1483,7 +1498,10 @@ final class SyncService: ObservableObject { /// reloads do not fire on every CRDT row during host sync. private var databaseRevisionDebounceTask: Task? private var pendingDatabaseTouchedTables: Set = [] + private var pendingLaneDetailIds: Set = [] private var pendingDatabaseChangeAffectsAll = false + private var laneSnapshotSignatures: [String: String] = [:] + private var laneDetailSignatures: [String: String] = [:] private var latestRemoteDbVersion = 0 private var outboundLocalDbVersion = 0 private var outboundCursorPersistTask: Task? @@ -1523,6 +1541,19 @@ final class SyncService: ObservableObject { private var supportsProjectActions = false private var supportsChatStreaming = false private var supportsChangesetAck = false + /// Host advertises cross-project chat "quick look": a `chat_subscribe` (and + /// the chat command actions) may carry a `projectId`/`projectRootPath` + /// override so a foreign project's chat streams read-only without switching + /// the phone's active project. Absent on the currently-published brain, so + /// the hub falls back to a full project activation. `private(set)` so the hub + /// can decide which open path to take. + private(set) var supportsCrossProjectChat = false + /// Foreign-project routing for the cross-project chat feature: sessionId -> + /// the project that session lives in. Present ONLY while a foreign chat is + /// open (registered by the chat view, cleared on close); empty in the common + /// same-project case, so it adds no work when the feature is unused. Every + /// chat read/write for a listed session routes to that project. + private var crossProjectChatScopeBySession: [String: SyncCrossProjectChatScope] = [:] private var projectSelectionTask: Task? private var projectSelectionGeneration: UInt64 = 0 private var healthyConnectionSampleCount = 0 @@ -1642,6 +1673,16 @@ final class SyncService: ObservableObject { if isActiveProject(project) { projectHomePresented = false + // An in-place hub activation can leave a project marked active while its + // work domain never finished hydrating (or landed in a failed/dropped + // state). Re-entering the project must repair that rather than no-op, + // otherwise its chats render blank until a manual reconnect. + if canSendLiveRequests() { + let workPhase = domainStatuses[.work]?.phase + if workPhase == .failed || workPhase == .disconnected { + startInitialHydrationTask(for: connectionGeneration) + } + } return } @@ -1726,6 +1767,52 @@ final class SyncService: ObservableObject { if isCurrentProjectSelection(selectionGeneration) { projectSwitchInFlightRootPath = nil } + // The hub chat cover renders the chat as soon as this returns, then reads + // the session row from the local DB. The switch flips `activeProjectId` + // immediately, but the project's work rows + chat subscriptions only land + // once the (re)connect completes its hello handshake and initial + // hydration. Wait (bounded) for that fresh hydration so the cover — and any + // follow-up project open, which no-ops because the project is already + // active — sees a hydrated project instead of a momentarily empty one. + await awaitFreshActiveProjectWorkHydration(selectionGeneration: selectionGeneration) + } + + /// Wait until the active project's work domain reports a *fresh* successful + /// hydration (a `lastHydratedAt` newer than when the caller started), or the + /// connection settles into a terminal non-hydrating state, or `timeout` + /// elapses. Used to hold the hub chat cover on its activating spinner until + /// an in-place project activation has actually loaded data. + private func awaitFreshActiveProjectWorkHydration( + selectionGeneration: UInt64, + timeout: TimeInterval = 6 + ) async { + let baselineHydratedAt = domainStatuses[.work]?.lastHydratedAt + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + guard isCurrentProjectSelection(selectionGeneration) else { return } + if canSendLiveRequests() { + let workPhase = domainStatuses[.work]?.phase + // `.ready` (with a fresh timestamp) means the reconnect's hello landed + // AND the work list loaded for the new project. `.failed` also implies + // the hello completed and the socket is now scoped to the new project — + // the transcript reads (which is what the cover needs) will resolve even + // though the work-list fetch itself hiccuped — so stop waiting rather + // than spinning to the timeout. + if workPhase == .ready, domainStatuses[.work]?.lastHydratedAt != baselineHydratedAt { + return + } + if workPhase == .failed { + return + } + } + // Give up early on a switch that failed to establish a live socket; the + // cover falls back to its own offline/empty handling rather than spinning + // for the full timeout. + if connectionState.isHostUnreachable { + return + } + try? await Task.sleep(nanoseconds: 200_000_000) + } } func forgetProject(_ project: MobileProjectSummary) { @@ -2344,6 +2431,7 @@ final class SyncService: ObservableObject { let scopeChanged = previousProjectId != nextProjectId || previousRootPath != nextRootPath if scopeChanged { prepareOutboundStateForProjectScopeChange() + resetLanePayloadSignatures() } activeProjectId = projectId activeProjectRootPath = nextRootPath @@ -2379,6 +2467,12 @@ final class SyncService: ObservableObject { } } + private func resetLanePayloadSignatures() { + laneSnapshotSignatures.removeAll() + laneDetailSignatures.removeAll() + laneDetailRevisions.removeAll() + } + private func normalizeActiveProjectSelection(allowSingleProjectFallback: Bool) { let projectIds = Set(projects.map(\.id)) @@ -2617,8 +2711,13 @@ final class SyncService: ObservableObject { .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } ) + let laneDetailIds = Set( + (notification.userInfo?[ADEDatabaseChangeNotification.laneDetailIdsUserInfoKey] as? [String] ?? []) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) Task { @MainActor in - self.scheduleProjectionRevisionBumpAfterDatabaseChange(touchedTables: touchedTables) + self.scheduleProjectionRevisionBumpAfterDatabaseChange(touchedTables: touchedTables, laneDetailIds: laneDetailIds) } } socketSessionDelegate.service = self @@ -2656,12 +2755,14 @@ final class SyncService: ObservableObject { } } - private func scheduleProjectionRevisionBumpAfterDatabaseChange(touchedTables: Set) { + private func scheduleProjectionRevisionBumpAfterDatabaseChange(touchedTables: Set, laneDetailIds: Set = []) { if touchedTables.isEmpty { pendingDatabaseChangeAffectsAll = true pendingDatabaseTouchedTables.removeAll() + pendingLaneDetailIds.removeAll() } else if !pendingDatabaseChangeAffectsAll { pendingDatabaseTouchedTables.formUnion(touchedTables) + pendingLaneDetailIds.formUnion(laneDetailIds) } databaseRevisionDebounceTask?.cancel() databaseRevisionDebounceTask = Task { @MainActor [weak self] in @@ -2669,7 +2770,9 @@ final class SyncService: ObservableObject { try? await Task.sleep(nanoseconds: 280_000_000) guard !Task.isCancelled else { return } let touchedTables = self.pendingDatabaseChangeAffectsAll ? Set() : self.pendingDatabaseTouchedTables + let laneDetailIds = self.pendingDatabaseChangeAffectsAll ? Set() : self.pendingLaneDetailIds self.pendingDatabaseTouchedTables.removeAll() + self.pendingLaneDetailIds.removeAll() self.pendingDatabaseChangeAffectsAll = false let affectsAll = touchedTables.isEmpty @@ -2688,7 +2791,7 @@ final class SyncService: ObservableObject { } if affectsAnyProjection { localStateRevision += 1 - self.bumpProjectionRevisions(for: touchedTables) + self.bumpProjectionRevisions(for: touchedTables, laneDetailIds: laneDetailIds) } if affectsActiveSessions { self.refreshActiveSessionsAndSnapshot() @@ -2712,14 +2815,12 @@ final class SyncService: ObservableObject { ].contains(table) } - private func bumpProjectionRevisions(for touchedTables: Set) { + private func bumpProjectionRevisions(for touchedTables: Set, laneDetailIds: Set = []) { let affectsAll = touchedTables.isEmpty if affectsAll || touchedTables.contains(where: Self.tableAffectsLanesProjection) { lanesProjectionRevision += 1 } - if affectsAll || touchedTables.contains(where: Self.tableAffectsLaneDetailProjection) { - laneDetailProjectionRevision += 1 - } + bumpLaneDetailRevisions(touchedTables: touchedTables, laneDetailIds: laneDetailIds, affectsAll: affectsAll) if affectsAll || touchedTables.contains(where: Self.tableAffectsWorkProjection) { workProjectionRevision += 1 } @@ -2734,6 +2835,28 @@ final class SyncService: ObservableObject { } } + /// Route lane-detail invalidation per-lane when the sole trigger is a local + /// `lane_detail_snapshots` write with a known lane id; otherwise fall back to + /// the global `laneDetailProjectionRevision`. `lane_detail_snapshots` is a + /// phone-local cache (never CRR-synced), so its writes always carry an id; the + /// other lane-detail tables (lanes / PR / linear) arrive over CRR without a + /// cheap lane id and must stay broad. + private func bumpLaneDetailRevisions(touchedTables: Set, laneDetailIds: Set, affectsAll: Bool) { + if affectsAll { + laneDetailProjectionRevision += 1 + return + } + let laneDetailTables = touchedTables.filter(Self.tableAffectsLaneDetailProjection) + guard !laneDetailTables.isEmpty else { return } + if laneDetailTables == ["lane_detail_snapshots"], !laneDetailIds.isEmpty { + for laneId in laneDetailIds { + laneDetailRevisions[laneId, default: 0] += 1 + } + } else { + laneDetailProjectionRevision += 1 + } + } + private static func tableAffectsLanesProjection(_ table: String) -> Bool { if table == "projects" { return true } return [ @@ -2742,7 +2865,6 @@ final class SyncService: ObservableObject { "lane_state_snapshots", "pull_requests", "pull_request_snapshots", - "terminal_sessions", "lane_linear_issues", "lane_linear_issue_links", ].contains(table) @@ -3740,14 +3862,26 @@ final class SyncService: ObservableObject { func refreshLaneSnapshots(includeStatus: Bool = true, includeDecorations: Bool = true) async throws { setDomainStatus([.lanes, .files], phase: .hydrating) do { - let raw = try await sendCommand(action: "lanes.refreshSnapshots", args: [ + let signatureKey = laneSnapshotSignatureKey(includeStatus: includeStatus, includeDecorations: includeDecorations) + var args: [String: Any] = [ "includeArchived": true, "includeStatus": includeStatus, "includeConflictStatus": includeDecorations, "includeRebaseSuggestions": includeDecorations, "includeAutoRebaseStatus": includeDecorations, - ]) + ] + if let signature = laneSnapshotSignatures[signatureKey] { + args["ifNoneMatch"] = signature + } + let raw = try await sendCommand(action: "lanes.refreshSnapshots", args: args) let payload = try decodeHydrationPayload(raw, as: LaneRefreshPayload.self, domainLabel: "lane", decoder: decoder) + if payload.notModified == true { + if let signature = payload.signature { + laneSnapshotSignatures[signatureKey] = signature + } + setDomainStatus([.lanes, .files], phase: .ready) + return + } let lanes = includeStatus ? payload.lanes : lanesPreservingStatus(payload.lanes) @@ -3758,6 +3892,9 @@ final class SyncService: ObservableObject { ? decoratedSnapshots : laneSnapshotsPreservingStatus(decoratedSnapshots) try database.replaceLaneSnapshots(lanes, snapshots: snapshots) + if let signature = payload.signature { + laneSnapshotSignatures[signatureKey] = signature + } setDomainStatus([.lanes, .files], phase: .ready) } catch { let friendlyMessage = SyncUserFacingError.message(for: error) @@ -3770,6 +3907,10 @@ final class SyncService: ObservableObject { } } + private func laneSnapshotSignatureKey(includeStatus: Bool, includeDecorations: Bool) -> String { + "archived:true|status:\(includeStatus)|decorations:\(includeDecorations)" + } + private func lanesPreservingStatus(_ lanes: [LaneSummary]) -> [LaneSummary] { let existingByLaneId = Dictionary( uniqueKeysWithValues: database.fetchLanes(includeArchived: true) @@ -4032,8 +4173,34 @@ final class SyncService: ObservableObject { setDomainStatus([.lanes], phase: .hydrating) } do { - let detail = try await sendDecodableCommand(action: "lanes.getDetail", args: ["laneId": laneId], as: LaneDetailPayload.self) + var args: [String: Any] = ["laneId": laneId] + let hasCachedDetail = database.fetchLaneDetail(laneId: laneId) != nil + if hasCachedDetail, let signature = laneDetailSignatures[laneId] { + args["ifNoneMatch"] = signature + } + var raw = try await sendCommand(action: "lanes.getDetail", args: args) + if let envelope = try? decodeHydrationPayload(raw, as: LaneNotModifiedEnvelope.self, domainLabel: "lane detail", decoder: decoder), + envelope.notModified == true { + if let signature = envelope.signature { + laneDetailSignatures[laneId] = signature + } + if let cachedDetail = database.fetchLaneDetail(laneId: laneId) { + setDomainStatus([.lanes], phase: .ready) + return cachedDetail + } + // The cached row vanished between request and response (a concurrent + // full re-hydration can prune lane_detail_snapshots). The notModified + // shell has no detail fields, so re-request the full payload instead + // of failing the screen on a field-less decode. + laneDetailSignatures[laneId] = nil + args["ifNoneMatch"] = nil + raw = try await sendCommand(action: "lanes.getDetail", args: args) + } + let detail = try decodeHydrationPayload(raw, as: LaneDetailPayload.self, domainLabel: "lane detail", decoder: decoder) try database.replaceLaneDetail(detail) + if let signature = detail.signature { + laneDetailSignatures[laneId] = signature + } setDomainStatus([.lanes], phase: .ready) return detail } catch { @@ -4079,6 +4246,33 @@ final class SyncService: ObservableObject { database.fetchSession(id: sessionId) } + /// Best-effort hydration for a session whose local DB row may not have synced + /// yet — e.g. a chat just created into (or opened from the hub against) a + /// project that was activated in place, where the switch flips the active + /// project before the reconnect's changeset stream has delivered its rows. + /// Pulls the authoritative session list from the host (which rewrites the + /// local rows) and, failing that, briefly waits for the changeset stream to + /// deliver the row. Returns the row once it resolves, or nil if it never does + /// (genuinely absent session) so callers still fall through to their empty + /// state. + @discardableResult + func ensureSessionRowHydrated(sessionId: String) async -> TerminalSessionSummary? { + if let existing = database.fetchSession(id: sessionId) { return existing } + if canSendLiveRequests() { + try? await refreshWorkSessions() + if let refreshed = database.fetchSession(id: sessionId) { return refreshed } + } + // Absorb changeset lag right after an in-place project activation: the row + // arrives via CRDT sync a beat after the switch. Bounded so a genuinely + // missing session still falls through to the empty state. + for _ in 0..<6 { + try? await Task.sleep(nanoseconds: 300_000_000) + if Task.isCancelled { break } + if let row = database.fetchSession(id: sessionId) { return row } + } + return nil + } + func listProcessDefinitions() async throws -> [ProcessDefinition] { try await sendDecodableCommand(action: "processes.listDefinitions", as: [ProcessDefinition].self) } @@ -4108,13 +4302,16 @@ final class SyncService: ObservableObject { } func setSessionPinned(sessionId: String, pinned: Bool) async throws { + // Local DB write no-ops for a cross-project quick look (no mirrored row); + // the meta command routes to the session's project via its scope. try database.setSessionPinned(sessionId: sessionId, pinned: pinned) if supportsRemoteAction("work.updateSessionMeta") { + let scope = chatCommandScope(for: sessionId) do { _ = try await sendCommand(action: "work.updateSessionMeta", args: [ "sessionId": sessionId, "pinned": pinned, - ]) + ], targetProjectId: scope.projectId, targetProjectRootPath: scope.rootPath) } catch { syncConnectLog.info( "work setSessionPinned deferred session=\(sessionId, privacy: .public) error=\(String(describing: error), privacy: .public)" @@ -4780,6 +4977,46 @@ final class SyncService: ObservableObject { ]) } + /// Registers a chat session as living in a FOREIGN project (cross-project + /// "quick look"). While registered, every chat read/write for this session + /// routes to that project without switching the phone's active project. The + /// chat view sets this before it loads and clears it on close. No-ops (and + /// clears any stale scope) when the host doesn't support the feature or the + /// target is actually the active project, so the normal same-project path is + /// used instead. + func setCrossProjectChatScope(sessionId: String, projectId: String?, projectRootPath: String?) { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + let normalizedProjectId = syncNormalizedCommandScopeValue(projectId) + let normalizedRootPath = syncNormalizedProjectRootScope(projectRootPath) + let matchesActive: Bool = { + if let normalizedProjectId, let activeProjectId, normalizedProjectId == activeProjectId { return true } + if let normalizedRootPath, let activeProjectRootPath, normalizedRootPath == activeProjectRootPath { return true } + return false + }() + guard supportsCrossProjectChat, !matchesActive, normalizedProjectId != nil || normalizedRootPath != nil else { + crossProjectChatScopeBySession.removeValue(forKey: trimmedSessionId) + return + } + crossProjectChatScopeBySession[trimmedSessionId] = SyncCrossProjectChatScope( + projectId: normalizedProjectId, + projectRootPath: normalizedRootPath + ) + } + + func clearCrossProjectChatScope(sessionId: String) { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty else { return } + crossProjectChatScopeBySession.removeValue(forKey: trimmedSessionId) + } + + /// The foreign project a chat command for this session must target, or + /// (nil, nil) for the active project (the common case). + private func chatCommandScope(for sessionId: String) -> (projectId: String?, rootPath: String?) { + guard let scope = crossProjectChatScopeBySession[sessionId] else { return (nil, nil) } + return (scope.projectId, scope.projectRootPath) + } + func subscribeToChatEvents(sessionId: String, requestSnapshot: Bool = false, maxBytes: Int? = nil) async throws { let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSessionId.isEmpty else { return } @@ -5343,15 +5580,6 @@ final class SyncService: ObservableObject { _ = try await sendCommand(action: "git.commit", args: ["laneId": laneId, "message": message, "amend": amend]) } - func generateCommitMessage(laneId: String, amend: Bool = false) async throws -> String { - let result = try await sendDecodableCommand( - action: "git.generateCommitMessage", - args: ["laneId": laneId, "amend": amend], - as: GitGenerateCommitMessageResult.self - ) - return result.message - } - func listRecentCommits(laneId: String) async throws -> [GitCommitSummary] { try await sendDecodableCommand(action: "git.listRecentCommits", args: ["laneId": laneId], as: [GitCommitSummary].self) } @@ -5706,13 +5934,23 @@ final class SyncService: ObservableObject { } func fetchChatSummary(sessionId: String) async throws -> AgentChatSessionSummary { - try await sendDecodableCommand(action: "chat.getSummary", args: ["sessionId": sessionId], as: AgentChatSessionSummary.self) + let scope = chatCommandScope(for: sessionId) + return try await sendDecodableCommand( + action: "chat.getSummary", + args: ["sessionId": sessionId], + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, + as: AgentChatSessionSummary.self + ) } func fetchChatEventHistorySnapshot(sessionId: String, maxEvents: Int = chatEventHistoryMaxEvents) async throws -> AgentChatEventHistorySnapshot { - try await sendDecodableCommand( + let scope = chatCommandScope(for: sessionId) + return try await sendDecodableCommand( action: "chat.getChatEventHistory", args: ["sessionId": sessionId, "maxEvents": max(1, min(chatEventHistoryMaxEvents, maxEvents))], + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, as: AgentChatEventHistorySnapshot.self ) } @@ -5746,17 +5984,23 @@ final class SyncService: ObservableObject { if let maxBytes, maxBytes > 0 { args["maxBytes"] = maxBytes } + let scope = chatCommandScope(for: sessionId) return try await sendDecodableCommand( action: "chat.getChatEventHistoryPage", args: args, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, as: AgentChatEventHistoryPage.self ) } func fetchChatTranscriptResponse(sessionId: String, limit: Int = 500, maxChars: Int = 600_000) async throws -> AgentChatTranscriptResponse { - try await sendDecodableCommand( + let scope = chatCommandScope(for: sessionId) + return try await sendDecodableCommand( action: "chat.getTranscript", args: ["sessionId": sessionId, "limit": limit, "maxChars": maxChars], + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, as: AgentChatTranscriptResponse.self ) } @@ -5821,7 +6065,13 @@ final class SyncService: ObservableObject { if let cursor, cursor > 0 { args["cursor"] = String(cursor) } - let response = try await sendCommand(action: "chat.getTranscript", args: args) + let scope = chatCommandScope(for: sessionId) + let response = try await sendCommand( + action: "chat.getTranscript", + args: args, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) if let payload = response as? [String: Any], payload["queued"] as? Bool == true { throw QueuedRemoteCommandError(action: "chat.getTranscript") } @@ -5867,7 +6117,13 @@ final class SyncService: ObservableObject { if let offset { args["offset"] = offset } - let response = try await sendCommand(action: "chat.getSubagentTranscript", args: args) + let scope = chatCommandScope(for: sessionId) + let response = try await sendCommand( + action: "chat.getSubagentTranscript", + args: args, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) if response is NSNull { return nil } @@ -5878,9 +6134,12 @@ final class SyncService: ObservableObject { } func fetchSubagents(sessionId: String) async throws -> [AgentChatSubagentSnapshot] { + let scope = chatCommandScope(for: sessionId) let response = try await sendCommand( action: "chat.listSubagents", - args: ["sessionId": sessionId] + args: ["sessionId": sessionId], + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) if let payload = response as? [String: Any], payload["queued"] as? Bool == true { throw QueuedRemoteCommandError(action: "chat.listSubagents") @@ -5895,53 +6154,81 @@ final class SyncService: ObservableObject { targetProjectId: String? = nil, targetProjectRootPath: String? = nil ) async throws -> SyncChatMessageDelivery { + // Auto-route to the session's foreign project (cross-project "quick look") + // unless the caller already named a target explicitly (e.g. the hub + // composer creating into a chosen project). + let scope = chatCommandScope(for: sessionId) let response = try await sendCommand( action: "chat.send", args: ["sessionId": sessionId, "text": text], disconnectOnTimeout: false, timeoutMessage: SyncRequestTimeout.chatSendMessage, timeoutNanoseconds: SyncRequestTimeout.chatSendTimeoutNanoseconds, - targetProjectId: targetProjectId, - targetProjectRootPath: targetProjectRootPath + targetProjectId: targetProjectId ?? scope.projectId, + targetProjectRootPath: targetProjectRootPath ?? scope.rootPath ) return syncChatMessageDelivery(from: response) } func interruptChatSession(sessionId: String) async throws { - _ = try await sendChatCommand(action: "chat.interrupt", payload: AgentChatInterruptRequest(sessionId: sessionId)) + let scope = chatCommandScope(for: sessionId) + _ = try await sendChatCommand( + action: "chat.interrupt", + payload: AgentChatInterruptRequest(sessionId: sessionId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } @discardableResult func steerChatSession(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { - let response = try await sendChatCommand(action: "chat.steer", payload: AgentChatSteerRequest(sessionId: sessionId, text: text)) + let scope = chatCommandScope(for: sessionId) + let response = try await sendChatCommand( + action: "chat.steer", + payload: AgentChatSteerRequest(sessionId: sessionId, text: text), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) return syncChatMessageDelivery(from: response) } func cancelChatSteer(sessionId: String, steerId: String) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.cancelSteer", - payload: AgentChatCancelSteerRequest(sessionId: sessionId, steerId: steerId) + payload: AgentChatCancelSteerRequest(sessionId: sessionId, steerId: steerId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } func editChatSteer(sessionId: String, steerId: String, text: String) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.editSteer", - payload: AgentChatEditSteerRequest(sessionId: sessionId, steerId: steerId, text: text) + payload: AgentChatEditSteerRequest(sessionId: sessionId, steerId: steerId, text: text), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } func dispatchChatSteer(sessionId: String, steerId: String, mode: String) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.dispatchSteer", - payload: AgentChatDispatchSteerRequest(sessionId: sessionId, steerId: steerId, mode: mode) + payload: AgentChatDispatchSteerRequest(sessionId: sessionId, steerId: steerId, mode: mode), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } func cancelDispatchedChatSteer(sessionId: String, steerId: String) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.cancelDispatchedSteer", - payload: AgentChatCancelDispatchedSteerRequest(sessionId: sessionId, steerId: steerId) + payload: AgentChatCancelDispatchedSteerRequest(sessionId: sessionId, steerId: steerId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } @@ -5951,9 +6238,12 @@ final class SyncService: ObservableObject { decision: AgentChatApprovalDecision, responseText: String? = nil ) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.approve", - payload: AgentChatApproveRequest(sessionId: sessionId, itemId: itemId, decision: decision, responseText: responseText) + payload: AgentChatApproveRequest(sessionId: sessionId, itemId: itemId, decision: decision, responseText: responseText), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } @@ -5964,6 +6254,7 @@ final class SyncService: ObservableObject { answers: [String: AgentChatInputAnswerValue]? = nil, responseText: String? = nil ) async throws { + let scope = chatCommandScope(for: sessionId) _ = try await sendChatCommand( action: "chat.respondToInput", payload: AgentChatRespondToInputRequest( @@ -5972,7 +6263,9 @@ final class SyncService: ObservableObject { decision: decision, answers: answers, responseText: responseText - ) + ), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath ) } @@ -5996,7 +6289,8 @@ final class SyncService: ObservableObject { computerUse: RemoteJSONValue? = nil, manuallyNamed: Bool? = nil ) async throws -> AgentChatSession { - try await sendDecodableChatCommand( + let scope = chatCommandScope(for: sessionId) + return try await sendDecodableChatCommand( action: "chat.updateSession", payload: AgentChatUpdateSessionRequest( sessionId: sessionId, @@ -6018,20 +6312,40 @@ final class SyncService: ObservableObject { computerUse: computerUse, manuallyNamed: manuallyNamed ), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, as: AgentChatSession.self ) } func archiveChatSession(sessionId: String) async throws { - _ = try await sendChatCommand(action: "chat.archive", payload: AgentChatSessionIdRequest(sessionId: sessionId)) + let scope = chatCommandScope(for: sessionId) + _ = try await sendChatCommand( + action: "chat.archive", + payload: AgentChatSessionIdRequest(sessionId: sessionId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } func unarchiveChatSession(sessionId: String) async throws { - _ = try await sendChatCommand(action: "chat.unarchive", payload: AgentChatSessionIdRequest(sessionId: sessionId)) + let scope = chatCommandScope(for: sessionId) + _ = try await sendChatCommand( + action: "chat.unarchive", + payload: AgentChatSessionIdRequest(sessionId: sessionId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } func deleteChatSession(sessionId: String) async throws { - _ = try await sendChatCommand(action: "chat.delete", payload: AgentChatSessionIdRequest(sessionId: sessionId)) + let scope = chatCommandScope(for: sessionId) + _ = try await sendChatCommand( + action: "chat.delete", + payload: AgentChatSessionIdRequest(sessionId: sessionId), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } func readArtifact(artifactId: String? = nil, uri: String? = nil, path: String? = nil) async throws -> SyncFileBlob { @@ -8120,6 +8434,7 @@ final class SyncService: ObservableObject { return false } supportsChatStreaming = featureEnabled("chatStreaming", "chat_streaming") + supportsCrossProjectChat = featureEnabled("crossProjectChat", "cross_project_chat") supportsProjectCatalog = featureEnabled("projectCatalog", "project_catalog") supportsProjectActions = featureEnabled("projectActions", "project_actions") supportsChangesetAck = featureEnabled("changesetAck", "changeset_ack") @@ -9069,12 +9384,36 @@ final class SyncService: ObservableObject { return args } - private func sendChatCommand(action: String, payload: T) async throws -> Any { - try await sendCommand(action: action, args: try encodedCommandArgs(from: payload)) + private func sendChatCommand( + action: String, + payload: T, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> Any { + try await sendCommand( + action: action, + args: try encodedCommandArgs(from: payload), + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) } - private func sendDecodableChatCommand(action: String, payload: T, as type: U.Type) async throws -> U { - try decode(try await sendChatCommand(action: action, payload: payload), as: type) + private func sendDecodableChatCommand( + action: String, + payload: T, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil, + as type: U.Type + ) async throws -> U { + try decode( + try await sendChatCommand( + action: action, + payload: payload, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ), + as: type + ) } private func jsonObject(from value: T) throws -> Any { @@ -9372,6 +9711,16 @@ final class SyncService: ObservableObject { if includeSinceSeq, let lastSeq = chatEventLastSeqBySession[sessionId] { payload["sinceSeq"] = lastSeq } + // Cross-project "quick look": ride the foreign target inside the payload so + // the host serves that project's transcript while the envelope stays + // stamped with the active project (mirrors a foreign command). Only when + // the host advertised support — otherwise the field would be ignored and + // the subscribe would silently bind to the wrong (active) project. + if supportsCrossProjectChat { + let scope = chatCommandScope(for: sessionId) + if let projectId = scope.projectId { payload["projectId"] = projectId } + if let projectRootPath = scope.rootPath { payload["projectRootPath"] = projectRootPath } + } return payload } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 7a43a95ab..fe243513c 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -1,8 +1,8 @@ import SwiftUI // Visual building blocks for the all-projects hub: the top bar, project cards -// (collapsible) with their lanes (collapsible) and chat rows, the bottom -// "type to vibecode" composer trigger, and the empty/connecting states. +// (collapsible) with their lanes (collapsible) and chat rows, and the +// empty/connecting states. The bottom composer lives in HubComposerDrawer.swift. // MARK: - Top bar @@ -115,8 +115,6 @@ struct HubProjectPresentation: Equatable, Identifiable { let isLoading: Bool let laneCount: Int let chatCount: Int - let runningCount: Int - let attentionCount: Int let lanes: [HubLanePresentation] let metaLine: String fileprivate let renderSignature: Int @@ -130,8 +128,6 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: Bool, laneCount: Int, chatCount: Int, - runningCount: Int, - attentionCount: Int, lanes: [HubLanePresentation] ) { self.project = project @@ -140,8 +136,6 @@ struct HubProjectPresentation: Equatable, Identifiable { self.isLoading = isLoading self.laneCount = laneCount self.chatCount = chatCount - self.runningCount = runningCount - self.attentionCount = attentionCount self.lanes = lanes let lanePart = "\(laneCount) lane\(laneCount == 1 ? "" : "s")" let chatPart = "\(chatCount) chat\(chatCount == 1 ? "" : "s")" @@ -153,8 +147,6 @@ struct HubProjectPresentation: Equatable, Identifiable { isLoading: isLoading, laneCount: laneCount, chatCount: chatCount, - runningCount: runningCount, - attentionCount: attentionCount, lanes: lanes ) } @@ -168,8 +160,6 @@ struct HubLanePresentation: Equatable, Identifiable { let lane: RemoteRosterLane let rows: [HubChatRowPresentation] let totalCount: Int - let runningCount: Int - let attentionCount: Int fileprivate let renderSignature: Int var id: String { lane.id } @@ -177,21 +167,15 @@ struct HubLanePresentation: Equatable, Identifiable { init( lane: RemoteRosterLane, rows: [HubChatRowPresentation], - totalCount: Int, - runningCount: Int, - attentionCount: Int + totalCount: Int ) { self.lane = lane self.rows = rows self.totalCount = totalCount - self.runningCount = runningCount - self.attentionCount = attentionCount self.renderSignature = hubLaneRenderSignature( lane: lane, rows: rows, - totalCount: totalCount, - runningCount: runningCount, - attentionCount: attentionCount + totalCount: totalCount ) } @@ -266,8 +250,6 @@ private func hubProjectRenderSignature( isLoading: Bool, laneCount: Int, chatCount: Int, - runningCount: Int, - attentionCount: Int, lanes: [HubLanePresentation] ) -> Int { var hasher = Hasher() @@ -281,8 +263,6 @@ private func hubProjectRenderSignature( hasher.combine(isLoading) hasher.combine(laneCount) hasher.combine(chatCount) - hasher.combine(runningCount) - hasher.combine(attentionCount) hasher.combine(lanes.map(\.renderSignature)) return hasher.finalize() } @@ -298,9 +278,7 @@ private func hubProjectIconSignature(_ dataUrl: String?) -> String { private func hubLaneRenderSignature( lane: RemoteRosterLane, rows: [HubChatRowPresentation], - totalCount: Int, - runningCount: Int, - attentionCount: Int + totalCount: Int ) -> Int { var hasher = Hasher() hasher.combine(lane.id) @@ -308,8 +286,6 @@ private func hubLaneRenderSignature( hasher.combine(lane.color) hasher.combine(lane.icon) hasher.combine(totalCount) - hasher.combine(runningCount) - hasher.combine(attentionCount) hasher.combine(rows.map(\.renderSignature)) return hasher.finalize() } @@ -353,8 +329,6 @@ func buildHubProjectPresentation( isLoading: isActive, laneCount: project.laneCount, chatCount: 0, - runningCount: 0, - attentionCount: 0, lanes: [] ) } @@ -394,9 +368,7 @@ func buildHubProjectPresentation( return HubLanePresentation( lane: lane, rows: rows, - totalCount: rows.count, - runningCount: laneChats.filter(\.isRunning).count, - attentionCount: laneChats.filter(\.needsAttention).count + totalCount: rows.count ) } let chatCount = lanes.reduce(0) { $0 + $1.rows.count } @@ -408,8 +380,6 @@ func buildHubProjectPresentation( isLoading: false, laneCount: roster.lanes.count, chatCount: chatCount, - runningCount: topLevelChats.filter(\.isRunning).count, - attentionCount: topLevelChats.filter(\.needsAttention).count, lanes: lanes ) } @@ -493,16 +463,12 @@ struct HubProjectCard: View, Equatable { // Tapping the title area opens the full project tabs. Button(action: onOpenProject) { - HStack(spacing: 6) { - Text(project.displayName) - .font(.system(.title3, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if presentation.runningCount > 0 { HubRunningPulse(count: presentation.runningCount) } - if presentation.attentionCount > 0 { HubAttentionBubble(count: presentation.attentionCount) } - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) + Text(project.displayName) + .font(.system(.title3, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -616,8 +582,6 @@ struct HubLaneSection: View, Equatable { .foregroundStyle(laneTint) .lineLimit(1) Spacer(minLength: 6) - if presentation.runningCount > 0 { HubRunningPulse(count: presentation.runningCount) } - if presentation.attentionCount > 0 { HubAttentionBubble(count: presentation.attentionCount) } Text("\(presentation.totalCount)") .font(.system(.caption2, design: .rounded).weight(.semibold).monospacedDigit()) .foregroundStyle(ADEColor.textMuted) @@ -722,73 +686,6 @@ struct HubStatusDot: View { } } -// MARK: - Attention bubble + running pulse - -struct HubAttentionBubble: View { - let count: Int - var body: some View { - Text("\(count)") - .font(.system(.caption2, design: .rounded).weight(.bold)) - .foregroundStyle(.white) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ADEColor.warning, in: Capsule()) - .accessibilityLabel("\(count) need\(count == 1 ? "s" : "") attention") - } -} - -struct HubRunningPulse: View { - let count: Int - - var body: some View { - HStack(spacing: 4) { - Circle() - .fill(ADEColor.success) - .frame(width: 7, height: 7) - Text("\(count)") - .font(.system(.caption2, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.success) - } - .accessibilityLabel("\(count) running") - } -} - -// MARK: - Bottom composer trigger - -struct HubComposerBar: View { - let onTap: () -> Void - - var body: some View { - Button(action: onTap) { - HStack(spacing: 10) { - Image(systemName: "sparkles") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ADEColor.accent) - Text("Type to vibecode…") - .font(.system(.subheadline, design: .rounded)) - .foregroundStyle(ADEColor.textMuted) - Spacer(minLength: 8) - Image(systemName: "arrow.up.circle.fill") - .font(.system(size: 24)) - .foregroundStyle(ADEColor.accent.opacity(0.85)) - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - .background { - Capsule().fill(ADEColor.pageBackground) - Capsule().fill(ADEColor.composerBackground) - } - .overlay(Capsule().stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) - .contentShape(Capsule()) - } - .buttonStyle(.plain) - .padding(.horizontal, 16) - .padding(.bottom, 8) - .accessibilityLabel("New chat") - .accessibilityHint("Opens the new chat composer.") - } -} - // MARK: - State cards struct HubConnectingCard: View { diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index f4334c971..d3f8831d7 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -1,18 +1,20 @@ import SwiftUI -// The hub's slide-up "new chat" drawer. Opened from the bottom "type to -// vibecode" bar, it mirrors the in-project new-chat composer -// (`WorkNewChatScreen`) — same model picker, access-mode pills, fast-mode -// toggle, dictation, and Chat/CLI switch — but adds a combined Project ▸ Lane -// destination control, because from the hub a chat isn't scoped to a project -// yet. On send it creates the chat IN THE CHOSEN PROJECT IN PLACE (no -// active-project switch), reports the created chat back through `onCreated`, -// and dismisses. The hub surfaces a toast; it does NOT navigate into the chat. +// The hub's bottom "type to vibecode" composer. The box pinned to the bottom of +// the hub IS the real composer — focusing it raises the keyboard and expands +// the full new-chat controls in place (Project ▸ Lane destination, Chat/CLI +// switch, model/mode/dictation row) directly above the keyboard, mirroring the +// in-project new-chat composer (`WorkNewChatScreen`). Collapsing the keyboard +// hides the controls but keeps the draft text and every setting; send works +// from the minimized box too. On send it creates the chat IN THE CHOSEN +// PROJECT IN PLACE (no active-project switch), reports the created chat back +// through `onCreated`, and collapses. The hub surfaces a toast; it does NOT +// navigate into the chat. // MARK: - Public surface (the hub depends on these names) -/// A chat created from the hub drawer, handed back to the hub via `onCreated` -/// after a successful create (the drawer has already dismissed by then). +/// A chat created from the hub composer, handed back to the hub via `onCreated` +/// after a successful create (the composer has already collapsed by then). struct HubCreatedChat: Equatable { let projectId: String let projectRootPath: String? @@ -29,44 +31,17 @@ private struct HubDestinationTopKey: PreferenceKey { static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() } } -extension View { - /// Presents the hub new-chat drawer as a sheet. `onCreated` fires after a - /// successful create (drawer already dismissed). - func hubComposerDrawer( - isPresented: Binding, - onCreated: @escaping (HubCreatedChat) -> Void = { _ in } - ) -> some View { - modifier(HubComposerDrawerModifier(isPresented: isPresented, onCreated: onCreated)) - } -} +// MARK: - Inline composer -/// Hosts the drawer in a medium/large sheet. Sheets do NOT inherit environment -/// objects from their presenter, so we read the app-level `SyncService` and -/// `DictationController` here and re-inject them into the drawer. -private struct HubComposerDrawerModifier: ViewModifier { - @Binding var isPresented: Bool - let onCreated: (HubCreatedChat) -> Void +/// The one animation every hub-composer expand/collapse rides on — shared with +/// HubScreen so a tap-outside collapse moves identically to a focus expand. +let hubComposerSpring = Animation.spring(response: 0.34, dampingFraction: 0.86) +struct HubInlineComposer: View { @EnvironmentObject private var syncService: SyncService - @EnvironmentObject private var dictationController: DictationController - - func body(content: Content) -> some View { - content.sheet(isPresented: $isPresented) { - HubComposerDrawer(onCreated: onCreated) - .environmentObject(syncService) - .environmentObject(dictationController) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - } -} - -// MARK: - Drawer - -struct HubComposerDrawer: View { - @EnvironmentObject private var syncService: SyncService - @Environment(\.dismiss) private var dismiss + /// Owned by the hub so taps on the list behind the composer can collapse it. + @Binding var expanded: Bool let onCreated: (HubCreatedChat) -> Void // Composer selection (seeded from the app-wide "last used" record in init so @@ -104,7 +79,8 @@ struct HubComposerDrawer: View { private let dictationTargetId = "hub-new-chat-drawer" - init(onCreated: @escaping (HubCreatedChat) -> Void) { + init(expanded: Binding, onCreated: @escaping (HubCreatedChat) -> Void) { + self._expanded = expanded self.onCreated = onCreated if let saved = WorkComposerPreferences.load() { _provider = State(initialValue: saved.provider) @@ -113,7 +89,7 @@ struct HubComposerDrawer: View { _reasoningEffort = State(initialValue: saved.reasoningEffort) _codexFastMode = State(initialValue: saved.codexFastMode) } - if let dest = HubComposerDrawer.loadLastDestination() { + if let dest = HubInlineComposer.loadLastDestination() { _pickedProjectId = State(initialValue: dest.projectId) _selectedLaneId = State(initialValue: dest.laneId) } @@ -121,6 +97,21 @@ struct HubComposerDrawer: View { // MARK: Derived state + /// Expanded while the user is composing, dictating, or inside one of the + /// pickers (presenting a sheet/popover can resign the text field's focus — + /// the panel must not collapse underneath it). `expanded` is explicit state + /// (not derived from focus) so every change happens inside a spring + /// transaction instead of snapping with the focus flip. + private var isExpanded: Bool { + expanded || isDictating || modelPickerPresented || destinationPickerPresented + } + + /// Collapses the panel, keeping the draft text and all settings. + private func collapse() { + composerFocused = false + withAnimation(hubComposerSpring) { expanded = false } + } + private var composerSelection: WorkComposerPreferences.Selection { WorkComposerPreferences.Selection( provider: provider, @@ -195,38 +186,61 @@ struct HubComposerDrawer: View { // MARK: Body var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(spacing: 14) { - destinationControl - if !isDictating { - WorkSessionTypeSwitcher(selection: $sessionMode) - .frame(maxWidth: .infinity, alignment: .center) - } - if let errorMessage { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.caption) - .foregroundStyle(ADEColor.danger) - Text(errorMessage) - .font(.caption) - .foregroundStyle(ADEColor.danger) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(10) - .background(ADEColor.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - composerCard + VStack(spacing: 12) { + if isExpanded { + destinationControl + .transition(.move(edge: .bottom).combined(with: .opacity)) + if !isDictating { + WorkSessionTypeSwitcher(selection: $sessionMode) + .frame(maxWidth: .infinity, alignment: .center) + .transition(.move(edge: .bottom).combined(with: .opacity)) } - .padding(.horizontal, 16) - .padding(.top, 6) - .padding(.bottom, 16) } - .scrollBounceBehavior(.basedOnSize) - .scrollDismissesKeyboard(.interactively) - } - .background(ADEColor.pageBackground.ignoresSafeArea()) + if let errorMessage { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(ADEColor.danger) + Text(errorMessage) + .font(.caption) + .foregroundStyle(ADEColor.danger) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(10) + .background(ADEColor.danger.opacity(0.1), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + composerCard + } + .padding(.horizontal, 16) + .padding(.top, isExpanded ? 12 : 0) + .padding(.bottom, 8) + .animation(hubComposerSpring, value: isExpanded) + .animation(.easeOut(duration: 0.16), value: errorMessage != nil) + // Dragging down anywhere on the panel collapses it (the keyboard follows + // via the focus reset in collapse()). + .gesture( + DragGesture(minimumDistance: 16) + .onEnded { value in + if isExpanded && value.translation.height > 24 { collapse() } + } + ) .onAppear { onAppearSetup() } + .onChange(of: composerFocused) { _, focused in + if focused { withAnimation(hubComposerSpring) { expanded = true } } + } + .onChange(of: expanded) { _, nowExpanded in + // The hub collapses us from the outside (tap on the list) by flipping the + // binding — drop focus so the keyboard goes down with the panel. + if !nowExpanded { composerFocused = false } + } + // The keyboard can disappear without SwiftUI clearing focus (interactive + // scroll dismissal, hardware-keyboard toggles). Never leave the panel + // expanded with no keyboard and no way out — unless a picker or dictation + // legitimately owns the screen. + .onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification)) { _ in + guard expanded, !modelPickerPresented, !destinationPickerPresented, !isDictating else { return } + collapse() + } .onChange(of: provider) { _, newProvider in runtimeMode = workDefaultRuntimeMode(provider: newProvider) if !hubChatModelBelongs(modelId, to: hubNormalizedChatProvider(newProvider)) { @@ -253,7 +267,11 @@ struct HubComposerDrawer: View { .onChange(of: composerSelection) { _, newValue in WorkComposerPreferences.save(newValue) } - .sheet(isPresented: $modelPickerPresented) { + // Projects/lanes can appear or vanish while the hub sits open (reconnects, + // roster refreshes) — keep the persisted destination honest against them. + .onChange(of: syncService.rosterRevision) { _, _ in reconcileDestination() } + .onChange(of: syncService.projects.map(\.id)) { _, _ in reconcileDestination() } + .sheet(isPresented: $modelPickerPresented, onDismiss: { composerFocused = true }) { WorkModelPickerSheet( currentModelId: modelId, currentProvider: provider, @@ -323,6 +341,11 @@ struct HubComposerDrawer: View { destinationPicker .presentationCompactAdaptation(.popover) } + .onChange(of: destinationPickerPresented) { _, presented in + // Presenting the popover can resign the text field; bring the keyboard + // back when it closes so the flow stays continuous. + if !presented { composerFocused = true } + } } @ViewBuilder @@ -539,60 +562,30 @@ struct HubComposerDrawer: View { // MARK: Composer card + /// One card that morphs between two densities: minimized it reads as the old + /// "type to vibecode" capsule (sparkles + field + send), expanded it grows the + /// controls row (model/mode/fast/dictation) beneath the field. private var composerCard: some View { VStack(alignment: .leading, spacing: 12) { - TextField("Type to vibecode…", text: $draft, axis: .vertical) - .textFieldStyle(.plain) - .lineLimit(1...6) - .font(.body) - .foregroundStyle(ADEColor.textPrimary) - .tint(ADEColor.accent) - .textInputAutocapitalization(.sentences) - .focused($composerFocused) - .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - - HStack(alignment: .center, spacing: 8) { - if !isDictating { - ScrollView(.horizontal, showsIndicators: false) { - WorkComposerControlsRow( - provider: provider, - modelDisplayName: hubPrettyModelName(modelId), - reasoningEffort: reasoningEffort, - currentMode: runtimeMode, - modeOptions: workRuntimeModeOptions(provider: provider), - modeLabel: workRuntimeModeLabel(provider: provider, mode: runtimeMode), - isCollapsed: isControlsCollapsed, - fastModeSupported: fastModeSupported, - fastModeEnabled: codexFastMode, - settingsMutationInFlight: busy, - onOpenModelPicker: { modelPickerPresented = true }, - onSelectMode: { runtimeMode = $0 }, - onToggleFastMode: { codexFastMode = $0 } - ) - .padding(.trailing, 4) - } - .background( - GeometryReader { proxy in - Color.clear - .onAppear { controlsWidth = proxy.size.width } - .onChange(of: proxy.size.width) { _, newValue in - controlsWidth = newValue - } - } - ) - - DictationRawUndoChip(coordinator: dictationCoordinator, draft: $draft) + HStack(alignment: .center, spacing: 10) { + if !isExpanded { + Image(systemName: "sparkles") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .transition(.opacity) } - DictationMicButton( - draft: $draft, - coordinator: dictationCoordinator, - targetId: dictationTargetId, - onRecordingChange: { isDictating = $0 } - ) - .frame(maxWidth: isDictating ? .infinity : nil) + TextField("Type to vibecode…", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .lineLimit(1...6) + .font(.body) + .foregroundStyle(ADEColor.textPrimary) + .tint(ADEColor.accent) + .textInputAutocapitalization(.sentences) + .focused($composerFocused) + .frame(maxWidth: .infinity, minHeight: 28, alignment: .leading) - if !isDictating { + if !isExpanded { ADEComposerSendButton( enabled: canSend && !busy, sending: busy, @@ -601,20 +594,79 @@ struct HubComposerDrawer: View { ) { dispatch() } + .transition(.opacity) } } + + if isExpanded { + HStack(alignment: .center, spacing: 8) { + if !isDictating { + ScrollView(.horizontal, showsIndicators: false) { + WorkComposerControlsRow( + provider: provider, + modelDisplayName: hubPrettyModelName(modelId), + reasoningEffort: reasoningEffort, + currentMode: runtimeMode, + modeOptions: workRuntimeModeOptions(provider: provider), + modeLabel: workRuntimeModeLabel(provider: provider, mode: runtimeMode), + isCollapsed: isControlsCollapsed, + fastModeSupported: fastModeSupported, + fastModeEnabled: codexFastMode, + settingsMutationInFlight: busy, + onOpenModelPicker: { modelPickerPresented = true }, + onSelectMode: { runtimeMode = $0 }, + onToggleFastMode: { codexFastMode = $0 } + ) + .padding(.trailing, 4) + } + .background( + GeometryReader { proxy in + Color.clear + .onAppear { controlsWidth = proxy.size.width } + .onChange(of: proxy.size.width) { _, newValue in + controlsWidth = newValue + } + } + ) + + DictationRawUndoChip(coordinator: dictationCoordinator, draft: $draft) + } + + DictationMicButton( + draft: $draft, + coordinator: dictationCoordinator, + targetId: dictationTargetId, + onRecordingChange: { isDictating = $0 } + ) + .frame(maxWidth: isDictating ? .infinity : nil) + + if !isDictating { + ADEComposerSendButton( + enabled: canSend && !busy, + sending: busy, + accessibilityLabelText: "Start chat", + disabledAccessibilityLabel: "Enter a message to start" + ) { + dispatch() + } + } + } + .transition(.move(edge: .top).combined(with: .opacity)) + } } .padding(.horizontal, 14) - .padding(.vertical, 14) - .background( - RoundedRectangle(cornerRadius: 22, style: .continuous) + .padding(.vertical, isExpanded ? 14 : 10) + .background { + RoundedRectangle(cornerRadius: isExpanded ? 22 : 26, style: .continuous) + .fill(ADEColor.pageBackground) + RoundedRectangle(cornerRadius: isExpanded ? 22 : 26, style: .continuous) .fill(ADEColor.composerBackground) - ) + } .overlay( - RoundedRectangle(cornerRadius: 22, style: .continuous) - .stroke(ADEColor.glassBorder, lineWidth: 1) + RoundedRectangle(cornerRadius: isExpanded ? 22 : 26, style: .continuous) + .stroke(isExpanded ? ADEColor.glassBorder : ADEColor.border.opacity(0.8), lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.16), radius: 8, y: 3) + .shadow(color: Color.black.opacity(isExpanded ? 0.16 : 0), radius: 8, y: 3) } // MARK: Actions @@ -622,7 +674,7 @@ struct HubComposerDrawer: View { @MainActor private func onAppearSetup() { // A restored selection (see init) can carry a model only valid in the mode - // it was last used in (e.g. a CLI-only Cursor model). The drawer opens in + // it was last used in (e.g. a CLI-only Cursor model). The composer starts in // .chat, so normalize only when the restored model is actually disallowed — // a valid restored selection keeps its runtimeMode. let availabilityMode: WorkCursorAvailabilityMode = sessionMode == .cli ? .cli : .chat @@ -633,11 +685,6 @@ struct HubComposerDrawer: View { runtimeMode = workDefaultRuntimeMode(provider: provider) } reconcileDestination() - Task { - // Defer focus until the sheet finishes presenting so the keyboard rises. - try? await Task.sleep(nanoseconds: 350_000_000) - composerFocused = true - } } @MainActor @@ -772,7 +819,8 @@ struct HubComposerDrawer: View { sessionId: sessionId, isCli: isCli ) - dismiss() + // Collapse back to the minimized box; the hub's toast takes over from here. + collapse() onCreated(created) return true } catch { @@ -854,7 +902,7 @@ struct HubComposerDrawer: View { guard !trimmed.isEmpty else { return } let dest = HubLastDestination(projectId: trimmed, laneId: laneId) guard let data = try? JSONEncoder().encode(dest) else { return } - ADESharedContainer.defaults.set(data, forKey: HubComposerDrawer.lastDestinationKey) + ADESharedContainer.defaults.set(data, forKey: HubInlineComposer.lastDestinationKey) } // MARK: Composer normalization (self-contained mirrors of the new-chat screen) diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index c7247933f..1483f4e7b 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -26,16 +26,112 @@ private struct HubChatCoverModifier: ViewModifier { } } +/// Identifies the foreign project a hub chat streams from in cross-project +/// "quick look" mode. Passed to `WorkSessionDestinationView.crossProjectContext`. +struct WorkChatCrossProjectContext: Equatable { + let projectId: String + let projectRootPath: String? + let displayName: String +} + +/// How the hub decided to open a chat: still deciding, a lightweight +/// cross-project quick look (no project switch), or after a full activation. +private enum HubChatOpenMode: Equatable { + case deciding + case crossProject(WorkChatCrossProjectContext, TerminalSessionSummary) + case activated +} + +/// Chat tool type for a roster-seeded session stub. Prefers the row's own tool +/// type; derives one from the provider only as a fallback so `isChatSession` +/// still recognizes the stub as a chat. +private func workCrossProjectChatToolType(chat: RemoteRosterChat) -> String { + if let raw = chat.toolType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { + return raw + } + let provider = chat.provider?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + switch provider { + case "cursor": return "cursor" + case "": return "claude-chat" + default: return "\(provider)-chat" + } +} + +/// Synthesize a `TerminalSessionSummary` from the hub roster stub so the chat +/// view renders immediately in cross-project mode — the foreign session row is +/// never mirrored into the phone's local DB. The authoritative summary/status +/// arrives over the scoped transcript stream and `chat.getSummary`. +func makeCrossProjectSessionStub(chat: RemoteRosterChat, lane: RemoteRosterLane?) -> TerminalSessionSummary { + let (statusString, runtimeState): (String, String) = { + switch chat.status { + case .running: return ("running", "running") + case .awaiting: return ("awaiting-input", "waiting-input") + case .idle: return ("idle", "idle") + case .ended: return ("ended", "stopped") + case .failed: return ("failed", "failed") + } + }() + return TerminalSessionSummary( + id: chat.id, + laneId: chat.laneId, + laneName: lane?.name ?? "", + ptyId: nil, + tracked: true, + pinned: chat.pinned ?? false, + manuallyNamed: nil, + goal: nil, + toolType: workCrossProjectChatToolType(chat: chat), + title: chat.title ?? "Chat", + status: statusString, + startedAt: chat.lastActivityAt ?? "", + endedAt: nil, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: chat.preview, + summary: nil, + runtimeState: runtimeState, + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil, + chatSessionId: chat.chatSessionId + ) +} + private struct HubChatCover: View { let target: HubChatTarget let syncService: SyncService let onClose: () -> Void - @State private var ready = false + @State private var mode: HubChatOpenMode = .deciding var body: some View { NavigationStack { Group { - if ready { + switch mode { + case .deciding: + HubChatActivatingView(projectName: target.project.displayName, onClose: onClose) + case .crossProject(let context, let sessionStub): + // Lightweight cross-project quick look: stream the transcript from the + // foreign project WITHOUT switching the phone's active project. Lane/PR + // affordances are hidden (showsLaneActions: false) and gated off inside + // the view; sending / approving still work via scoped commands. + WorkSessionDestinationView( + sessionId: target.chat.id, + initialOpeningPrompt: nil, + initialSession: sessionStub, + initialChatSummary: nil, + initialTranscript: nil, + transitionNamespace: nil, + isLive: true, + navigationChrome: .pushedDetail, + forceFreshTranscriptOnOpen: true, + showsLaneActions: false, + lanes: target.lane.map { [$0.asLaneSummary()] } ?? [], + crossProjectContext: context + ) + .id(target.id) + case .activated: WorkSessionDestinationView( sessionId: target.chat.id, initialOpeningPrompt: nil, @@ -49,24 +145,38 @@ private struct HubChatCover: View { lanes: target.lane.map { [$0.asLaneSummary()] } ?? [] ) .id(target.id) - } else { - HubChatActivatingView(projectName: target.project.displayName, onClose: onClose) } } } - .task { - // Activate the chat's project (keeping the hub) so transcript sync targets - // the right project, then render the chat. No-op when already active. - if syncService.isActiveProject(target.project) { - ready = true - return - } - await syncService.openProjectForHubChat(target.project) - // Only render the chat once the project switch actually landed; otherwise - // the cover would open against the wrong active project (failed/offline switch). - guard syncService.isActiveProject(target.project) else { return } - ready = true + .task { await decideAndOpen() } + } + + private func decideAndOpen() async { + // Already the active project → the full-detail path (existing infra). + if syncService.isActiveProject(target.project) { + mode = .activated + return + } + // Cross-project quick look when the host supports it (newer brain): stream + // the foreign chat in place, no project switch. + if syncService.supportsCrossProjectChat { + mode = .crossProject( + WorkChatCrossProjectContext( + projectId: target.project.id, + projectRootPath: target.project.rootPath, + displayName: target.project.displayName + ), + makeCrossProjectSessionStub(chat: target.chat, lane: target.lane) + ) + return } + // Fallback for hosts without cross-project support (e.g. the currently + // published brain): activate the project first (keeping the hub), then + // render the chat once the switch lands. An offline/failed switch leaves + // the activating spinner so the cover never opens against the wrong project. + await syncService.openProjectForHubChat(target.project) + guard syncService.isActiveProject(target.project) else { return } + mode = .activated } } diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 30568659c..b718961e1 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -4,8 +4,9 @@ import SwiftUI // connected. Lists every project on the machine, each expandable to its chats // grouped by lane (sourced from the live roster feed). Tapping a project card // opens its detailed tabbed view; tapping a chat opens that chat directly -// (presented over the hub, so Back returns here). A bottom "type to vibecode" -// bar slides up a new-chat drawer with a Project ▸ Lane destination picker. +// (presented over the hub, so Back returns here). The bottom "type to vibecode" +// box is the inline new-chat composer — focusing it expands the full controls +// above the keyboard (see HubInlineComposer). // // Replaces the connected-state layout of the old `ProjectHomeView`; the // no-machine / connecting states are preserved here. @@ -21,7 +22,9 @@ struct HubScreen: View { // hub looks identical after opening a project and coming back — mobile-only, // never touches desktop ordering. @State private var projectOrder: [String] = [] - @State private var composerPresented = false + // Whether the bottom composer is expanded (keyboard/session controls up). + // Owned here so taps on the list behind it collapse it. + @State private var composerExpanded = false // Set when a hub chat row is tapped — drives the chat cover (wired in // HubScreen+ChatNavigation). @State var openChatTarget: HubChatTarget? @@ -48,7 +51,7 @@ struct HubScreen: View { } private var hubIsActive: Bool { - syncService.shouldShowProjectHome && openChatTarget == nil && !composerPresented + syncService.shouldShowProjectHome && openChatTarget == nil } var body: some View { @@ -66,7 +69,6 @@ struct HubScreen: View { RemoteProjectAddSheet().environmentObject(syncService) } .hubChatCover(target: $openChatTarget) - .hubComposerDrawer(isPresented: $composerPresented, onCreated: handleCreated) .overlay(alignment: .bottom) { if let toast = createdToast { HubCreatedToast(toast: toast, onOpen: { openCreated(toast) }, onDismiss: { dismissToast() }) @@ -98,6 +100,10 @@ struct HubScreen: View { createdToast = nil } + private func collapseComposer() { + withAnimation(hubComposerSpring) { composerExpanded = false } + } + private func openCreated(_ created: HubCreatedChat) { dismissToast() guard let project = syncService.projects.first(where: { $0.id == created.projectId }) else { return } @@ -163,6 +169,24 @@ struct HubScreen: View { .padding(.bottom, 16) } .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.interactively) + // While the composer is expanded, a dim scrim sits between the list and + // the composer: it swallows taps/drags (no accidental row opens) and any + // touch on it collapses the composer. Layered before safeAreaInset so + // the composer itself stays above and interactive. + .overlay { + if composerExpanded { + Color.black.opacity(0.38) + .ignoresSafeArea() + .onTapGesture { collapseComposer() } + .gesture( + DragGesture(minimumDistance: 12).onEnded { value in + if value.translation.height > 24 { collapseComposer() } + } + ) + .transition(.opacity) + } + } .safeAreaInset(edge: .bottom, spacing: 0) { if canShowProjects { VStack(spacing: 0) { @@ -177,7 +201,7 @@ struct HubScreen: View { .frame(height: 14) .allowsHitTesting(false) - HubComposerBar { composerPresented = true } + HubInlineComposer(expanded: $composerExpanded, onCreated: handleCreated) } .background( ADEColor.pageBackground diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index 150be3da5..6ad63b434 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -438,74 +438,10 @@ struct LaneListRow: View, Equatable { } } -// MARK: - Inline rebase warning (rendered inside lane cards) - -enum LaneCardRebaseWarningPresentation: Equatable { - case suggestion(behindCount: Int, hasPr: Bool) - case autoRebase(state: String, message: String?) - - var icon: String { - switch self { - case .suggestion: return "arrow.triangle.2.circlepath" - case .autoRebase(let state, _): - return state == "rebaseConflict" ? "exclamationmark.triangle.fill" : "exclamationmark.arrow.triangle.2.circlepath" - } - } - - var tint: Color { - switch self { - case .suggestion: return ADEColor.warning - case .autoRebase(let state, _): - return (state == "rebaseConflict" || state == "rebaseFailed") ? ADEColor.danger : ADEColor.warning - } - } - - var title: String { - switch self { - case .suggestion: return "Rebase suggested" - case .autoRebase(let state, _): - switch state { - case "rebaseConflict": return "Auto-rebase conflict" - case "rebaseFailed": return "Auto-rebase failed" - default: return "Auto-rebase needs attention" - } - } - } - - var detail: String? { - switch self { - case .suggestion(let behindCount, let hasPr): - let noun = behindCount == 1 ? "commit" : "commits" - let base = "\(behindCount) \(noun) behind" - return hasPr ? "\(base) · PR open" : base - case .autoRebase(_, let message): - return message - } - } - - var accessibilitySummary: String { - [title, detail].compactMap { part in - guard let part, !part.isEmpty else { return nil } - return part - }.joined(separator: ". ") - } -} - -func laneCardRebaseWarningPresentation(for snapshot: LaneListSnapshot) -> LaneCardRebaseWarningPresentation? { - if let status = snapshot.autoRebaseStatus, status.state != "autoRebased" { - return .autoRebase(state: status.state, message: status.message) - } - if let suggestion = snapshot.rebaseSuggestion, suggestion.dismissedAt == nil { - return .suggestion(behindCount: suggestion.behindCount, hasPr: suggestion.hasPr) - } - return nil -} - func laneStackCardAccessibilityLabel( snapshot: LaneListSnapshot, isPinned: Bool, isOpen: Bool, - rebaseWarning: LaneCardRebaseWarningPresentation?, pullRequest: LanePrTag? = nil ) -> String { var parts = [snapshot.lane.name, normalizedPrBranchName(snapshot.lane.branchRef)] @@ -517,45 +453,9 @@ func laneStackCardAccessibilityLabel( if snapshot.lane.status.ahead > 0 { parts.append("\(snapshot.lane.status.ahead) ahead") } if snapshot.lane.status.behind > 0 { parts.append("\(snapshot.lane.status.behind) behind") } if let pullRequest { parts.append(formatLanePrBadgeLabel(pullRequest)) } - if let warning = rebaseWarning { parts.append(warning.accessibilitySummary) } return parts.joined(separator: ", ") } -struct LaneCardRebaseWarning: View { - let presentation: LaneCardRebaseWarningPresentation - - var body: some View { - HStack(alignment: .center, spacing: 8) { - Image(systemName: presentation.icon) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(presentation.tint) - VStack(alignment: .leading, spacing: 1) { - Text(presentation.title) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - if let detail = presentation.detail, !detail.isEmpty { - Text(detail) - .font(.caption2) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(2) - } - } - Spacer(minLength: 0) - } - .padding(.vertical, 8) - .padding(.horizontal, 10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(presentation.tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(presentation.tint.opacity(0.28), lineWidth: 0.5) - ) - .accessibilityElement(children: .ignore) - .accessibilityLabel(presentation.accessibilitySummary) - } -} - // MARK: - PR tag struct LanePrTagChip: View { @@ -594,12 +494,23 @@ struct LaneStackCard: View, Equatable { var isSelectedTransitionSource = false static func == (lhs: LaneStackCard, rhs: LaneStackCard) -> Bool { - lhs.snapshot == rhs.snapshot - && lhs.isPinned == rhs.isPinned - && lhs.isOpen == rhs.isOpen - && lhs.depth == rhs.depth - && lhs.pullRequest == rhs.pullRequest - && lhs.isSelectedTransitionSource == rhs.isSelectedTransitionSource + lhs.renderSignature == rhs.renderSignature + } + + /// Cheap render-relevant equality key (mirrors the Hub row-signature pattern in + /// `HubComponents.swift`): hashes only the fields this card actually draws, so a + /// legitimate lanes update re-renders only rows whose visible state changed, and + /// the `.equatable()` diff is one `Int` compare instead of a deep + /// `LaneListSnapshot` compare that also over-invalidates on non-rendered fields. + fileprivate var renderSignature: Int { + laneStackCardRenderSignature( + snapshot: snapshot, + isPinned: isPinned, + isOpen: isOpen, + depth: depth, + pullRequest: pullRequest, + isSelectedTransitionSource: isSelectedTransitionSource + ) } var body: some View { @@ -674,10 +585,6 @@ struct LaneStackCard: View, Equatable { } .scrollClipDisabled() } - - if let warning = rebaseWarning { - LaneCardRebaseWarning(presentation: warning) - } } .padding(.leading, 14) .padding(.trailing, 14) @@ -704,10 +611,6 @@ struct LaneStackCard: View, Equatable { laneTint.text ?? ADEColor.textPrimary } - private var rebaseWarning: LaneCardRebaseWarningPresentation? { - laneCardRebaseWarningPresentation(for: snapshot) - } - private var cardStrokeTint: Color { if isOpen { return laneTint.accentBar.opacity(0.55) } return laneTint.border @@ -739,8 +642,47 @@ struct LaneStackCard: View, Equatable { snapshot: snapshot, isPinned: isPinned, isOpen: isOpen, - rebaseWarning: rebaseWarning, pullRequest: pullRequest ) } } + +/// Render-relevant signature for a `LaneStackCard` row. Combine only the fields +/// the card draws; adding a field here is required whenever the card starts +/// rendering something new, or that change will not trigger a re-render. +func laneStackCardRenderSignature( + snapshot: LaneListSnapshot, + isPinned: Bool, + isOpen: Bool, + depth: Int, + pullRequest: LanePrTag?, + isSelectedTransitionSource: Bool +) -> Int { + var hasher = Hasher() + let lane = snapshot.lane + hasher.combine(lane.id) + hasher.combine(lane.name) + hasher.combine(lane.color) + hasher.combine(lane.icon?.rawValue) + hasher.combine(lane.laneType) + hasher.combine(lane.archivedAt) + hasher.combine(lane.branchRef) + hasher.combine(lane.status.dirty) + hasher.combine(lane.status.ahead) + hasher.combine(lane.status.behind) + hasher.combine(lane.childCount) + hasher.combine(lane.devicesOpen?.count ?? 0) + hasher.combine(primaryLaneLinearIssue(for: lane)?.identifier) + hasher.combine(laneLinearIssueLinkCount(for: lane)) + hasher.combine(isPinned) + hasher.combine(isOpen) + hasher.combine(depth) + hasher.combine(isSelectedTransitionSource) + if let pullRequest { + hasher.combine(pullRequest.githubPrNumber) + hasher.combine(pullRequest.state) + } else { + hasher.combine(0) + } + return hasher.finalize() +} diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift b/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift index bbd1bc001..77477bccf 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailGitActionsPane.swift @@ -14,7 +14,6 @@ struct LaneDetailGitActionsPane: View { let onRefresh: () -> Void let onCommit: () -> Void - let onGenerateMessage: () async throws -> String let onPull: (_ mode: String) -> Void let onPush: (_ forceWithLease: Bool) -> Void let onFetch: () -> Void @@ -46,9 +45,10 @@ struct LaneDetailGitActionsPane: View { @State private var pullMode: String = "rebase" @State private var showMoreActions = false - @State private var isGeneratingMessage = false - @State private var aiSetupHint: String? @State private var pendingCommitConfirmation: CommitHistoryConfirmation? + @State private var filesDisclosure = LaneSectionDisclosure() + @State private var stashesDisclosure = LaneSectionDisclosure() + @State private var historyDisclosure = LaneSectionDisclosure() @FocusState private var commitFieldFocused: Bool private var stagedFiles: [FileChange] { detail.diffChanges?.staged ?? [] } @@ -61,6 +61,9 @@ struct LaneDetailGitActionsPane: View { var body: some View { VStack(alignment: .leading, spacing: 0) { headerSection + Divider() + .overlay(ADEColor.border.opacity(0.55)) + .padding(.bottom, 10) actionToolbar if showMoreActions { moreActionsSection @@ -74,6 +77,11 @@ struct LaneDetailGitActionsPane: View { .padding(EdgeInsets(top: 12, leading: 0, bottom: 20, trailing: 0)) } } + .onAppear { applyAutoDisclosure() } + .onChange(of: stagedFiles.count) { _, _ in applyAutoDisclosure() } + .onChange(of: unstagedFiles.count) { _, _ in applyAutoDisclosure() } + .onChange(of: detail.stashes.count) { _, _ in applyAutoDisclosure() } + .onChange(of: detail.recentCommits.count) { _, _ in applyAutoDisclosure() } .alert(item: $pendingCommitConfirmation) { confirmation in Alert( title: Text(confirmation.title), @@ -93,46 +101,41 @@ struct LaneDetailGitActionsPane: View { private var headerSection: some View { VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .firstTextBaseline, spacing: 8) { + HStack(alignment: .top, spacing: 8) { Circle() .fill(laneAccentColor) .frame(width: 8, height: 8) + .padding(.top, 6) Text(snapshot.lane.name) .font(.headline.weight(.bold)) .foregroundStyle(ADEColor.textPrimary) - .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + branchBadge + + LaneChipFlowLayout(spacing: 6, lineSpacing: 6) { + cleanBadge + if snapshot.lane.status.ahead > 0 || snapshot.lane.status.behind > 0 { + LaneMicroChip( + icon: "arrow.up.arrow.down", + text: "base ↑\(snapshot.lane.status.ahead) ↓\(snapshot.lane.status.behind)", + tint: ADEColor.textMuted + ) + } + linkedPullRequestBadge if let issue = primaryLaneLinearIssue(for: snapshot.lane) { - LaneLinearIssueBadge(issue: issue) + LaneLinearIssueBadge(issue: issue, compact: true) } - Spacer(minLength: 0) - if let syncStatus { - Text(compactSyncSummary(syncStatus)) + if let origin = originLabel { + Text(origin) .font(.caption2) .foregroundStyle(ADEColor.textMuted) .lineLimit(1) } } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - branchBadge - cleanBadge - if snapshot.lane.status.ahead > 0 || snapshot.lane.status.behind > 0 { - LaneMicroChip( - icon: "arrow.up.arrow.down", - text: "base ↑\(snapshot.lane.status.ahead) ↓\(snapshot.lane.status.behind)", - tint: ADEColor.textMuted - ) - } - linkedPullRequestBadge - if let origin = originLabel { - Text(origin) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - } - } + .frame(maxWidth: .infinity, alignment: .leading) if let conflictStatus = detail.conflictStatus { Text(conflictSummary(conflictStatus)) @@ -144,6 +147,12 @@ struct LaneDetailGitActionsPane: View { .padding(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0)) } + private func applyAutoDisclosure() { + filesDisclosure.syncAuto(hasContent: !(stagedFiles.isEmpty && unstagedFiles.isEmpty)) + stashesDisclosure.syncAuto(hasContent: !detail.stashes.isEmpty) + historyDisclosure.syncAuto(hasContent: !detail.recentCommits.isEmpty) + } + private var laneAccentColor: Color { laneSurfaceTint(forHex: snapshot.lane.color).text ?? ADEColor.accent } @@ -207,6 +216,7 @@ struct LaneDetailGitActionsPane: View { .font(.system(.subheadline, design: .monospaced)) .padding(.horizontal, 10) .padding(.vertical, 8) + .frame(maxWidth: .infinity) .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 8, style: .continuous) @@ -215,6 +225,13 @@ struct LaneDetailGitActionsPane: View { .focused($commitFieldFocused) .disabled(!canRunLiveActions || busyAction != nil) + gitToolbarButton(title: "Commit", tint: ADEColor.accent, emphasize: true) { + onCommit() + } + .disabled(!canRunLiveActions || busyAction != nil || (!amendCommit && stagedFiles.isEmpty)) + } + + LaneChipFlowLayout(spacing: 6, lineSpacing: 6) { gitToolbarButton( title: amendCommit ? "Amend on" : "Amend", tint: amendCommit ? ADEColor.warning : ADEColor.textSecondary, @@ -223,60 +240,32 @@ struct LaneDetailGitActionsPane: View { amendCommit.toggle() } .disabled(!canRunLiveActions || busyAction != nil) - - gitToolbarButton(title: "Commit", tint: ADEColor.accent, emphasize: true) { - onCommit() + gitToolbarButton(title: pullMode == "merge" ? "Merge" : "Rebase", tint: ADEColor.textSecondary) { + pullMode = pullMode == "merge" ? "rebase" : "merge" } - .disabled(!canRunLiveActions || busyAction != nil || (!amendCommit && stagedFiles.isEmpty)) - } - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - gitToolbarButton(title: pullMode == "merge" ? "Merge" : "Rebase", tint: ADEColor.textSecondary) { - pullMode = pullMode == "merge" ? "rebase" : "merge" - } - gitToolbarButton(title: "Pull", tint: shouldPull ? ADEColor.warning : ADEColor.textPrimary, emphasize: shouldPull) { - onPull(pullMode) - } - .disabled(!canRunLiveActions || busyAction != nil || !shouldPull) - gitToolbarButton(title: pushTitle, tint: shouldPush ? ADEColor.success : ADEColor.textPrimary, emphasize: shouldPush) { - onPush(false) - } - .disabled(!canRunLiveActions || busyAction != nil || !shouldPush || (syncStatus?.diverged ?? false)) - gitToolbarButton(title: showMoreActions ? "More ▴" : "More ▾", tint: showMoreActions ? ADEColor.accent : ADEColor.textMuted) { - withAnimation(.smooth(duration: 0.2)) { showMoreActions.toggle() } - } - Button(action: onRefresh) { - Image(systemName: "arrow.clockwise") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - .frame(width: 34, height: 34) - .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(busyAction != nil) - .accessibilityLabel("Refresh git state") + gitToolbarButton(title: "Pull", tint: shouldPull ? ADEColor.warning : ADEColor.textPrimary, emphasize: shouldPull) { + onPull(pullMode) } - } - - HStack(spacing: 8) { - Button(action: triggerSuggest) { - Label("Suggest message", systemImage: "sparkles") - .font(.caption.weight(.semibold)) + .disabled(!canRunLiveActions || busyAction != nil || !shouldPull) + gitToolbarButton(title: pushTitle, tint: shouldPush ? ADEColor.success : ADEColor.textPrimary, emphasize: shouldPush) { + onPush(false) } - .buttonStyle(.borderless) - .disabled(!canRunLiveActions || isGeneratingMessage || aiSetupHint != nil) - Spacer(minLength: 0) - Text(compactSyncSummary(syncStatus)) - .font(.caption2) - .foregroundStyle(ADEColor.textMuted) - } - - if let aiSetupHint { - Text(aiSetupHint) - .font(.caption2) - .foregroundStyle(ADEColor.warning) + .disabled(!canRunLiveActions || busyAction != nil || !shouldPush || (syncStatus?.diverged ?? false)) + gitToolbarButton(title: showMoreActions ? "More ▴" : "More ▾", tint: showMoreActions ? ADEColor.accent : ADEColor.textMuted) { + withAnimation(.smooth(duration: 0.2)) { showMoreActions.toggle() } + } + Button(action: onRefresh) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(busyAction != nil) + .accessibilityLabel("Refresh git state") } + .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 10) .padding(.horizontal, 10) @@ -327,26 +316,6 @@ struct LaneDetailGitActionsPane: View { .buttonStyle(.plain) } - private func triggerSuggest() { - guard !isGeneratingMessage else { return } - isGeneratingMessage = true - Task { - defer { isGeneratingMessage = false } - do { - let message = try await onGenerateMessage() - commitMessage = message - ADEHaptics.success() - } catch { - let text = error.localizedDescription - if text.localizedCaseInsensitiveContains("not configured") || text.localizedCaseInsensitiveContains("disabled") { - aiSetupHint = "Enable AI commit messages on desktop in Settings." - } else { - ADEHaptics.error() - } - } - } - } - // MARK: - More actions private var moreActionsSection: some View { @@ -391,21 +360,12 @@ struct LaneDetailGitActionsPane: View { private var filesSection: some View { VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("FILES") - .font(.caption.weight(.bold)) - .tracking(0.7) - .foregroundStyle(ADEColor.textMuted) - let fileCount = stagedFiles.count + unstagedFiles.count - if fileCount > 0 { - Text("\(fileCount)") - .font(.caption2.weight(.bold)) - .foregroundStyle(ADEColor.accent) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ADEColor.accent.opacity(0.14), in: Capsule()) - } - Spacer(minLength: 0) + disclosureHeader( + title: "Files", + badge: stagedFiles.count + unstagedFiles.count, + expanded: filesDisclosure.expanded, + onToggle: { withAnimation(.smooth(duration: 0.2)) { filesDisclosure.toggle() } } + ) { if canRescueUnstaged { Button("New lane with changes") { onCreateLaneFromChanges() @@ -414,6 +374,15 @@ struct LaneDetailGitActionsPane: View { .foregroundStyle(ADEColor.accent) } } + if filesDisclosure.expanded { + filesContent + } + } + } + + @ViewBuilder + private var filesContent: some View { + VStack(alignment: .leading, spacing: 10) { if stagedFiles.isEmpty && unstagedFiles.isEmpty { Text("No changed files.") .font(.caption) @@ -480,7 +449,21 @@ struct LaneDetailGitActionsPane: View { private var stashesSection: some View { VStack(alignment: .leading, spacing: 10) { - sectionTitle("Branch stashes", badge: detail.stashes.count) + disclosureHeader( + title: "Branch stashes", + badge: detail.stashes.count, + expanded: stashesDisclosure.expanded, + onToggle: { withAnimation(.smooth(duration: 0.2)) { stashesDisclosure.toggle() } } + ) + if stashesDisclosure.expanded { + stashesContent + } + } + } + + @ViewBuilder + private var stashesContent: some View { + VStack(alignment: .leading, spacing: 10) { if detail.stashes.isEmpty { HStack { Text("None saved") @@ -520,7 +503,21 @@ struct LaneDetailGitActionsPane: View { private var historySection: some View { VStack(alignment: .leading, spacing: 8) { - sectionTitle("History", badge: detail.recentCommits.count) + disclosureHeader( + title: "History", + badge: detail.recentCommits.count, + expanded: historyDisclosure.expanded, + onToggle: { withAnimation(.smooth(duration: 0.2)) { historyDisclosure.toggle() } } + ) + if historyDisclosure.expanded { + historyContent + } + } + } + + @ViewBuilder + private var historyContent: some View { + VStack(alignment: .leading, spacing: 8) { if detail.recentCommits.isEmpty { Text("No commits yet.") .font(.caption) @@ -628,22 +625,45 @@ struct LaneDetailGitActionsPane: View { .background(tint.opacity(0.14), in: Capsule()) } - private func sectionTitle(_ title: String, badge: Int) -> some View { + @ViewBuilder + private func disclosureHeader( + title: String, + badge: Int, + expanded: Bool, + onToggle: @escaping () -> Void, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) -> some View { HStack(spacing: 8) { - Text(title.uppercased()) - .font(.caption.weight(.bold)) - .tracking(0.7) - .foregroundStyle(ADEColor.textMuted) - if badge > 0 { - Text("\(badge)") - .font(.caption2.weight(.bold)) - .foregroundStyle(ADEColor.accent) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(ADEColor.accent.opacity(0.14), in: Capsule()) + Button(action: onToggle) { + HStack(spacing: 8) { + Image(systemName: "chevron.right") + .font(.system(size: 10, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .rotationEffect(.degrees(expanded ? 90 : 0)) + Text(title.uppercased()) + .font(.caption.weight(.bold)) + .tracking(0.7) + .foregroundStyle(ADEColor.textMuted) + if badge > 0 { + Text("\(badge)") + .font(.caption2.weight(.bold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + } + .contentShape(Rectangle()) } + .buttonStyle(.plain) Spacer(minLength: 0) + trailing() } + .accessibilityElement(children: .combine) + .accessibilityLabel("\(title), \(badge) item\(badge == 1 ? "" : "s")") + .accessibilityValue(expanded ? "Expanded" : "Collapsed") + .accessibilityHint(expanded ? "Double tap to collapse" : "Double tap to expand") + .accessibilityAddTraits(.isButton) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index 6650c047c..4aa95522e 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -35,7 +35,10 @@ struct LaneDetailScreen: View { @State private var lastLaneDetailLocalReload = Date.distantPast var laneDetailProjectionReloadKey: String { - "\(laneId)-\(syncService.laneDetailProjectionRevision)" + // Per-lane revision keeps this screen from reloading when an unrelated lane's + // local detail cache changes; the global revision still catches broad triggers + // (CRR-synced lanes/PR/linear rows, project-scope changes). + "\(laneId)-\(syncService.laneDetailProjectionRevision)-\(syncService.laneDetailRevisions[laneId] ?? 0)" } @State private var copiedLinkNotice: String? @State private var showRescueSheet = false @@ -283,9 +286,6 @@ struct LaneDetailScreen: View { } } }, - onGenerateMessage: { - try await syncService.generateCommitMessage(laneId: laneId, amend: amendCommit) - }, onPull: { mode in Task { await performAction("pull \(mode)") { try await syncService.syncGit(laneId: laneId, mode: mode) } } }, diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift b/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift index e6c6d46c6..e53f85f18 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift @@ -1 +1,81 @@ import SwiftUI + +// MARK: - Wrapping chip flow + +/// Lightweight flow layout: lays subviews left-to-right and wraps onto a new +/// line when the next subview would overflow the available width. Used for the +/// lane-detail header chip row and the git action buttons so they wrap instead +/// of horizontally scrolling. +struct LaneChipFlowLayout: Layout { + var spacing: CGFloat = 6 + var lineSpacing: CGFloat = 6 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) -> CGSize { + let maxWidth = proposal.width ?? .infinity + let rows = computeRows(maxWidth: maxWidth, subviews: subviews) + let height = rows.reduce(0) { $0 + $1.height } + CGFloat(max(0, rows.count - 1)) * lineSpacing + let width = proposal.width ?? rows.map(\.width).max() ?? 0 + return CGSize(width: width, height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout Void) { + let rows = computeRows(maxWidth: bounds.width, subviews: subviews) + var y = bounds.minY + for row in rows { + var x = bounds.minX + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place(at: CGPoint(x: x, y: y), anchor: .topLeading, proposal: ProposedViewSize(size)) + x += size.width + spacing + } + y += row.height + lineSpacing + } + } + + private struct Row { + var indices: [Int] = [] + var width: CGFloat = 0 + var height: CGFloat = 0 + } + + private func computeRows(maxWidth: CGFloat, subviews: Subviews) -> [Row] { + var rows: [Row] = [] + var current = Row() + for index in subviews.indices { + let size = subviews[index].sizeThatFits(.unspecified) + let projected = current.indices.isEmpty ? size.width : current.width + spacing + size.width + if projected > maxWidth && !current.indices.isEmpty { + rows.append(current) + current = Row(indices: [index], width: size.width, height: size.height) + } else { + if !current.indices.isEmpty { current.width += spacing } + current.indices.append(index) + current.width += size.width + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} + +// MARK: - Collapsible section disclosure state + +/// Tracks expand/collapse for a lane-detail section. The auto rule: a section +/// with content opens expanded, an empty one opens collapsed. `syncAuto` re-runs +/// as data hydrates until the user manually toggles, after which their choice +/// sticks. +struct LaneSectionDisclosure { + var expanded = false + private var pinnedByUser = false + + mutating func syncAuto(hasContent: Bool) { + guard !pinnedByUser else { return } + expanded = hasContent + } + + mutating func toggle() { + expanded.toggle() + pinnedByUser = true + } +} diff --git a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift index 13e18e064..399f66d63 100644 --- a/apps/ios/ADE/Views/Lanes/LaneHelpers.swift +++ b/apps/ios/ADE/Views/Lanes/LaneHelpers.swift @@ -618,16 +618,6 @@ func syncSummary(_ status: GitUpstreamSyncStatus) -> String { return "In sync with remote." } -func compactSyncSummary(_ status: GitUpstreamSyncStatus?) -> String { - guard let status else { return "Checking remote" } - if !status.hasUpstream { return "No upstream" } - if status.diverged { return "Diverged" } - if status.ahead > 0 && status.behind == 0 { return "\(status.ahead) ahead remote" } - if status.behind > 0 && status.ahead == 0 { return "\(status.behind) behind remote" } - if status.ahead > 0 && status.behind > 0 { return "\(status.ahead) ahead · \(status.behind) behind remote" } - return "In sync with remote" -} - func conflictSummary(_ status: ConflictStatus) -> String { switch status.status { case "conflict-active": diff --git a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift index d8399af03..89586e681 100644 --- a/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneManageSheet.swift @@ -174,6 +174,11 @@ struct LaneManageSheet: View { .background(ADEColor.surfaceBackground.opacity(0.35), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) appearanceTab } else { + // Matches the host's adoptableAttached derivation (attached AND not + // archived) — an archived attached lane can't be adopted. + if snapshot.adoptableAttached { + adoptSection + } manageTabBar tabContent } @@ -571,6 +576,34 @@ struct LaneManageSheet: View { } } + private var adoptSection: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "arrow.up.forward.app") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + VStack(alignment: .leading, spacing: 4) { + Text("Move to ADE-managed worktree") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Copies registration into .ade/worktrees so ADE can manage this lane's lifecycle like the others. Does not rewrite git history.") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + } + LaneActionButton(title: "Move", symbol: "arrow.up.forward.app", tint: ADEColor.accent) { + Task { await performAdopt() } + } + .disabled(!canRunLiveActions || busyAction != nil) + } + .padding(14) + .background(ADEColor.accent.opacity(0.06), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.accent.opacity(0.22), lineWidth: 1) + ) + } + private var archiveTab: some View { VStack(alignment: .leading, spacing: 12) { HStack(spacing: 10) { @@ -683,6 +716,11 @@ struct LaneManageSheet: View { busyAction = nil } + @MainActor + private func performAdopt() async { + await performAction("move lane") { _ = try await syncService.adoptAttachedLane(snapshot.lane.id) } + } + @MainActor private func performAction(_ label: String, operation: () async throws -> Void) async { guard canRunLiveActions else { diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index b74970214..01a19ab44 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -497,7 +497,6 @@ struct WorkComposerControlsRow: View { /// through the full model picker. struct WorkComposerChipStrip: View { let chatSummary: WorkChatSummaryRenderContext - let pendingInputCount: Int let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? let onOpenModelPicker: (() -> Void)? @@ -539,10 +538,6 @@ struct WorkComposerChipStrip: View { onToggleFastMode: onToggleCodexFastMode ) } - - if pendingInputCount > 0 { - statusChip(icon: "hand.raised.circle.fill", label: "\(pendingInputCount) waiting", tint: ADEColor.warning) - } } .padding(.horizontal, 2) } @@ -560,25 +555,6 @@ struct WorkComposerChipStrip: View { ) } - @ViewBuilder - private func statusChip(icon: String, label: String, tint: Color) -> some View { - HStack(spacing: 5) { - Image(systemName: icon) - .font(.caption2.weight(.semibold)) - Text(label) - .font(.caption.weight(.semibold)) - .lineLimit(1) - } - .foregroundStyle(tint) - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background(tint.opacity(0.1), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(tint.opacity(0.22), lineWidth: 0.5) - ) - } - private func prettyModelName(_ model: String) -> String { // Match the desktop composer's model label: "Claude Sonnet 4.6" / // "GPT-5.4" instead of a bare short id. Host-reported @@ -889,52 +865,6 @@ struct WorkQueuedSteerRow: View { } } -struct WorkApprovalRequestCard: View { - let approval: WorkPendingApprovalModel - let busy: Bool - let onDecision: @MainActor (AgentChatApprovalDecision) async -> Void - - var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("Approval needed") - .font(.headline) - .foregroundStyle(ADEColor.textPrimary) - - Text(approval.description) - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - - if let detail = approval.detail, !detail.isEmpty { - WorkStructuredOutputBlock(title: "Details", text: detail) - } - - HStack(spacing: 10) { - Button("Approve") { - Task { await onDecision(.accept) } - } - .buttonStyle(.glassProminent) - .tint(ADEColor.success) - .disabled(busy) - - Button("Approve for session") { - Task { await onDecision(.acceptForSession) } - } - .buttonStyle(.glass) - .tint(ADEColor.accent) - .disabled(busy) - - Button("Deny") { - Task { await onDecision(.decline) } - } - .buttonStyle(.glass) - .tint(ADEColor.danger) - .disabled(busy) - } - } - .adeGlassCard(cornerRadius: 18, padding: 14) - } -} - /// Whether an option preview should render as a fixed-column monospace block /// rather than markdown. True when the text contains ASCII box-drawing or /// wireframe glyphs (│┌┐└┘├┤┼─ ╭╮╰╯ ●○◉◯ ▢▣ etc.) or repeated indentation diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index ebbb3a562..d20e7dee0 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -1159,6 +1159,433 @@ struct WorkEventCardView: View { } } +/// Resolved / historical structured-question card shown in the transcript once +/// a question is no longer pending. Replaces the flat "Question asked · Input +/// requested" event card: renders the asking provider's logo + "{Provider} +/// asked", the question text as the primary line, clean option rows (recommended +/// and chosen marked), and — when resolved — the outcome inline so the separate +/// "Input resolved" ribbon can be folded away. +struct WorkResolvedQuestionCard: View { + let card: WorkEventCardModel + var fallbackProvider: String? = nil + + private var model: WorkPendingQuestionModel? { card.questionModel } + + private var resolvedProvider: String? { + if let source = model?.source?.trimmingCharacters(in: .whitespacesAndNewlines), !source.isEmpty { + return source + } + return fallbackProvider + } + + private var accent: Color { ADEColor.providerChatAccent(for: resolvedProvider) } + + private var resolution: String? { + guard let trimmed = card.resolution?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } + + private var isResolved: Bool { resolution != nil } + + private var isDeclined: Bool { + switch (resolution ?? "").lowercased() { + case "declined", "rejected", "cancelled", "canceled": return true + default: return false + } + } + + /// The option the user picked, matched by value or label against the + /// resolution word. Nil when the resolution is a plain status ("accepted"), + /// a freeform/typed answer, or a decline. + private var chosenOption: WorkPendingQuestionOption? { + guard let model, !isDeclined else { return nil } + for question in model.questions { + if let match = question.options.first(where: { isSelected($0) }) { + return match + } + } + return nil + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + + if let model { + ForEach(Array(model.questions.enumerated()), id: \.offset) { _, question in + questionSection(question) + } + } else if let body = card.body, !body.isEmpty { + Text(body) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + if isResolved { + resolutionPill + } + } + .padding(14) + .background(ADEColor.cardBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(0.22), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + private var header: some View { + HStack(spacing: 8) { + WorkProviderBareLogo( + provider: resolvedProvider, + fallbackSymbol: providerIcon(resolvedProvider ?? ""), + tint: accent, + size: 16 + ) + Text("\(workChatSurfaceProviderName(resolvedProvider)) asked") + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + Spacer(minLength: 8) + Text(relativeTimestamp(card.timestamp)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + } + + @ViewBuilder + private func questionSection(_ question: WorkPendingQuestion) -> some View { + let questionText = question.isSecret ? "Secure response requested" : question.question + VStack(alignment: .leading, spacing: 8) { + if let header = question.header, !header.isEmpty { + Text(header.uppercased()) + .font(.caption2.weight(.bold)) + .tracking(0.6) + .foregroundStyle(ADEColor.textMuted) + } + if !questionText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(questionText) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + if !question.options.isEmpty { + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(question.options.enumerated()), id: \.offset) { _, option in + optionRow(option) + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private func optionRow(_ option: WorkPendingQuestionOption) -> some View { + let selected = isSelected(option) + HStack(alignment: .top, spacing: 8) { + Image(systemName: selected ? "checkmark.circle.fill" : "circle") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(selected ? accent : ADEColor.textMuted.opacity(0.55)) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(option.label) + .font(.caption.weight(selected ? .semibold : .regular)) + .foregroundStyle(selected ? ADEColor.textPrimary : ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + if option.recommended { + Text("Recommended") + .font(.caption2.weight(.semibold)) + .foregroundStyle(accent) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(accent.opacity(0.12), in: Capsule(style: .continuous)) + } + } + if let description = option.description, !description.isEmpty { + Text(description) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(.vertical, 5) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + selected ? accent.opacity(0.08) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected ? accent.opacity(0.30) : Color.clear, lineWidth: 1) + ) + .opacity(isDeclined ? 0.55 : 1) + } + + private func isSelected(_ option: WorkPendingQuestionOption) -> Bool { + guard let resolution, !isDeclined else { return false } + let normalized = resolution.lowercased() + if normalized == option.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + return true + } + return normalized == option.label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private var resolutionPill: some View { + let key = (resolution ?? "").lowercased() + let tint = pendingInputResolutionTint(for: key).color + let label: String = { + if let chosen = chosenOption { + return "Answered · \(chosen.label)" + } + return isDeclined ? pendingInputResolutionLabel(for: key) : "Answered" + }() + return HStack(spacing: 5) { + Image(systemName: pendingInputResolutionIcon(for: key)) + .font(.system(size: 11, weight: .semibold)) + Text(label) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.10), in: Capsule(style: .continuous)) + } + + private var accessibilityText: String { + var parts: [String] = ["\(workChatSurfaceProviderName(resolvedProvider)) asked."] + if let model { + for question in model.questions where !question.question.isEmpty { + parts.append(question.isSecret ? "Secure response requested." : question.question) + } + } else if let body = card.body { + parts.append(body) + } + if let chosen = chosenOption { + parts.append("Answered \(chosen.label).") + } else if let resolution { + parts.append(pendingInputResolutionLabel(for: resolution.lowercased()) + ".") + } + return parts.joined(separator: " ") + } +} + +/// Resolved / historical plan-approval card. Mirrors the plan-ready composer +/// badge instead of dumping the plan markdown into per-line bullets: provider +/// logo + "{Provider} · Plan", the plan title, a short markdown preview, and a +/// "View full plan" affordance that presents the same `WorkPlanFullScreenView` +/// sheet the live badge uses. Shows the approve/reject outcome inline. +struct WorkResolvedPlanCard: View { + let card: WorkEventCardModel + var fallbackProvider: String? = nil + + @State private var planExpanded = false + + private var plan: WorkPendingPlanApprovalModel? { card.planApprovalModel } + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan?.source ?? "", fallbackProvider: fallbackProvider) + } + + private var accent: Color { ADEColor.providerChatAccent(for: resolvedProvider) } + + private var resolution: String? { + guard let trimmed = card.resolution?.trimmingCharacters(in: .whitespacesAndNewlines), + !trimmed.isEmpty else { return nil } + return trimmed + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + + if let plan { + if !plan.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(plan.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + WorkMarkdownRenderer(markdown: planPreviewText(plan.planText)) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + Button { + planExpanded = true + } label: { + HStack(spacing: 4) { + Image(systemName: "arrow.down.left.and.arrow.up.right") + .font(.system(size: 9, weight: .bold)) + Text("View full plan") + .font(.system(size: 11, weight: .semibold)) + } + .foregroundStyle(accent) + } + .buttonStyle(.plain) + .accessibilityLabel("View full plan") + + Spacer(minLength: 8) + + resolutionPill + } + } else if let body = card.body, !body.isEmpty { + Text(body) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + resolutionPill + } + } + .padding(14) + .background(ADEColor.cardBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(0.22), lineWidth: 1) + ) + .sheet(isPresented: $planExpanded) { + if let plan { + WorkPlanFullScreenView(plan: plan, fallbackProvider: fallbackProvider) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityText) + } + + private var header: some View { + HStack(spacing: 8) { + WorkProviderBareLogo( + provider: resolvedProvider, + fallbackSymbol: providerIcon(resolvedProvider ?? ""), + tint: accent, + size: 16 + ) + Text("\(workChatSurfaceProviderName(resolvedProvider)) · Plan") + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + Spacer(minLength: 8) + Image(systemName: "list.bullet.clipboard") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(accent.opacity(0.45)) + } + } + + @ViewBuilder + private var resolutionPill: some View { + if let resolution { + let key = resolution.lowercased() + let tint = pendingInputResolutionTint(for: key).color + HStack(spacing: 5) { + Image(systemName: pendingInputResolutionIcon(for: key)) + .font(.system(size: 11, weight: .semibold)) + Text(planResolutionLabel(key)) + .font(.caption2.weight(.semibold)) + } + .foregroundStyle(tint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(tint.opacity(0.10), in: Capsule(style: .continuous)) + } + } + + /// Plan approvals read as "Approved" / "Rejected" rather than the generic + /// "Accepted" / "Declined" the shared resolution mapping produces. + private func planResolutionLabel(_ key: String) -> String { + switch key { + case "accepted": return "Approved" + case "declined", "rejected": return "Rejected" + default: return pendingInputResolutionLabel(for: key) + } + } + + /// A compact preview of the plan markdown for the collapsed card — the full + /// text is available in the expand sheet. + private func planPreviewText(_ text: String) -> String { + let lines = text.components(separatedBy: .newlines) + var preview = lines.prefix(6).joined(separator: "\n") + if preview.count > 400 { + preview = String(preview.prefix(400)).trimmingCharacters(in: .whitespacesAndNewlines) + "…" + } else if lines.count > 6 { + preview += "\n…" + } + return preview + } + + private var accessibilityText: String { + var parts: [String] = ["\(workChatSurfaceProviderName(resolvedProvider)) plan."] + if let plan, !plan.title.isEmpty { + parts.append(plan.title) + } + if let resolution { + parts.append(planResolutionLabel(resolution.lowercased()) + ".") + } + return parts.joined(separator: " ") + } +} + +/// Resolved / historical generic (tool / file-change) approval, rendered as a +/// sleek one-line chip — provider logo + a small-caps "✓ ACCEPTED" / "✕ DECLINED" +/// outcome + the request description — instead of the old full card that printed +/// the description three times. The standalone "Input resolved" ribbon is folded +/// away (see `buildWorkEventCards`), so this chip is the single resolved surface. +struct WorkResolvedApprovalChip: View { + let card: WorkEventCardModel + var fallbackProvider: String? = nil + + private var accent: Color { ADEColor.providerChatAccent(for: fallbackProvider) } + private var resolutionKey: String { (card.resolution ?? "").lowercased() } + private var isResolved: Bool { card.resolution?.isEmpty == false } + + var body: some View { + HStack(spacing: 8) { + WorkProviderBareLogo( + provider: fallbackProvider, + fallbackSymbol: providerIcon(fallbackProvider ?? ""), + tint: accent, + size: 14 + ) + Image(systemName: isResolved ? pendingInputResolutionIcon(for: resolutionKey) : "checkmark.shield") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(statusTint) + Text(statusLabel.uppercased()) + .font(.caption2.monospaced().weight(.semibold)) + .tracking(0.6) + .foregroundStyle(statusTint) + if let description = card.body, !description.isEmpty { + Text(description) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer(minLength: 8) + Text(relativeTimestamp(card.timestamp)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel([statusLabel, card.body].compactMap { $0 }.joined(separator: ". ")) + } + + private var statusTint: Color { + isResolved ? pendingInputResolutionTint(for: resolutionKey).color : ADEColor.textMuted + } + + private var statusLabel: String { + isResolved ? pendingInputResolutionLabel(for: resolutionKey) : "Approval" + } +} + /// Rich proposed-plan card — per-step checklist with status icon/color and a /// progress meter. Replaces the generic "Status: text" bullet list so plans /// feel like a plan, not a dumped array. diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index f23b0a1ad..9e19d7923 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -236,6 +236,12 @@ extension WorkChatSessionView { ) } else if card.kind == "plan" { WorkProposedPlanCard(card: card) + } else if card.kind == "question" { + WorkResolvedQuestionCard(card: card, fallbackProvider: chatSummaryContext.provider) + } else if card.kind == "planApproval" { + WorkResolvedPlanCard(card: card, fallbackProvider: chatSummaryContext.provider) + } else if card.kind == "approval" { + WorkResolvedApprovalChip(card: card, fallbackProvider: chatSummaryContext.provider) } else { WorkEventCardView( card: card, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 8ad8bce99..117afedcc 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -301,6 +301,17 @@ struct WorkChatSessionView: View { }.first } + /// First open generic / file-change approval gate (Codex `approval` kind). + /// Rendered as a pinned badge above the composer, like the plan-ready badge. + var pendingApproval: WorkPendingApprovalModel? { + pendingInputs.compactMap { item -> WorkPendingApprovalModel? in + if case .approval(let model) = item { + return model + } + return nil + }.first + } + var hasPendingInputGate: Bool { workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) } @@ -345,26 +356,6 @@ struct WorkChatSessionView: View { } } - var awaitingPromptDetailsMissing: Bool { - workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) - } - - var awaitingPromptDetailsMessage: String { - let fallback = "The session is marked as needing input, but the prompt details have not synced to this iPhone yet. Keep the machine connected and try again when the prompt appears." - guard let preview = awaitingPromptPreview else { return fallback } - return "\(preview)\n\(fallback)" - } - - private var awaitingPromptPreview: String? { - [chatSummaryContext.lastOutputPreview, session.lastOutputPreview] - .compactMap { value -> String? in - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - .first - } - @MainActor private func runComposerSettingMutation( onFailure: @MainActor @escaping () -> Void = {}, @@ -486,15 +477,14 @@ struct WorkChatSessionView: View { if !canSendMessages { return "Reconnect to send messages." } - if pendingInputs.count == 1, pendingPlanApproval != nil { - return "Review the plan above the composer, or reject it before sending another message." + if pendingInputs.count == 1, pendingPlanApproval != nil || pendingApproval != nil { + // The plan-ready / approval badge (Approve · Decline) sits directly above + // the composer and is self-explanatory, so it carries no guidance banner. + return nil } if !pendingInputs.isEmpty { return "Answer the waiting prompt above, or decline it before sending another message." } - if awaitingPromptDetailsMissing { - return "Waiting for prompt details from the machine." - } return nil } @@ -518,32 +508,10 @@ struct WorkChatSessionView: View { // pending cards themselves stay visible in the timeline in a read-only // state, so duplicating the reconnect nag at the top added noise // without new information. - if isLive { - ForEach(pendingInputs) { item in - if case .approval(let approval) = item { - WorkApprovalRequestCard( - approval: approval, - busy: actionInFlight, - onDecision: { decision in - await runSessionAction { - await onApproveRequest(approval.id, decision, nil) - } - } - ) - } - } - } - - if awaitingPromptDetailsMissing { - ADENoticeCard( - title: "Prompt details syncing", - message: awaitingPromptDetailsMessage, - icon: "exclamationmark.bubble.fill", - tint: ADEColor.warning, - actionTitle: nil, - action: nil - ) - } + // Tool/file-change approval gates now render as a pinned badge directly + // above the composer (see `composerInset`), mirroring the plan-ready badge, + // so the Accept / Decline actions are always in reach instead of scrolled + // off the top of the transcript. // Connection-caused failures are communicated via the top-right gear, but // cached/offline chat actions still need their own visible errors. @@ -715,6 +683,20 @@ struct WorkChatSessionView: View { .id(planApproval.id) } + if let approval = pendingApproval { + WorkApprovalComposerStrip( + approval: approval, + busy: actionInFlight || !isLive, + fallbackProvider: chatSummaryContext.provider, + onDecision: { decision in + await runSessionAction { + await onApproveRequest(approval.id, decision, nil) + } + } + ) + .id(approval.id) + } + WorkChatComposerCard( chatSummary: chatSummaryContext, usageViewModel: workContextUsageViewModel( @@ -723,7 +705,6 @@ struct WorkChatSessionView: View { fallbackContextWindow: chatSummaryContext.contextWindowFallback ), dictationTargetId: "work-chat:\(session.id)", - pendingInputCount: pendingInputs.count, awaitingInputGate: hasPendingInputGate, composerPlaceholder: composerPlaceholderText, canCompose: canCompose, @@ -1586,7 +1567,6 @@ private struct WorkChatComposerCard: View { let chatSummary: WorkChatSummaryRenderContext let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String - let pendingInputCount: Int let awaitingInputGate: Bool let composerPlaceholder: String let canCompose: Bool @@ -1612,7 +1592,6 @@ private struct WorkChatComposerCard: View { chatSummary: chatSummary, usageViewModel: usageViewModel, dictationTargetId: dictationTargetId, - pendingInputCount: pendingInputCount, awaitingInputGate: awaitingInputGate, composerPlaceholder: composerPlaceholder, canCompose: canCompose, @@ -1648,7 +1627,6 @@ private struct WorkChatComposerDraftInput: View { let chatSummary: WorkChatSummaryRenderContext let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String - let pendingInputCount: Int let awaitingInputGate: Bool let composerPlaceholder: String let canCompose: Bool @@ -1692,7 +1670,6 @@ private struct WorkChatComposerDraftInput: View { if !isDictating { WorkComposerChipStrip( chatSummary: chatSummary, - pendingInputCount: pendingInputCount, settingsMutationInFlight: settingsMutationInFlight, codexFastModeOverride: codexFastModeOverride, onOpenModelPicker: onOpenModelPicker, diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index ec2f67ed4..0560265a5 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -68,7 +68,11 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { ) case .activity(let activity, let detail, let turnId): return .activity(kind: activity.rawValue, detail: detail, turnId: turnId) - case .plan(let steps, let explanation, let turnId): + // Labeled bindings on purpose: AgentChatEvent.plan orders (steps, turnId, + // explanation) while WorkChatEvent.plan orders (steps, explanation, turnId). + // A positional match here silently swaps turnId/explanation — the turn id + // renders as the plan body and per-delta cards stop merging. + case .plan(steps: let steps, turnId: let turnId, explanation: let explanation): let mapped = steps.map { WorkPlanStep(text: $0.text, status: $0.status) } return .plan(steps: mapped, explanation: explanation, turnId: turnId) case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 83d7e075b..964ff4bf1 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -633,6 +633,19 @@ struct WorkEventCardModel: Identifiable, Equatable { /// in-progress affordance (spinner + "Compacting context…"); once the host /// emits the completed event the merged card flips this back to false. let isInProgress: Bool + /// Structured question payload for `kind == "question"`, so the resolved + /// transcript card can render the provider logo, the question text, and clean + /// option rows (with the recommended/selected option marked) instead of the + /// flat "Extras: … Options: …" bullet dump. + let questionModel: WorkPendingQuestionModel? + /// Structured plan payload for `kind == "planApproval"`, so the resolved card + /// can render a markdown preview + expand-to-sheet instead of per-line bullets. + let planApprovalModel: WorkPendingPlanApprovalModel? + /// Resolution word (`accepted` / `declined` / `cancelled` / a chosen value) + /// joined from the matching `pending_input_resolved` event. Lets the question + /// and plan cards fold the resolved state inline and drop the separate + /// floating "Input resolved · Accepted" ribbon. + let resolution: String? init( id: String, @@ -645,7 +658,10 @@ struct WorkEventCardModel: Identifiable, Equatable { bullets: [String], metadata: [String], planSteps: [WorkPlanStep] = [], - isInProgress: Bool = false + isInProgress: Bool = false, + questionModel: WorkPendingQuestionModel? = nil, + planApprovalModel: WorkPendingPlanApprovalModel? = nil, + resolution: String? = nil ) { self.id = id self.kind = kind @@ -658,6 +674,9 @@ struct WorkEventCardModel: Identifiable, Equatable { self.metadata = metadata self.planSteps = planSteps self.isInProgress = isInProgress + self.questionModel = questionModel + self.planApprovalModel = planApprovalModel + self.resolution = resolution } } diff --git a/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift index 0b64972f2..a57d63e69 100644 --- a/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift +++ b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift @@ -278,6 +278,257 @@ struct WorkPlanComposerStrip: View { } } +/// Pinned tool / file-change approval badge shown directly above the composer, +/// mirroring `WorkPlanComposerStrip`. Collapsed row = provider logo + "{Provider} +/// · Approval" + Accept / Decline; tapping the header opens a detail sheet that +/// adds an "Accept all for session" action and the raw request detail. Wired to +/// the same `onApproveRequest` path the plan badge uses (accept / accept_for_session +/// / decline). +struct WorkApprovalComposerStrip: View { + let approval: WorkPendingApprovalModel + let busy: Bool + var fallbackProvider: String? = nil + let onDecision: @MainActor (AgentChatApprovalDecision) async -> Void + + @State private var detailPresented = false + + private var accent: Color { ADEColor.providerChatAccent(for: fallbackProvider) } + private var providerName: String { workChatSurfaceProviderName(fallbackProvider) } + + var body: some View { + compactRow + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.cardBackground.opacity(0.76), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(accent.opacity(0.22), lineWidth: 1) + ) + .overlay(alignment: .top) { + WorkPlanAccentGradient(accent: accent) + .padding(.horizontal, 12) + } + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .sheet(isPresented: $detailPresented) { + WorkApprovalDetailSheet( + approval: approval, + busy: busy, + fallbackProvider: fallbackProvider, + onDecision: onDecision + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("\(providerName) approval requested. \(approval.description)") + } + + private var compactRow: some View { + HStack(spacing: 8) { + Button { + detailPresented = true + } label: { + HStack(spacing: 7) { + WorkProviderBareLogo( + provider: fallbackProvider, + fallbackSymbol: providerIcon(fallbackProvider ?? ""), + tint: accent, + size: 16 + ) + VStack(alignment: .leading, spacing: 1) { + Text("\(providerName) · Approval") + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + .lineLimit(1) + Text(approval.description) + .font(.caption2) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.tail) + } + Image(systemName: "chevron.up") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(accent.opacity(0.55)) + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Review approval details and more options.") + .accessibilityHint("Opens the approval detail sheet.") + + WorkApprovalCompactActionButton( + title: "Accept", + icon: "checkmark", + tint: ADEColor.success, + filled: true, + accessibilityLabel: "Accept and allow this action" + ) { + Task { await onDecision(.accept) } + } + .disabled(busy) + + WorkApprovalCompactActionButton( + title: "Decline", + icon: "xmark", + tint: ADEColor.danger, + filled: false, + accessibilityLabel: "Decline this action" + ) { + Task { await onDecision(.decline) } + } + .disabled(busy) + } + } +} + +/// Compact capsule button matching the plan badge's action buttons — used by the +/// collapsed approval strip. +private struct WorkApprovalCompactActionButton: View { + let title: String + let icon: String + let tint: Color + let filled: Bool + let accessibilityLabel: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 9, weight: .bold)) + Text(title) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + } + .foregroundStyle(filled ? .white : tint) + .padding(.horizontal, 8) + .padding(.vertical, 5) + .background( + filled ? tint.opacity(0.86) : tint.opacity(0.08), + in: Capsule(style: .continuous) + ) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(filled ? 0.18 : 0.28), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityLabel) + } +} + +/// Expanded approval sheet: full request detail plus the three decision actions, +/// including "Accept all for session" which doesn't fit the collapsed strip. +struct WorkApprovalDetailSheet: View { + let approval: WorkPendingApprovalModel + let busy: Bool + var fallbackProvider: String? = nil + let onDecision: @MainActor (AgentChatApprovalDecision) async -> Void + + @Environment(\.dismiss) private var dismiss + + private var accent: Color { ADEColor.providerChatAccent(for: fallbackProvider) } + private var providerName: String { workChatSurfaceProviderName(fallbackProvider) } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 7) { + WorkProviderBareLogo( + provider: fallbackProvider, + fallbackSymbol: providerIcon(fallbackProvider ?? ""), + tint: accent, + size: 18 + ) + Text("\(providerName) · Approval") + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + } + + Text(approval.description) + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + if let detail = approval.detail, !detail.isEmpty { + WorkStructuredOutputBlock(title: "Request detail", text: detail) + } + + VStack(spacing: 10) { + actionButton(title: "Accept", icon: "checkmark", tint: ADEColor.success, filled: true) { + await onDecision(.accept) + } + actionButton(title: "Accept all for session", icon: "checkmark.circle", tint: accent, filled: false) { + await onDecision(.acceptForSession) + } + actionButton(title: "Decline", icon: "xmark", tint: ADEColor.danger, filled: false) { + await onDecision(.decline) + } + } + } + .padding(20) + } + .scrollIndicators(.hidden) + .background(workChatCanvasBackground.ignoresSafeArea()) + .navigationTitle("Approval") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + .accessibilityLabel("Dismiss approval") + } + } + } + } + + private func actionButton( + title: String, + icon: String, + tint: Color, + filled: Bool, + action: @escaping @MainActor () async -> Void + ) -> some View { + Button { + Task { + await action() + dismiss() + } + } label: { + HStack(spacing: 6) { + Image(systemName: icon) + .font(.system(size: 12, weight: .bold)) + Text(title) + .font(.subheadline.weight(.semibold)) + Spacer(minLength: 0) + } + .foregroundStyle(filled ? .white : tint) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity) + .background( + filled ? tint.opacity(0.86) : tint.opacity(0.08), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(tint.opacity(filled ? 0.20 : 0.28), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel(title) + } +} + struct WorkPlanFullScreenView: View { let plan: WorkPendingPlanApprovalModel var fallbackProvider: String? = nil diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index a570623ff..7a88e87be 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -308,6 +308,17 @@ struct WorkSessionDestinationView: View { var navigationTitleOverride: String? /// Lanes forwarded to the chat composer for `@`-mention autocomplete. var lanes: [LaneSummary] = [] + /// Set when this chat is opened from the hub as a cross-project "quick look": + /// the session lives in a project OTHER than the phone's active one and + /// streams read-only without a project switch. Nil for the ordinary + /// same-project path. When set, transcript/streaming/sends route to that + /// project (via the sync scope registered on appear) and active-project-only + /// affordances (local DB row observation, lane presence, PR badges, proof + /// artifacts) are gated off — the existing full-detail path is untouched. + var crossProjectContext: WorkChatCrossProjectContext? + + /// Whether this view is a cross-project "quick look" (see `crossProjectContext`). + var isCrossProject: Bool { crossProjectContext != nil } @State var session: TerminalSessionSummary? @State var chatSummary: AgentChatSessionSummary? @@ -787,6 +798,16 @@ struct WorkSessionDestinationView: View { Text("Give this session a clearer title for search, pinning, and activity tracking.") } .task { + // Cross-project "quick look": register the foreign scope BEFORE load() + // so every transcript/summary/send routes to that project without + // switching the phone's active project. + if let crossProjectContext { + syncService.setCrossProjectChatScope( + sessionId: sessionId, + projectId: crossProjectContext.projectId, + projectRootPath: crossProjectContext.projectRootPath + ) + } mainChatRenderEpoch = 0 liveTranscriptCache.reset(sessionId: sessionId) resetTranscriptHistoryState() @@ -798,7 +819,11 @@ struct WorkSessionDestinationView: View { await load() await sendInitialOpeningPromptIfNeeded() refreshSubagentSnapshots() - await refreshRemoteSubagentSnapshots() + // Remote subagent probing hits the host; skip the eager pass for a + // cross-project quick look (the drawer still loads it on demand). + if !isCrossProject { + await refreshRemoteSubagentSnapshots() + } } .task(id: liveChatObservationKey) { syncTranscriptFromLiveEvents() @@ -808,6 +833,10 @@ struct WorkSessionDestinationView: View { await hydrateEmptyTranscriptFromHostIfNeeded() } .task(id: sessionRowObservationKey) { + // A cross-project quick look has no local DB row for this session (only + // the active project is mirrored) — status comes from the streamed + // chat summary / turn hint instead. + guard !isCrossProject else { return } // Session rows arrive through CRDT-backed local DB updates, not chat // event streams. Observe work projection changes without also poking // proof refresh state on every normal chat revision. @@ -823,6 +852,10 @@ struct WorkSessionDestinationView: View { await refreshSessionRowFromLocalStore() } .task(id: artifactObservationKey) { + // Proof artifacts are served from the active project's projection; a + // cross-project quick look has no access to the foreign project's proof + // drawer, so leave it empty rather than querying the wrong project. + guard !isCrossProject else { return } // Proof rows arrive through their own projection. Keep this separate // from work row refreshes so live chat deltas don't churn artifact // loading state. @@ -834,6 +867,9 @@ struct WorkSessionDestinationView: View { await syncLanePresence() } .task(id: headerMenuPrLookupKey) { + // PR + lane presence lookups read the active project's caches; skip + // them for a cross-project quick look (the header hides lane/PR actions). + guard !isCrossProject else { return } await resolveLaneOpenPr(for: headerMenuLaneId) await loadPrCreateCapabilitiesIfNeeded() } @@ -856,9 +892,16 @@ struct WorkSessionDestinationView: View { self.announcedLaneId = nil } cleanupLoadedArtifactContent() + let wasCrossProject = isCrossProject Task { try? await syncService.unsubscribeFromChatEvents(sessionId: sessionId) } + // Drop the foreign routing so a later same-session open (e.g. after + // activating the project) uses the normal active-project path. The + // unsubscribe above doesn't need the scope (the host keys off sessionId). + if wasCrossProject { + syncService.clearCrossProjectChatScope(sessionId: sessionId) + } } } @@ -1045,6 +1088,9 @@ struct WorkSessionDestinationView: View { @MainActor func syncLanePresence() async { + // Lane presence is an active-project concern; a cross-project quick look + // must not announce itself into a foreign project's lane presence. + guard !isCrossProject else { return } guard showsLaneActions else { return } guard let laneId = session?.laneId ?? initialSession?.laneId else { return } guard announcedLaneId != laneId else { return } @@ -1064,10 +1110,20 @@ struct WorkSessionDestinationView: View { do { if let fetchedSession = try await syncService.fetchSession(id: sessionId) { session = fetchedSession + } else if session == nil, initialSession == nil, isLive, hostReachable { + // A chat opened straight from the hub — e.g. just created into a + // project activated in place — may not have its local session row yet + // (created on the host, still in flight over the changeset stream). + // Hydrate it from the host instead of flashing "Session unavailable". + if let hydrated = await syncService.ensureSessionRowHydrated(sessionId: sessionId) { + session = hydrated + } } lastSessionRowRefreshAt = Date() await refreshChatSummaryFromHost() - if !syncService.prefersReducedSyncLoad { + // Proof artifacts are active-project-scoped; skip for a cross-project + // quick look (the drawer stays empty rather than querying the wrong project). + if !isCrossProject && !syncService.prefersReducedSyncLoad { await refreshArtifacts(force: true) } await loadTranscript(forceRemote: shouldHydrateTranscriptFromHost, preferLightweight: syncService.prefersReducedSyncLoad) @@ -1514,6 +1570,9 @@ struct WorkSessionDestinationView: View { @MainActor func refreshArtifacts(force: Bool) async { + // Proof artifacts live in the active project's projection; a cross-project + // quick look has no access to the foreign project's proof drawer. + guard !isCrossProject else { return } guard let currentSession = session ?? initialSession, isChatSession(currentSession) else { return } @@ -2055,6 +2114,7 @@ extension WorkSessionDestinationView: Equatable { && lhs.showsLaneActions == rhs.showsLaneActions && lhs.navigationTitleOverride == rhs.navigationTitleOverride && workLaneListRenderSignature(lhs.lanes) == workLaneListRenderSignature(rhs.lanes) + && lhs.crossProjectContext == rhs.crossProjectContext } } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 14314488f..e82e249c9 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -43,18 +43,17 @@ func workSessionDeepLink(sessionId: String, laneId: String?) -> String { return "ade://session/\(encodedSessionId)?lane=\(encodedLaneId)" } +/// Whether the composer gates freeform typing behind a structured reply. Only a +/// live pending-input card (question / plan / permission) blocks — a bare +/// `awaiting-input` session status with no derived pending input does NOT lock +/// the composer, since that state can transiently lag after a plan is approved +/// (pending inputs clear a beat before the status does) and the user should keep +/// typing normally through it. func workChatComposerBlocksFreeformInput(pendingInputCount: Int, sessionStatus: String) -> Bool { - pendingInputCount > 0 || sessionStatus == "awaiting-input" -} - -func workChatAwaitingPromptDetailsMissing(pendingInputCount: Int, sessionStatus: String) -> Bool { - pendingInputCount == 0 && sessionStatus == "awaiting-input" + pendingInputCount > 0 } func workChatComposerPlaceholder(pendingInputCount: Int, sessionStatus: String) -> String { - if workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputCount, sessionStatus: sessionStatus) { - return "Waiting for prompt details..." - } if workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputCount, sessionStatus: sessionStatus) { return "Answer the prompt above..." } @@ -62,9 +61,6 @@ func workChatComposerPlaceholder(pendingInputCount: Int, sessionStatus: String) } func workChatComposerPlaceholder(pendingInputs: [WorkPendingInputItem], sessionStatus: String) -> String { - if workChatAwaitingPromptDetailsMissing(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) { - return "Waiting for prompt details..." - } if pendingInputs.count == 1, case .planApproval = pendingInputs[0] { return "Review the plan above..." @@ -1056,6 +1052,9 @@ func noticeTitle(for kind: String) -> String { case "file_persist": return "File persistence" case "provider_health": return "Provider health" case "thread_error": return "Thread notice" + case "warning": return "Warning" + case "error": return "Error" + case "config": return "Configuration notice" default: return "System notice" } } @@ -1068,14 +1067,17 @@ func noticeIcon(for kind: String) -> String { case "file_persist": return "externaldrive.badge.checkmark" case "provider_health": return "waveform.path.ecg" case "thread_error": return "exclamationmark.bubble" + case "warning": return "exclamationmark.triangle" + case "error": return "xmark.octagon" + case "config": return "gearshape" default: return "info.circle" } } func noticeTint(for kind: String) -> ColorToken { switch kind { - case "auth", "thread_error": return .danger - case "rate_limit", "hook": return .warning + case "auth", "thread_error", "error": return .danger + case "rate_limit", "hook", "warning": return .warning case "provider_health": return .secondary default: return .accent } diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 76a042e85..e93c72a7f 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -1201,6 +1201,51 @@ func buildWorkFileChangeCards(from transcript: [WorkChatEnvelope]) -> [WorkFileC return order.compactMap { byId[$0] } } +/// Map each resolved pending-input `itemId` to its resolution word so the +/// question / plan / approval cards can render the outcome inline. When several +/// resolutions share an itemId the last one wins (a re-answer supersedes). +private func workPendingInputResolutions(from transcript: [WorkChatEnvelope]) -> [String: String] { + var result: [String: String] = [:] + for envelope in transcript { + guard case .pendingInputResolved(let itemId, let resolution, _) = envelope.event else { continue } + let trimmed = resolution.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + result[itemId] = trimmed + } + return result +} + +/// Set of pending-input `itemId`s whose resolution is rendered inline on a +/// question, plan-approval, or generic-approval card. Their standalone "Input +/// resolved" ribbon is folded away in `buildWorkEventCards`. Permission and +/// model-selection gates are excluded — they don't surface the resolution +/// inline, so their ribbon stays. +private func workResolvedInlineItemIds(from transcript: [WorkChatEnvelope]) -> Set { + var ids = Set() + for envelope in transcript { + switch envelope.event { + case .approvalRequest(let description, let detail, let itemId, _): + if pendingWorkModelSelectionFromApproval(description: description, detail: detail, itemId: itemId) != nil { + continue + } + if pendingWorkPlanApprovalFromApproval(description: description, detail: detail, itemId: itemId) != nil { + ids.insert(itemId) + } else if pendingWorkQuestionFromApproval(description: description, detail: detail, itemId: itemId) != nil { + ids.insert(itemId) + } else if pendingWorkPermissionFromApproval(description: description, detail: detail, itemId: itemId) != nil { + continue + } else { + ids.insert(itemId) + } + case .structuredQuestion(_, _, let itemId, _): + ids.insert(itemId) + default: + continue + } + } + return ids +} + func buildWorkEventCards( from transcript: [WorkChatEnvelope], suppressedItemIds: Set = [] @@ -1208,6 +1253,12 @@ func buildWorkEventCards( var byId: [String: WorkEventCardModel] = [:] var order: [String] = [] let terminalDoneTurnIds = workTerminalDoneTurnIds(from: transcript) + // Join `pending_input_resolved` events onto the question / plan / approval + // card they resolve so those cards can show the outcome inline. Any resolved + // itemId that lands on such a card gets its standalone "Input resolved" ribbon + // folded away (below) to avoid rendering the resolution twice. + let resolutionByItemId = workPendingInputResolutions(from: transcript) + let foldedResolutionItemIds = workResolvedInlineItemIds(from: transcript) for envelope in transcript { if !suppressedItemIds.isEmpty { switch envelope.event { @@ -1219,10 +1270,14 @@ func buildWorkEventCards( break } } + if case .pendingInputResolved(let itemId, _, _) = envelope.event, + foldedResolutionItemIds.contains(itemId) { + continue + } if redundantWorkTerminalStatus(envelope.event, terminalDoneTurnIds: terminalDoneTurnIds) { continue } - guard let card = eventCard(for: envelope) else { continue } + guard let card = eventCard(for: envelope, resolutionByItemId: resolutionByItemId) else { continue } if let existing = byId[card.id], let merged = mergedWorkEventCard(existing, with: card) { byId[card.id] = merged } else { @@ -1342,9 +1397,12 @@ private func approvalRequestEventCard( timestamp: String, description: String, detail: String?, - itemId: String + itemId: String, + resolution: String? = nil ) -> WorkEventCardModel { if let planApproval = pendingWorkPlanApprovalFromApproval(description: description, detail: detail, itemId: itemId) { + // The resolved plan card renders a markdown preview + expand-to-sheet from + // `planApprovalModel`, so we no longer split the plan into per-line bullets. return WorkEventCardModel( id: id, kind: "planApproval", @@ -1353,12 +1411,17 @@ private func approvalRequestEventCard( tint: .warning, timestamp: timestamp, body: nonEmptyWorkTimelineText(planApproval.title) ?? nonEmptyWorkTimelineText(description), - bullets: workTimelinePlanBullets(from: planApproval.planText), - metadata: nonEmptyWorkTimelineText(planApproval.source).map { [$0] } ?? [] + bullets: [], + metadata: nonEmptyWorkTimelineText(planApproval.source).map { [$0] } ?? [], + planApprovalModel: planApproval, + resolution: resolution ) } if let question = pendingWorkQuestionFromApproval(description: description, detail: detail, itemId: itemId) { + // The resolved question card renders provider logo + question text + option + // rows from `questionModel`, so title/body/bullets are only fallbacks for + // the (now-unused) generic path and accessibility text. return WorkEventCardModel( id: id, kind: "question", @@ -1366,9 +1429,11 @@ private func approvalRequestEventCard( icon: "questionmark.circle", tint: .warning, timestamp: timestamp, - body: workApprovalRequestBody(primary: question.title, secondary: question.body, fallback: description), - bullets: workTimelineQuestionBullets(from: question), - metadata: [] + body: nonEmptyWorkTimelineText(question.body), + bullets: [], + metadata: [], + questionModel: question, + resolution: resolution ) } @@ -1386,6 +1451,10 @@ private func approvalRequestEventCard( ) } + // Resolved generic / file-change approvals collapse to a compact chip + // (`WorkResolvedApprovalChip`) showing the description once + the outcome, so + // we drop the redundant detail bullets/metadata that used to print the + // description three times (title, body, and a bullet). return WorkEventCardModel( id: id, kind: "approval", @@ -1394,8 +1463,9 @@ private func approvalRequestEventCard( tint: .warning, timestamp: timestamp, body: nonEmptyWorkTimelineText(description), - bullets: genericApprovalDetailBullets(from: detail), - metadata: [] + bullets: [], + metadata: [], + resolution: resolution ) } @@ -1414,53 +1484,6 @@ private func workApprovalRequestBody(primary: String?, secondary: String?, fallb return pieces.prefix(2).joined(separator: "\n") } -private func workTimelineQuestionBullets(from model: WorkPendingQuestionModel) -> [String] { - model.questions.prefix(4).map { question in - let questionText = question.isSecret - ? "Secure response requested" - : (nonEmptyWorkTimelineText(question.question) ?? "Response requested") - var text = question.header.map { "\($0): \(questionText)" } ?? questionText - let options = question.options - .compactMap { nonEmptyWorkTimelineText($0.label) } - .prefix(4) - .joined(separator: ", ") - if !options.isEmpty { - text += " Options: \(options)" - } else if question.allowsFreeform { - text += " Freeform response allowed." - } - return truncatedWorkTimelineText(text, limit: 220) - } -} - -private func workTimelinePlanBullets(from planText: String) -> [String] { - planText - .components(separatedBy: .newlines) - .compactMap { raw -> String? in - let text = raw - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: #"^[-*]\s+"#, with: "", options: .regularExpression) - .replacingOccurrences(of: #"^\d+\.\s+"#, with: "", options: .regularExpression) - return nonEmptyWorkTimelineText(text) - } - .prefix(4) - .map { truncatedWorkTimelineText($0, limit: 180) } -} - -private func genericApprovalDetailBullets(from detail: String?) -> [String] { - guard let detail = nonEmptyWorkTimelineText(detail) else { return [] } - if let object = workJSONObject(from: detail) { - let request = object["request"] as? [String: Any] ?? object - return [ - optionalString(request["description"]), - optionalString(request["tool"]) ?? optionalString(request["toolName"]), - ] - .compactMap(nonEmptyWorkTimelineText) - .map { truncatedWorkTimelineText($0, limit: 180) } - } - return [truncatedWorkTimelineText(detail, limit: 240)] -} - private func nonEmptyWorkTimelineText(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -1540,7 +1563,10 @@ func workPendingInputTimestamps(from transcript: [WorkChatEnvelope]) -> [String: return result } -private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { +private func eventCard( + for envelope: WorkChatEnvelope, + resolutionByItemId: [String: String] = [:] +) -> WorkEventCardModel? { switch envelope.event { case .activity: // Activity events ("searching_glob", "running_bash", etc.) are pre-tool @@ -1584,7 +1610,8 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { timestamp: envelope.timestamp, description: description, detail: detail, - itemId: itemId + itemId: itemId, + resolution: resolutionByItemId[itemId] ) case .pendingInputResolved(_, let resolution, _): return WorkEventCardModel( @@ -1598,7 +1625,18 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { bullets: [], metadata: [pendingInputResolutionLabel(for: resolution)] ) - case .structuredQuestion(let question, let options, _, _): + case .structuredQuestion(let question, let options, let itemId, _): + let questionModel = WorkPendingQuestionModel( + id: itemId, + questions: [ + WorkPendingQuestion( + questionId: "response", + question: question, + options: options, + allowsFreeform: options.isEmpty + ) + ] + ) return WorkEventCardModel( id: envelope.id, kind: "question", @@ -1608,7 +1646,9 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { timestamp: envelope.timestamp, body: question, bullets: options.map { $0.label }, - metadata: [] + metadata: [], + questionModel: questionModel, + resolution: resolutionByItemId[itemId] ) case .todoUpdate(let items, _): return WorkEventCardModel( diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 429272db9..ac47332e4 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -2102,6 +2102,88 @@ final class ADETests: XCTestCase { XCTAssertEqual(turnId, "turn-1") } + func testSystemNoticeDecodesSeverityKindsAndFallsBackForUnknown() throws { + // The host emits noticeKind "warning"/"error"/"config"; the phone must decode + // them rather than throw. Unknown future kinds fall back to `.info`. + for (raw, expected): (String, AgentChatNoticeKind) in [ + ("warning", .warning), + ("error", .error), + ("config", .config), + ("some_future_kind", .info), + ] { + let json = """ + { + "sessionId": "s", + "timestamp": "2026-03-17T00:00:00.000Z", + "event": { "type": "system_notice", "noticeKind": "\(raw)", "message": "m" } + } + """ + let envelope = try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(json.utf8)) + guard case .systemNotice(let kind, _, _, _, _) = envelope.event else { + return XCTFail("Expected system notice for raw kind \(raw).") + } + XCTAssertEqual(kind, expected, "noticeKind \(raw) should decode to \(expected)") + } + } + + func testChatEventHistorySnapshotSurvivesWarningNoticeAlongsidePlanApproval() throws { + // Regression: a single `system_notice` with an out-of-enum noticeKind used to + // throw during the strict `[AgentChatEventEnvelope]` array decode, discarding + // the whole snapshot — including the pending plan-approval — and stranding the + // phone on "Waiting for prompt details from the machine." + let json = """ + { + "sessionId": "chat-1", + "events": [ + { + "sessionId": "chat-1", + "timestamp": "2026-03-25T00:00:00.000Z", + "sequence": 1, + "event": { "type": "system_notice", "noticeKind": "warning", "message": "heads up" } + }, + { + "sessionId": "chat-1", + "timestamp": "2026-03-25T00:00:01.000Z", + "sequence": 2, + "event": { + "type": "approval_request", + "itemId": "plan-1", + "kind": "tool_call", + "description": "Plan ready", + "turnId": "turn-1", + "detail": { + "request": { + "kind": "plan_approval", + "source": "codex", + "title": "Plan Ready for Review", + "questions": [ + { "id": "plan_decision", "question": "## Plan\\n1. Ship it." } + ] + } + } + } + }, + { + "sessionId": "chat-1", + "timestamp": "2026-03-25T00:00:02.000Z", + "sequence": 3, + "event": { "type": "done", "turnId": "turn-1", "status": "completed" } + } + ], + "truncated": false + } + """ + + let snapshot = try JSONDecoder().decode(AgentChatEventHistorySnapshot.self, from: Data(json.utf8)) + XCTAssertEqual(snapshot.events.count, 3, "No event should be dropped by the array decode.") + + let transcript = makeWorkChatTranscript(from: snapshot.events) + let pendingInputs = derivePendingWorkInputs(from: transcript) + guard case .planApproval = pendingInputs.first else { + return XCTFail("Expected the plan approval to survive the completed turn.") + } + } + func testAgentChatEventEnvelopeDecodesTokenUsageEvent() throws { let json = """ { @@ -6501,44 +6583,113 @@ final class ADETests: XCTestCase { XCTAssertEqual(try? results[2].result.get(), "lane-c-done") } - func testLaneCardRebaseWarningPrefersAutoRebaseStatusOverSuggestion() { - var snapshot = makeLaneListSnapshot( - id: "lane-rebase", - name: "iOS simulator", - laneType: "worktree", - baseRef: "main", - branchRef: "ade/ios-sim", - worktreePath: "/project/.ade/worktrees/ios-sim", - description: nil, - status: LaneStatus(dirty: false, ahead: 0, behind: 2, remoteBehind: 0, rebaseInProgress: false), - runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), - createdAt: "2026-03-20T00:00:00.000Z", - archivedAt: nil + func testLaneStackCardRenderSignatureFlipsForEveryRenderedField() throws { + // Guards the hand-listed field set in laneStackCardRenderSignature: the + // card's Equatable gates SwiftUI re-render on this hash, so a rendered + // field missing from it means silent under-invalidation (stale rows). + func makeSnapshot() -> LaneListSnapshot { + makeLaneListSnapshot( + id: "lane-sig", + name: "Signature lane", + laneType: "worktree", + baseRef: "main", + branchRef: "ade/signature-lane", + worktreePath: "/project/.ade/worktrees/signature-lane", + description: nil, + status: LaneStatus(dirty: false, ahead: 1, behind: 2, remoteBehind: 0, rebaseInProgress: false), + runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), + createdAt: "2026-03-20T00:00:00.000Z", + archivedAt: nil + ) + } + func signature( + _ snapshot: LaneListSnapshot, + isPinned: Bool = false, + isOpen: Bool = false, + depth: Int = 0, + pullRequest: LanePrTag? = nil, + isSelectedTransitionSource: Bool = false + ) -> Int { + laneStackCardRenderSignature( + snapshot: snapshot, + isPinned: isPinned, + isOpen: isOpen, + depth: depth, + pullRequest: pullRequest, + isSelectedTransitionSource: isSelectedTransitionSource + ) + } + + let base = signature(makeSnapshot()) + + // These models are decode-only in the app (no memberwise call sites), so + // build fixtures the same way production data arrives. + let decoder = JSONDecoder() + let issue = try decoder.decode( + LaneLinearIssue.self, + from: Data(#"{"id":"iss-1","identifier":"ADE-123","title":"Issue"}"#.utf8) + ) + let linkJSON = #""" + {"id":"link-1","laneId":"lane-sig","role":"primary","source":"manual", + "includeInPr":true,"closeOnMerge":false, + "createdAt":"2026-03-20T00:00:00.000Z","updatedAt":"2026-03-20T00:00:00.000Z", + "issue":{"id":"iss-1","identifier":"ADE-123","title":"Issue"}} + """# + let link = try decoder.decode(LaneLinearIssueLink.self, from: Data(linkJSON.utf8)) + var secondLink = link + secondLink.id = "link-2" + secondLink.issue.id = "iss-2" + secondLink.issue.identifier = "ADE-124" + + let laneMutations: [(String, (inout LaneListSnapshot) -> Void)] = [ + ("id", { $0.lane.id = "lane-sig-2" }), + ("name", { $0.lane.name = "Renamed lane" }), + ("color", { $0.lane.color = "#ff00ff" }), + ("icon", { $0.lane.icon = .bolt }), + ("laneType", { $0.lane.laneType = "attached" }), + ("archivedAt", { $0.lane.archivedAt = "2026-03-21T00:00:00.000Z" }), + ("branchRef", { $0.lane.branchRef = "ade/other-branch" }), + ("status.dirty", { $0.lane.status.dirty = true }), + ("status.ahead", { $0.lane.status.ahead = 5 }), + ("status.behind", { $0.lane.status.behind = 7 }), + ("childCount", { $0.lane.childCount = 3 }), + ("devicesOpen", { $0.lane.devicesOpen = [DeviceMarker(deviceId: "d1", displayName: "Phone", platform: "ios")] }), + ("linearIssue", { $0.lane.linearIssue = issue }), + ("linearIssueLinks", { $0.lane.linearIssueLinks = [link, secondLink] }), + ] + for (field, mutate) in laneMutations { + var mutated = makeSnapshot() + mutate(&mutated) + XCTAssertNotEqual(signature(mutated), base, "renderSignature must change when \(field) changes") + } + + XCTAssertNotEqual(signature(makeSnapshot(), isPinned: true), base, "renderSignature must change when isPinned changes") + XCTAssertNotEqual(signature(makeSnapshot(), isOpen: true), base, "renderSignature must change when isOpen changes") + XCTAssertNotEqual(signature(makeSnapshot(), depth: 2), base, "renderSignature must change when depth changes") + XCTAssertNotEqual( + signature(makeSnapshot(), isSelectedTransitionSource: true), + base, + "renderSignature must change when isSelectedTransitionSource changes" ) - snapshot.rebaseSuggestion = RebaseSuggestion( - laneId: "lane-rebase", - parentLaneId: "lane-main", - parentHeadSha: "parent-sha", - behindCount: 2, - lastSuggestedAt: "2026-03-20T00:01:00.000Z", - deferredUntil: nil, - dismissedAt: nil, - hasPr: true + let pr = LanePrTag( + source: .github, + prId: nil, + githubPrNumber: 42, + githubUrl: "https://github.com/org/repo/pull/42", + title: "PR", + state: "open", + headBranch: "ade/signature-lane", + updatedAt: "2026-03-20T01:00:00.000Z" ) - snapshot.autoRebaseStatus = AutoRebaseLaneStatus( - laneId: "lane-rebase", - parentLaneId: "lane-main", - parentHeadSha: "parent-sha", - state: "rebaseConflict", - updatedAt: "2026-03-20T00:02:00.000Z", - conflictCount: 3, - message: "Resolve conflicts in the Rebase/Merge tab." + let withPr = signature(makeSnapshot(), pullRequest: pr) + XCTAssertNotEqual(withPr, base, "renderSignature must change when a PR appears") + var mergedPr = pr + mergedPr.state = "merged" + XCTAssertNotEqual( + signature(makeSnapshot(), pullRequest: mergedPr), + withPr, + "renderSignature must change when the PR state changes" ) - - let warning = laneCardRebaseWarningPresentation(for: snapshot) - - XCTAssertEqual(warning, .autoRebase(state: "rebaseConflict", message: "Resolve conflicts in the Rebase/Merge tab.")) - XCTAssertEqual(warning?.accessibilitySummary, "Auto-rebase conflict. Resolve conflicts in the Rebase/Merge tab.") } func testSelectLanePrTagPrefersOpenPrOnMatchingBranch() { @@ -6847,44 +6998,6 @@ final class ADETests: XCTestCase { ) } - func testLaneStackCardAccessibilityLabelIncludesRebaseWarningSummary() { - var snapshot = makeLaneListSnapshot( - id: "lane-warning", - name: "Sync polish", - laneType: "worktree", - baseRef: "main", - branchRef: "ade/sync-polish", - worktreePath: "/project/.ade/worktrees/sync-polish", - description: nil, - status: LaneStatus(dirty: false, ahead: 1, behind: 4, remoteBehind: 0, rebaseInProgress: false), - runtime: LaneRuntimeSummary(bucket: "ended", runningCount: 0, awaitingInputCount: 0, endedCount: 1, sessionCount: 1), - createdAt: "2026-03-20T00:00:00.000Z", - archivedAt: nil - ) - snapshot.rebaseSuggestion = RebaseSuggestion( - laneId: "lane-warning", - parentLaneId: "lane-main", - parentHeadSha: "parent-sha", - behindCount: 4, - lastSuggestedAt: "2026-03-20T00:01:00.000Z", - deferredUntil: nil, - dismissedAt: nil, - hasPr: true - ) - let warning = laneCardRebaseWarningPresentation(for: snapshot) - - let label = laneStackCardAccessibilityLabel( - snapshot: snapshot, - isPinned: false, - isOpen: true, - rebaseWarning: warning - ) - - XCTAssertTrue(label.contains("Rebase suggested")) - XCTAssertTrue(label.contains("4 commits behind")) - XCTAssertTrue(label.contains("PR open")) - } - func testLaneDetailRebaseBannerAccessibilityLabelIncludesVisibleBadges() { XCTAssertEqual( laneDetailRebaseBannerAccessibilityLabel(behindCount: 1, parentLabel: "main", hasPr: true), @@ -6892,49 +7005,6 @@ final class ADETests: XCTestCase { ) } - func testCompactSyncSummaryUsesRemoteUpstreamState() { - XCTAssertEqual(compactSyncSummary(nil), "Checking remote") - XCTAssertEqual( - compactSyncSummary( - GitUpstreamSyncStatus( - hasUpstream: true, - upstreamRef: "origin/feature", - ahead: 0, - behind: 0, - diverged: false, - recommendedAction: "none" - ) - ), - "In sync with remote" - ) - XCTAssertEqual( - compactSyncSummary( - GitUpstreamSyncStatus( - hasUpstream: true, - upstreamRef: "origin/feature", - ahead: 2, - behind: 0, - diverged: false, - recommendedAction: "push" - ) - ), - "2 ahead remote" - ) - XCTAssertEqual( - compactSyncSummary( - GitUpstreamSyncStatus( - hasUpstream: false, - upstreamRef: nil, - ahead: 0, - behind: 0, - diverged: false, - recommendedAction: "publish" - ) - ), - "No upstream" - ) - } - func testLaneRootEmptyStateGuidesUnpairedUsersWhenNoCacheExists() { let emptyState = laneRootEmptyState( connectionState: .disconnected, @@ -8747,13 +8817,18 @@ final class ADETests: XCTestCase { XCTAssertEqual(normalizedWorkChatSessionStatus(session: crdtOnlySession, summary: staleCompletedSummary), "awaiting-input") } - func testWorkChatComposerPlaceholderDistinguishesMissingPromptDetails() { - XCTAssertTrue(workChatComposerBlocksFreeformInput(pendingInputCount: 0, sessionStatus: "awaiting-input")) - XCTAssertTrue(workChatAwaitingPromptDetailsMissing(pendingInputCount: 0, sessionStatus: "awaiting-input")) + func testWorkChatComposerStaysUnlockedWhenAwaitingInputHasNoPendingCard() { + // A bare `awaiting-input` status with no derived pending input must NOT lock + // the composer or show a "waiting for prompt details" placeholder — that + // state transiently lags right after a plan is approved, and the user should + // keep typing normally through it. + XCTAssertFalse(workChatComposerBlocksFreeformInput(pendingInputCount: 0, sessionStatus: "awaiting-input")) XCTAssertEqual( workChatComposerPlaceholder(pendingInputCount: 0, sessionStatus: "awaiting-input"), - "Waiting for prompt details..." + "Type to vibecode..." ) + // A real pending input still gates freeform typing behind the structured card. + XCTAssertTrue(workChatComposerBlocksFreeformInput(pendingInputCount: 1, sessionStatus: "awaiting-input")) XCTAssertEqual( workChatComposerPlaceholder(pendingInputCount: 1, sessionStatus: "awaiting-input"), "Answer the prompt above..." @@ -13760,9 +13835,20 @@ final class ADETests: XCTestCase { ) let questionCard = snapshot.eventCards.first { $0.kind == "question" } XCTAssertEqual(questionCard?.title, "Question asked") - XCTAssertEqual(questionCard?.body, "Mobile question fixture\nPick a mobile verification path.") - XCTAssertEqual(questionCard?.bullets.first, "Flow: Which Work prompt flow should continue? Options: Question flow, Approval flow") - XCTAssertEqual(questionCard?.bullets.last, "Notes: Add an optional note for the mobile audit. Freeform response allowed.") + // The redesigned resolved-question card carries the structured question so + // the view can render provider logo + option rows instead of a raw + // "Flow: … Options: …" bullet dump. + XCTAssertTrue(questionCard?.bullets.isEmpty ?? false, "Resolved question must not dump options into bullets.") + let questionModel = questionCard?.questionModel + XCTAssertEqual(questionModel?.questions.count, 2) + XCTAssertEqual(questionModel?.questions.first?.question, "Which Work prompt flow should continue?") + XCTAssertEqual(questionModel?.questions.first?.options.map(\.label), ["Question flow", "Approval flow"]) + XCTAssertEqual(questionModel?.questions.first?.options.first?.value, "question_flow") + XCTAssertEqual(questionModel?.questions.last?.question, "Add an optional note for the mobile audit.") + // The resolution is joined onto the card so it can show the outcome inline, + // and the standalone "Input resolved" ribbon is folded away. + XCTAssertEqual(questionCard?.resolution, "accepted") + XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "pendingInputResolved" }) XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "approval" }) XCTAssertFalse(snapshot.eventCards.flatMap { [$0.body ?? ""] + $0.bullets }.contains { $0.contains("{") || $0.contains("\"request\"") }) } @@ -13825,6 +13911,62 @@ final class ADETests: XCTestCase { XCTAssertFalse(snapshot.eventCards.flatMap { [$0.body ?? ""] + $0.bullets }.contains { $0.contains("{") || $0.contains("\"request\"") }) } + func testFileChangeApprovalDerivesAsApprovalPendingInput() { + // Codex file-change approval: request.kind == "approval", no options. It must + // derive as a `.approval` pending input so the composer-pinned badge renders. + let detail = """ + {"grantRoot":null,"reason":null,"request":{"requestId":"3","itemId":"call_abc","source":"codex","kind":"approval","description":"Approve file changes","questions":[],"allowsFreeform":false,"blocking":true}} + """ + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-02T20:14:18.125Z", + sequence: 1, + event: .approvalRequest(description: "Approve file changes", detail: detail, itemId: "call_abc", turnId: "t-1") + ), + ] + let pendingInputs = derivePendingWorkInputs(from: transcript) + guard case .approval(let approval)? = pendingInputs.first else { + return XCTFail("Expected a .approval pending input for the file-change gate.") + } + XCTAssertEqual(approval.id, "call_abc") + XCTAssertEqual(approval.description, "Approve file changes") + } + + func testResolvedFileChangeApprovalCollapsesToCompactChipAndFoldsRibbon() { + // Dedupe: the resolved file-change approval must carry its description once + // (no redundant bullet/metadata) and fold the standalone "Input resolved" row. + let detail = """ + {"grantRoot":null,"reason":null,"request":{"requestId":"3","itemId":"call_abc","source":"codex","kind":"approval","description":"Approve file changes","questions":[],"allowsFreeform":false,"blocking":true}} + """ + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-02T20:14:18.125Z", + sequence: 1, + event: .approvalRequest(description: "Approve file changes", detail: detail, itemId: "call_abc", turnId: "t-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-02T20:14:25.000Z", + sequence: 2, + event: .pendingInputResolved(itemId: "call_abc", resolution: "accepted", turnId: "t-1") + ), + ] + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let approvalCard = snapshot.eventCards.first { $0.kind == "approval" } + XCTAssertEqual(approvalCard?.body, "Approve file changes") + XCTAssertTrue(approvalCard?.bullets.isEmpty ?? false, "Resolved approval must not repeat its description as a bullet.") + XCTAssertTrue(approvalCard?.metadata.isEmpty ?? false) + XCTAssertEqual(approvalCard?.resolution, "accepted") + XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "pendingInputResolved" }) + } + func testBuildWorkTimelineSuppressesRawToolCardWhenPermissionRequestIsPending() { let detail = """ {"request":{"itemId":"perm-1","kind":"permissions","tool":"functions.GitHub","description":"Allow GitHub MCP"}} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 63757955f..b1b801ded 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -896,12 +896,14 @@ The sync subsystem is **owned by the ADE runtime** (`apps/ade-cli/src/services/s - Opens WebSocket to host after racing all saved address candidates with concurrent TCP probes (happy eyeballs) — a dead LAN IP no longer delays the live Tailscale route. Sends local `db_version` plus the per-host-DB cursor map (`remoteDbVersionBySite`); host replies with its `serverDbSiteId` and sends catch-up changesets. - `hello_ok` can include the host's mobile project catalog and project-action feature flag. The iOS app shows a native project home 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. 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. +- 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. 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. 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. - All reads are local and scoped to the active project id — the iOS tab is instant and offline-capable after the selected project's row has hydrated. - Writes from user actions: write locally, replicate to host. Execution commands (create PR, run command) are routed to the host via the `command`/`command_ack`/`command_result` message flow. - Sub-protocols: changeset sync, project catalog/switch, file access, subscribed terminal stream/control, chat stream (live `chat_event` - push from host), command routing, and lane presence announce/release. + push from host, including read-only cross-project quick look), the + all-projects chat roster feed backing the Hub, command routing, and + lane presence announce/release. Command routing includes the Work CLI launcher (`work.startCliSession`), whose provider command construction is shared with the desktop Work tab through diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 2103af89a..eec7b726b 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -227,7 +227,11 @@ runtime guidance as an ordinary system-context input item and keeps `collaborationMode.settings.developer_instructions` null, then turns completed Codex `plan` items (including `` wrappers) into ADE plan-approval requests. Accepting that request moves the session to -edit/default mode and starts the implementation turn. +`full-auto`/default mode and starts the implementation turn — the user +already reviewed exactly what the plan will do, so `stageCodexPlanApprovalFollowup` +hands the session straight to full access rather than dropping to `edit` +and gating every file change behind another approval round that would just +relitigate the plan. Default Codex chats map to the "Default permissions" preset (`workspace-write` + `on-request`). The older implicit fallback that diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 0a9a3a848..c48b3ddaa 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -56,7 +56,7 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. It also emits one-shot `LaneLifecycleEvent` notifications after successful create/archive/delete transitions so renderer surfaces can toast completed lifecycle changes without polling. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. It also emits one-shot `LaneLifecycleEvent` notifications after successful create/archive/delete transitions so renderer surfaces can toast completed lifecycle changes without polling. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `upsertLaneStateSnapshot` guards its `lane_state_snapshots` write with a `where` clause that only touches the row when a field actually changed (`dirty`/`ahead`/`behind`/`remote_behind`/`rebase_in_progress`, and `agent_summary_json` only when the caller passed an `agentSummary`), so a status recompute that yields identical values no longer authors a redundant CRR row — which otherwise fans an empty update out to every synced device and triggers a full mobile lane-list reload for nothing. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). | | `worktreeResidualCleanup.ts` | Machine-local retry worker for managed worktree directories that survive lane deletion. It stores cleanup debt in `local_worktree_residual_cleanups`, retries during `laneService.list()`, drops unsafe records, skips registered Git worktrees, active lane paths, and pending creations, removes old empty untracked directories under the managed worktrees directory, and leaves unknown non-empty directories alone unless they were explicitly recorded from the delete path. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index e347cc46c..17848b47e 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -192,7 +192,14 @@ Canonical files (`apps/ade-cli/src/services/sync/`): coalesced flush after a remote command adds/removes a roster-visible lane or chat. The snapshot itself comes from an optional injected `SyncRosterProvider.buildSnapshot()`; a host without one (single-project - desktop) never answers `roster_subscribe`. + desktop) never answers `roster_subscribe`. It also takes an optional + `foreignChatProvider` (`SyncForeignChatTranscriptResolver`) that powers + cross-project chat quick-look: a `chat_subscribe` naming a registered + foreign project is resolved to that project's on-disk transcript path and + streamed read-only (byte-capped tail snapshot plus a disk-tailing live + pump, tracked per peer in `foreignChatTranscriptPaths`) with no runtime + boot; the presence of the provider is what flips the advertised + `crossProjectChat` hello feature flag. - `rosterBuilder.ts` — builds the machine-wide all-projects chat roster (`SyncRosterProject[]`) consumed by the Hub. Opens each project's `/.ade/ade.db` **read-only** with `node:sqlite` (no cr-sqlite, no @@ -200,7 +207,14 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `recentProjectSummary.ts`) and merges cached `chat-sessions/*.json`, so an all-projects feed never activates every project. Live running/awaiting status is overlaid only for scopes already booted on the runtime; previews - are hard-truncated (~120 chars). + are hard-truncated (~120 chars). Also exports + `createForeignChatTranscriptResolver({ projectRegistry })` — the resolver + behind cross-project chat quick-look and its security boundary: it maps a + `(registered foreign project, sessionId)` pair to that session's transcript + JSONL path, rejecting unsafe session ids and any path that would escape the + project's `.ade` transcripts dir, and returning null for unknown projects + (never booting a runtime). `ade serve` wires it as the host's + `foreignChatProvider`. - `sharedSyncListener.ts` — the brain-level WebSocket listener shared across per-project host services. Binds once (preferred-port retry: ~8 attempts over ~3.2 s on the saved port before falling back to a @@ -635,8 +649,8 @@ payload. | Changeset sync | Bidirectional cr-sqlite row exchange | All devices | | File access | On-demand project/worktree file reads, listings, writes | iOS Files, desktop remote viewing | | Terminal stream/control | Subscribe to PTY output from the runtime; send input bytes and viewport resize events back to the subscribed PTY | iOS Work tab | -| Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session); per-session history is evicted with a 64-session LRU so a phone that has opened many chats cannot pin unbounded host memory. `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host). The snapshot is a byte-capped tail: `chat_subscribe` also carries the client's `maxBytes`, and the host clamps the snapshot's `getChatEventHistory` budget to `min(host cap, maxBytes)` — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries `turnActive` from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint) | iOS Work tab, controller chat | -| Chat roster | Machine-wide all-projects projection of every project's lanes + chats-grouped-by-lane, so the mobile Hub renders every project's chats at once **without activating each project**. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their chat status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime. Transcripts are excluded (they load on demand when a chat opens, which activates that project's full sync). Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | +| Chat stream | Agent chat transcript events. Each `chat_event` carries a host-assigned per-session monotonic `seq` backed by a capped replay buffer (500 events / 2 MB per session); per-session history is evicted with a 64-session LRU so a phone that has opened many chats cannot pin unbounded host memory. `chat_subscribe` accepts `sinceSeq`: gaps the buffer covers replay as ordinary events; uncoverable gaps fall back to a snapshot, and a non-resumed ack tells the client to drop its stale seq watermark (seq epochs restart at 1 on a new host). The snapshot is a byte-capped tail: `chat_subscribe` also carries the client's `maxBytes`, and the host clamps the snapshot's `getChatEventHistory` budget to `min(host cap, maxBytes)` — for a mobile-sized budget even the newest oversize event is dropped rather than force-included, so a phone never receives a snapshot larger than it asked for. Snapshot events are marked as already-sent to that peer, so the follow-on live pump does not re-deliver the overlap. The ack also carries `turnActive` from the live agent chat service — because the snapshot is a byte-capped tail, a long turn's `status: started` event can fall outside the window and the flag is what lets a mid-turn subscriber render streaming/stop affordances without waiting on the changeset pump (a full ack without the flag tells the client to drop any latched hint). **Cross-project "quick look":** `chat_subscribe` / `chat_unsubscribe` accept an optional `projectId` / `projectRootPath` override. When it names a registered project OTHER than the socket's active one — and the host advertised the `crossProjectChat` feature flag — the host serves that session's transcript **read-only straight off the foreign project's `.ade` transcript JSONL** (byte-capped tail snapshot, then the pump tails the same file for live events), with no project switch and no runtime boot for that project. Such sessions have no live agent chat service here, so `turnActive` is omitted and the client derives turn state from the streamed `status` events. The transcript resolver is the security boundary — it validates the project is registered and confines the path to that project's transcripts dir. A host without a foreign-chat provider never sets `crossProjectChat`, so the phone falls back to a full project activation | iOS Work tab, iOS Hub, controller chat | +| Chat roster | Machine-wide all-projects projection of every project's lanes + chats-grouped-by-lane, so the mobile Hub renders every project's chats at once **without activating each project**. `roster_subscribe` (handshake mirrors `chat_subscribe`, with an optional `sinceSeq`) → `roster_snapshot` then incremental `roster_delta` (`changed` upserts whole project entries, `removed` lists dropped `projectId`s). Un-booted projects are read cheaply from disk — each project's `/.ade/ade.db` (read-only, no cr-sqlite / no runtime boot) plus `.ade/cache/chat-sessions/*.json` — so their chat status is limited to the last-persisted `idle`/`ended`/`awaiting`; live `running`/`awaiting` fidelity is overlaid only for scopes currently booted on the runtime. Transcripts are excluded from the roster (they load on demand when a chat opens): on a host advertising `crossProjectChat` the phone opens a foreign-project chat as a read-only cross-project quick look (see the Chat stream row) without activating that project; only on older hosts lacking the flag does opening the chat fall back to activating the project's full sync. Oversized snapshots ride the generic `envelope_chunk` path. A host without a roster provider (single-project desktop) simply never answers `roster_subscribe`, so the phone falls back to the active project only | iOS Hub | | Command routing | Send named actions (`chat.send`, `lanes.create`, `git.push`, `prs.getMobileSnapshot`, etc.) | Controller devices | | Project switching | `project_catalog` + `project_switch_request/result` for multi-project runtimes | iOS project home | | Project actions | Runtime-scoped project browser plus open/create/clone/list-GitHub-repos/default-parent-dir/forget envelopes. Available from the active project host or the machine-wide fallback handler before a project is selected | iOS project home | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index 569fbea1f..ba76f586f 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -113,13 +113,18 @@ apps/ios/ │ │ ├── Hub/ # HubScreen (all-projects roster home), │ │ │ # HubComponents (project/lane/chat cards, │ │ │ # HubNoMachineState), HubComposerDrawer -│ │ │ # (in-place new-chat), HubScreen+ChatNavigation +│ │ │ # (HubInlineComposer — inline keyboard +│ │ │ # composer, not a modal drawer), +│ │ │ # HubScreen+ChatNavigation (chat open + +│ │ │ # cross-project quick look) │ │ ├── Lanes/ # LaneDetailScreen, LaneActionsCard, +│ │ │ # LaneDetailSectionChrome (collapsible +│ │ │ # sections + wrapping chip-flow layout), │ │ │ # LaneDetailGitActionsPane (commit / │ │ │ # stage / stash / history / escape │ │ │ # hatches, desktop pane parity), │ │ │ # LaneBatchManageSheet, LaneManageSheet -│ │ │ # (tabbed manage dialog), +│ │ │ # (tabbed manage dialog + adopt-attached), │ │ │ # LaneMultiAttachSheet, LaneStackGraphSheet, │ │ │ # LaneDeeplinkHelpers (ade:// lane/branch │ │ │ # link minting), @@ -133,6 +138,11 @@ apps/ios/ │ │ ├── Work/ # WorkRootScreen, WorkChatSessionView, │ │ │ # Work*Helpers, WorkNewChatScreen (chat/CLI │ │ │ # launcher), WorkLanePickerDropdown, +│ │ │ # WorkChatRichCardViews (de-glassed +│ │ │ # tool-call / work-log / command / +│ │ │ # file-change transcript cards), +│ │ │ # WorkPlanComposerViews (plan-approval +│ │ │ # strip + review sheet), │ │ │ # WorkChatAttachmentTray, │ │ │ # WorkArtifactTerminalViews, │ │ │ # TerminalSessionScreen + SwiftTermSessionView @@ -393,7 +403,7 @@ Implemented envelope types on iOS: | `terminal_subscribe` / `terminal_unsubscribe` / `terminal_data` | Phone ↔ runtime | Terminal streaming; `unsubscribe` is sent when a Work terminal screen disappears so the phone stops accumulating buffer for off-screen sessions. `terminal_data` carries `offset` — the transcript's end byte offset after the chunk (null when the session has no transcript or hit the size cap) — so the phone can detect dropped chunks. `terminal_subscribe` accepts `sinceOffset`; when the runtime can serve exactly `sinceOffset → end` within the byte budget it replies with a `delta: true` snapshot (append, don't replace), giving exact back-fill after reconnects/gaps. Snapshots also report `startOffset`/`endOffset`, plus `live: false` when no PTY backs the session (ended, or orphaned by a brain restart while status still says running) so the phone shows a resume bar instead of silently accepting keystrokes | | `terminal_history` | Phone → runtime | On-demand scrollback paging: `{ sessionId, beforeOffset, maxBytes? }` returns transcript bytes `[startOffset, endOffset)` ending at/before `beforeOffset` (page start scanned forward to a newline/ESC boundary; `atStart: true` at beginning of transcript). Requires an active `terminal_subscribe` | | `terminal_input` / `terminal_resize` | Phone → runtime | Raw input bytes and viewport size changes for a subscribed live PTY. Mobile resizes are non-authoritative: the runtime records the last desktop-originated size and restores it when the last subscribed phone detaches | -| `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot. The subscribe ack carries `turnActive` from the live agent chat service so a phone subscribing mid-turn renders the stop button and working indicator immediately — the byte-capped snapshot tail may have dropped the turn's `status: started` event, and the synced session row arrives via the slower changeset pump. The phone keeps the hint current from live `status` / `done` events, drops it when a full ack omits the flag (older host / no live summary), and clears it on project switch / reconnect resets. Incoming chat events bump a UI revision through a leading-edge coalescer (~150 ms window: the first event after a quiet period renders immediately, bursts batch); turn-state flips bypass the coalescer entirely so the stop button reacts instantly | +| `chat_subscribe` / `chat_event` | Phone → runtime / runtime → phone | Agent chat transcript streaming; `chat_subscribe` carries `sinceSeq` so the runtime can replay exactly the missed events from its per-session buffer instead of re-sending a snapshot. The subscribe ack carries `turnActive` from the live agent chat service so a phone subscribing mid-turn renders the stop button and working indicator immediately — the byte-capped snapshot tail may have dropped the turn's `status: started` event, and the synced session row arrives via the slower changeset pump. The phone keeps the hint current from live `status` / `done` events, drops it when a full ack omits the flag (older host / no live summary), and clears it on project switch / reconnect resets. Incoming chat events bump a UI revision through a leading-edge coalescer (~150 ms window: the first event after a quiet period renders immediately, bursts batch); turn-state flips bypass the coalescer entirely so the stop button reacts instantly. When the host advertises `crossProjectChat`, `chat_subscribe` / `chat_unsubscribe` also carry an optional `projectId` / `projectRootPath` override so the Hub can open a chat in a **foreign** project read-only (transcript streamed straight off that project's `.ade` JSONL) without switching the phone's active project — see the Hub and Lane-data-projection sections | | `roster_subscribe` / `roster_unsubscribe` / `roster_snapshot` / `roster_delta` | Phone → runtime / runtime → phone | All-projects chat roster feed backing the Hub. Subscribe (optionally with `sinceSeq`) yields a full `roster_snapshot` then incremental `roster_delta` upserts (`changed` = whole project entries) / `removed` project ids. Un-booted projects carry disk-derived status only; transcripts load on demand when a chat opens | | `envelope_chunk` | Runtime → phone | Slice of an oversized encoded envelope (>720 KB); the phone reassembles by `chunkId`/`index` before normal decode. `SyncEnvelopeChunkAssembler` enforces a 32 MiB reassembly byte cap (`maxChunkedSyncEnvelopeBytes`) and drops chunk sets with inconsistent `total`s so a malformed or oversized stream cannot grow phone memory unbounded | | `heartbeat` | Bidirectional | Connection health (30s) | @@ -603,9 +613,25 @@ project's chats come straight from the phone's already-synced local DB card is never stuck on "Loading chats…". Tapping a project card opens its detailed tabbed view; tapping a chat opens that chat directly over the Hub (the Hub stays mounted underneath so Back returns to it, and it keeps rebuilding -roster cards while a chat is open). A bottom "type to vibecode" bar slides up a -new-chat drawer (`HubComposerDrawer`) with a Project ▸ Lane destination picker; -the chat is created in place and does **not** auto-open — a "Created in +roster cards while a chat is open). Opening a chat that belongs to a project +other than the active one uses **cross-project quick look** +(`HubScreen+ChatNavigation`): when the host advertises `crossProjectChat` +(`SyncService.supportsCrossProjectChat`), the cover synthesizes a +`TerminalSessionSummary` stub from the roster row +(`makeCrossProjectSessionStub`) and streams that foreign project's transcript +read-only via a `WorkSessionDestinationView` carrying a +`WorkChatCrossProjectContext` — no project switch, no runtime boot. Lane/PR +affordances are hidden in this mode (`showsLaneActions: false`) but send / +approve still work through scoped commands. On an older host without the flag +it falls back to the previous behavior: activate the project first (keeping the +Hub) and open the full-detail chat once the switch lands. A bottom +"type to vibecode" bar is now an **inline composer** (`HubInlineComposer`, in +`HubComposerDrawer`): focusing it raises the keyboard and expands the controls +strip (permission/model/mode/dictation) in place above the keyboard rather than +presenting a modal drawer. Expansion is explicit state (never derived from +`@FocusState`, which would snap) so every expand/collapse rides one shared +spring, and a Project ▸ Lane destination picker chooses where the chat lands. +The chat is created in place and does **not** auto-open — a "Created in <project> · <lane>" toast offers an Open shortcut. Project cards are drag-reorderable (persisted per machine, mobile-only, never touching desktop ordering). Attention bubbles are driven by the roster's `attentionCount` @@ -654,7 +680,7 @@ Opening or selecting the project again clears those hidden keys. | Tab | Icon | Desktop equivalent | Capabilities | |---|---|---|---| -| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, now a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | +| **Lanes** | `square.stack.3d.up` | `/lanes` | Full lane surface: search/filter chips, open/create/attach/manage, multi-attach for unregistered worktrees, stack canvas, git/diff/rebase/conflicts, template-backed environment setup progress, lane-scoped sessions and AI chats. `devicesOpen` presence chips show which other devices currently have the lane open. The lane detail screen (full-screen, custom tab bar hidden) is organized into collapsible sections (`LaneDetailSectionChrome`): each section auto-opens when it has content and auto-collapses when empty (`LaneSectionDisclosure`), and stays where the user last put it once they toggle it manually. Header chips and the git action buttons flow through `LaneChipFlowLayout`, a wrapping flow layout that wraps onto new lines instead of horizontally scrolling. Lane rows in the list carry a cheap render-relevant signature (mirroring the Hub row-signature pattern) so `.equatable()` re-renders only rows whose visible state changed. It embeds `LaneDetailGitActionsPane`, a port of desktop's git actions pane: commit message field with amend toggle and an AI "Suggest message" button (gated by runtime capability, with a setup-hint when the runtime reports "AI commit messages are off"), pull (rebase/merge mode) / push (with force-with-lease) / fetch, staged + unstaged file lists with per-file and bulk stage / unstage / discard / restore / open-diff / open-files, stash push/apply/pop/drop, recent-commit history with context-menu view-files / copy-message / revert / cherry-pick, and a "more actions" menu holding switch branch plus the destructive escape hatches (rebase lane, rebase + descendants, rebase and push, force push). A conflict banner offers rebase **and merge** continue/abort (`git.rebaseContinue`/`Abort`, `git.mergeContinue`/`Abort`), and a rescue sheet creates a new lane from uncommitted changes. The lane options menu copies shareable deeplinks (`LaneDeeplinkHelpers`: `ade://lane/`, `ade://repo///branch/`) and opens `LaneManageSheet`, now a tabbed manage dialog (delete / appearance / stack / archive) mirroring desktop's `ManageLaneDialog`; for an attached-but-unmanaged lane (`adoptableAttached`, matching the host's derivation) it also surfaces a "Move to ADE-managed worktree" adopt action that copies registration into `.ade/worktrees` without rewriting git history. The previous `LaneAdvancedScreen`, `LaneCommitSheet`, `LaneStashesScreen`, and `LaneCommitHistoryScreen` destinations were deleted in favor of this single pane. | | **Files** | `doc.text` | `/files` | Lane-backed workspace picker (`FilesWorkspacePickerDropdown`, a desktop-shaped searchable dropdown that replaced the horizontal workspace chip row), live file tree/read. Search is a single full-screen page (`FilesSearchScreen`) opened from the magnifying-glass button in the Files top bar (desktop `SearchOverlay` parity): one query searches file *names* (quick open) and file *contents* (text search) together — name matches surface first under "Files", content hits are grouped per file with collapsible line previews, and tapping a line opens the file at that line. The inline `FilesQueryCard` quick-open / text-search cards (and their 40-row caps) were removed. Files are freely editable — the mobile read-only file-mutation gate (`mobileReadOnly` / edit-protection) was removed on both the host and the phone, matching the desktop change. | | **Work** | `terminal` | `/work` | Terminal + chat session list, cached history with persisted lane names, output streaming, native key-passthrough terminal input (keystrokes from the iOS keyboard flow straight into the PTY as `terminal_input`, coalesced ~16 ms; PTY echo is the only source of truth), Ctrl-C forwarding for subscribed live PTYs, in-app CLI session launcher (Claude / Codex / Cursor / OpenCode / Droid), message-to-continue on ended agent CLI rows, session pinning, live chat-event push from the runtime (no polling lag once subscribed). The new-session screen (`WorkNewChatScreen`) toggles between **Chat** and **CLI** via a compact nav-bar pill toggle (desktop `ModeSwitcherPills` parity); the lane is chosen through `WorkLanePickerDropdown` (searchable, with an auto-create-lane row), and in CLI mode the provider is derived from the picked model via `workResolveCliProvider` instead of a separate provider row — the explicit `workCliProviderOptions` picker (and its plain "Shell" launch option) was removed. The new-chat composer shares the in-session chat composer's `WorkComposerControlsRow` (the same controls strip used by `WorkComposerChipStrip`): a permission/access control that collapses to a single tone-dot dropdown when space is tight and expands to segmented chips when wide, a model pill, and a fast-mode lightning toggle. The fast-mode toggle is shown only in **Chat** mode for fast-capable models (threaded into `chat.create` via `codexFastMode`) and is hidden in CLI mode, where the launcher has no fast-mode parameter. The composer's last-used selection (model + access mode + reasoning effort + fast mode) persists across surfaces through `WorkComposerPreferences` (App Group `UserDefaults`, versioned key): the New Chat screen seeds its initial state from the saved selection instead of hardcoded defaults, and every change or send — from the New Chat composer, the in-session inline picker (`WorkSessionDestinationView`), or the session settings sheet — writes it back. Because the inline picker is cross-provider, the persisted provider is re-derived from the picked model, and a provider change resets the coupled access mode / sub-settings to that provider's defaults. Droid (Factory) is in the new-chat provider allowlist (`workNormalizedNewChatProvider`), so Droid Core models (GLM / Kimi / MiniMax) keep the `droid` provider instead of silently collapsing to the Claude runtime. The new-chat send button is the shared `ADEComposerSendButton` (an arrow-in-circle disc matching the in-session composer), replacing the earlier paperplane capsule. Each session row carries a minimal per-lane PR status indicator (`WorkLanePrIndicator`: a state-colored dot + `#num` + Open/Draft/Closed/Merged) beside the lane name. It and the Lanes tab chip both render the unified `LanePrTag` (`LaneHelpers.swift`, `selectLaneTabPrTag`, desktop parity), which merges ADE-mapped PRs (the synced `pull_requests` table) with GitHub PRs opened outside ADE — matched to a lane by branch and fetched into the shared `SyncService.laneGithubPrItems` cache (`refreshLaneGithubPrItems`, best-effort, throttled, reset on project switch / reconnect). CLI mode submits `work.startCliSession` with the resolved provider, permission mode (Claude additionally supports `auto`), an optional `reasoningEffort`, and an optional opening message. For most providers the runtime types the opening message into the spawned PTY; for Codex the opening message is forwarded as the final argv positional through `buildTrackedCliLaunchCommand`, so the prompt is treated as a real first turn instead of a typed shell line. The terminal viewer (`TerminalSessionScreen` + `SwiftTermSessionView`) is a full-bleed SwiftTerm (real VT100/xterm) emulator: tap-to-focus raises the iOS keyboard for direct passthrough, a single-row key bar provides esc/tab/latching-Ctrl/arrows/return plus an overflow menu, pinch adjusts font size, and the phone owns the PTY's cols×rows while the screen is open (sent as `terminal_resize`; the runtime restores the desktop size on detach). Live output streams via offset-stamped `terminal_data` with gap detection + `sinceOffset` delta resume (no snapshot polling); scrolling near the top auto-pages older transcript via `terminal_history`, and a floating "↓ Live N" pill snaps back to the live tail. When the hosted program enables mouse reporting (Claude Code, htop), vertical pans are translated into SGR wheel events so the TUI scrolls itself; mouse-off sessions scroll native scrollback. Against pre-offset hosts (older brains, whose PTY→sync bridge never pushed terminal output) the screen detects the missing offsets and falls back to a 2s tail-refresh poll until offsets appear. The screen unsubscribes via `terminal_unsubscribe` on disappear. The legacy `WorkTerminalEmulatorView`/`WorkTerminalScreen` mini-parser remains only for inline preview cards. The earlier "activity feed" section was retired — running chats are surfaced through the session list and a Work tab badge bound to `SyncService.runningChatSessionCount`. In chat sessions, user-message attachments render through `WorkChatAttachmentTray` (image thumbnails embedded in the bubble, desktop `ChatAttachmentTray` parity, placeholder tiles when the image bytes have not synced from the host yet), and the chat header's PR menu opens the lane's open PR on GitHub, copies its link, or launches the create-PR wizard in `singleModeOnly` mode (eligibility read from `prs.getMobileSnapshot.createCapabilities`). | | **PRs** | `arrow.triangle.pull` | `/prs` | PR list/detail driven by `prs.getMobileSnapshot`: stack visibility (`PrStackSheet`), create-PR wizard (`CreatePrWizardView`) gated by per-lane eligibility, workflow cards (queue / integration / rebase) rendered from `PrWorkflowCard`, per-PR action capabilities. | @@ -702,7 +728,14 @@ sends: summaries (running / awaiting-input / ended / session count). - Cached lane-detail payloads (`LaneDetailPayload`) keyed by lane id so the Lanes tab can render the desktop stack / git / diff / manage - / work surfaces without client-side reconstruction. + / work surfaces without client-side reconstruction. `lanes.getDetail` + and `lanes.refreshSnapshots` are conditional responses: each carries a + `signature` (a sha256 of the full payload) and the phone echoes the + cached one back as `ifNoneMatch`. When it matches, the runtime returns a + bare `{ signature, notModified: true }` shell (decoded through + `LaneNotModifiedEnvelope`) and the phone keeps its cached payload, + skipping both transport and a full re-decode. See + [remote-commands.md](./remote-commands.md). - Unregistered-worktree candidates (`UnregisteredLaneCandidate`) returned by `lanes.listUnregisteredWorktrees`; `LaneMultiAttachSheet` can attach selected rows and optionally move them under ADE management. @@ -910,6 +943,18 @@ reflected in the phone's UI on the next descriptor read. 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. +- **Chat-event snapshot decode is element-lossy, not all-or-nothing.** + The `events` array on every chat snapshot payload + (`AgentChatEventHistorySnapshot`, `SyncChatSubscribeSnapshotPayload`, + etc.) is decoded through the `@ADELossyArray` property wrapper: one + event the phone's model can't decode (a newer host emits a field/enum + case this build doesn't know) is skipped instead of failing the whole + array. A strict all-or-nothing decode there would drop the entire + transcript, which strands pending-input question/approval/plan cards + behind the plain-text fallback and locks the composer. When adding a + new event or card type, keep the phone's decoders tolerant — a foreign + event should degrade to "that one event is missing", never "the whole + transcript is gone". - **Long Work chats must keep row work and root polling cheap.** The Work chat detail keeps the full timeline snapshot preview-free, then attaches cached initial assistant-message previews only to the visible diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index c818912ee..3d4875f5d 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -149,6 +149,19 @@ requested lane through the scoped lane-summary path and then fetches the detail overlays for that lane, instead of forcing a full lane list as a side effect of opening a detail screen. +`lanes.getDetail` and `lanes.refreshSnapshots` are **conditional +responses**. Both compute their full payload, then hash it +(`sha256(JSON.stringify(response))`) into a `signature` field. A caller +that already holds a payload can send its cached `ifNoneMatch` +signature; when it equals the freshly computed one the runtime replies +with a lightweight `{ signature, notModified: true }` shell carrying no +payload body, so the phone skips both the transport of an unchanged +lane detail / snapshot set and the client-side re-decode. A mismatched +or absent `ifNoneMatch` returns the full payload with +`notModified: false` and the current `signature` to cache. The full +payload is still computed either way (the signature derives from it), +so the win is transport and decode, not host compute. + **Work** (`work.*`) - `listSessions`, `updateSessionMeta`, `runQuickCommand`, `startCliSession`, `sendToSession`, `stopRuntime` From 010c021b02d7df836608210c22fbdf91b7cafe43 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:57:05 -0400 Subject: [PATCH 2/8] Address review: fail-closed foreign chat scope, secret-question masking, wait-loop early exits - syncHostService: unresolvable explicit-foreign chat_subscribe now fails closed (empty snapshot) instead of falling back to a local session with the same id; rootPath-only payloads naming the host's own project route local - rosterBuilder: drop the always-false resolve equality check (the startsWith containment guard is the real defense) - iOS: secure-response questions hide their option list and never surface the chosen option via the resolution pill or accessibility text - iOS: hub hydration wait exits early when the connection has settled into disconnected/error instead of spinning the full 6s timeout - LaneSectionDisclosure: explicit zero-arg init Co-Authored-By: Claude Fable 5 --- .../src/services/sync/rosterBuilder.ts | 1 - .../src/services/sync/syncHostService.ts | 46 ++++++++++++------- apps/ios/ADE/Services/SyncService.swift | 8 +++- .../Views/Lanes/LaneDetailSectionChrome.swift | 5 ++ .../Views/Work/WorkChatRichCardViews.swift | 9 ++-- 5 files changed, 48 insertions(+), 21 deletions(-) diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index 116626b9b..bfa21feaf 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -436,7 +436,6 @@ export function createForeignChatTranscriptResolver(args: { const filePath = path.resolve(path.join(transcriptsDir, `${safeSessionId}.jsonl`)); // Defense in depth: a validated session id already can't traverse, but // confirm the resolved path stays inside the transcripts dir. - if (filePath !== path.join(transcriptsDir, `${safeSessionId}.jsonl`)) return null; if (!filePath.startsWith(transcriptsDir + path.sep)) return null; return filePath; }, diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 7984de79b..e194e044c 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3159,27 +3159,32 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // 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). Returns null when the - // payload targets this host's own project (caller uses the local path) or - // when the provider rejects/omits it. - function resolveForeignChatTranscriptPath( + // the path to that project's `.ade` transcripts). `kind: "local"` means the + // payload carried no foreign scope (or named this host's own project); + // `kind: "rejected"` means the payload EXPLICITLY named a foreign project + // 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, sessionId: string, - ): string | null { - if (!args.foreignChatProvider) return null; + ): { kind: "local" } | { kind: "foreign"; transcriptPath: string } | { kind: "rejected" } { const requestedProjectId = toOptionalString(payload?.projectId); const requestedRootPath = toOptionalString(payload?.projectRootPath); - if (!requestedProjectId && !requestedRootPath) return null; - // A payload that names THIS host's project is an ordinary subscribe — let - // the local sessionService path serve it. + 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 null; + return { kind: "local" }; + } + if (!requestedProjectId && requestedRootPath && path.resolve(requestedRootPath) === path.resolve(args.projectRoot)) { + return { kind: "local" }; } - return args.foreignChatProvider.resolveTranscriptPath({ + const transcriptPath = args.foreignChatProvider?.resolveTranscriptPath({ projectId: requestedProjectId, projectRootPath: requestedRootPath, sessionId, - }); + }) ?? null; + return transcriptPath ? { kind: "foreign", transcriptPath } : { kind: "rejected" }; } // Per-session replay buffers for resumable chat event streams. Map insertion @@ -4396,15 +4401,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // project is served read-only from that project's `.ade` transcript // JSONL — no local session row, no runtime boot. The pump tails the // same path for live events. The provider is the security boundary - // (validates the project, sandboxes the path). - const foreignTranscriptPath = resolveForeignChatTranscriptPath(payload, sessionId); + // (validates the project, sandboxes the path). An explicitly-foreign + // scope the provider can't confirm fails CLOSED (served as unknown), + // never falls back to a local session that happens to share the id. + const foreignScope = resolveForeignChatScope(payload, sessionId); + const foreignTranscriptPath = foreignScope.kind === "foreign" ? foreignScope.transcriptPath : null; if (foreignTranscriptPath) { peer.foreignChatTranscriptPaths.set(sessionId, foreignTranscriptPath); } else { peer.foreignChatTranscriptPaths.delete(sessionId); } - const session = foreignTranscriptPath ? null : args.sessionService.get(sessionId); + const session = foreignScope.kind === "local" ? args.sessionService.get(sessionId) : null; const transcriptPath = foreignTranscriptPath ?? session?.transcriptPath ?? null; // Snapshots are byte-capped transcript tails — a long-running turn's // `status: started` event can sit outside the tail, leaving a client @@ -4417,7 +4425,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Foreign quick-looks have no live agent chat service here, so they // derive turn state from the streamed status events instead. const resolveLiveStatusFields = async (): Promise<{ turnActive?: boolean }> => { - if (foreignTranscriptPath) return {}; + if (foreignScope.kind !== "local") return {}; const liveSummary = await args.agentChatService?.getSessionSummary(sessionId).catch(() => null); return liveSummary ? { turnActive: liveSummary.status === "active" } : {}; }; @@ -4465,6 +4473,12 @@ export function createSyncHostService(args: SyncHostServiceArgs) { events = foreignSnapshot.events; truncated = foreignSnapshot.truncated; transcriptSize = foreignSnapshot.transcriptSize; + } else if (foreignScope.kind === "rejected") { + // Unresolvable explicit-foreign scope: serve an empty snapshot, never + // this host's local history for the same session id. + events = []; + truncated = false; + transcriptSize = 0; } else { const history: AgentChatEventHistorySnapshot | null = args.agentChatService?.getChatEventHistory(sessionId, { maxEvents: CHAT_EVENT_REPLAY_MAX_EVENTS, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 8155b8bc9..27945dd82 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1807,10 +1807,16 @@ final class SyncService: ObservableObject { } // Give up early on a switch that failed to establish a live socket; the // cover falls back to its own offline/empty handling rather than spinning - // for the full timeout. + // for the full timeout. A `.disconnected` work phase is only worth + // waiting on while a reconnect is actually in flight — when the + // connection itself has settled into disconnected/error, no hydration is + // coming and the full 6s spin would just delay the cover's empty state. if connectionState.isHostUnreachable { return } + if connectionState == .disconnected || connectionState == .error { + return + } try? await Task.sleep(nanoseconds: 200_000_000) } } diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift b/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift index e53f85f18..438651cfa 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailSectionChrome.swift @@ -69,6 +69,11 @@ struct LaneSectionDisclosure { var expanded = false private var pinnedByUser = false + // Explicit: the synthesized memberwise init's access is dragged down by the + // private stored property; keep the zero-arg init unambiguously internal for + // the @State call sites in other files. + init() {} + mutating func syncAuto(hasContent: Bool) { guard !pinnedByUser else { return } expanded = hasContent diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index d20e7dee0..dfe95a302 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -1197,10 +1197,11 @@ struct WorkResolvedQuestionCard: View { /// The option the user picked, matched by value or label against the /// resolution word. Nil when the resolution is a plain status ("accepted"), - /// a freeform/typed answer, or a decline. + /// a freeform/typed answer, a decline, or a secure-response question (a + /// secret answer must never surface, even as a matched option label). private var chosenOption: WorkPendingQuestionOption? { guard let model, !isDeclined else { return nil } - for question in model.questions { + for question in model.questions where !question.isSecret { if let match = question.options.first(where: { isSelected($0) }) { return match } @@ -1271,7 +1272,9 @@ struct WorkResolvedQuestionCard: View { .foregroundStyle(ADEColor.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) } - if !question.options.isEmpty { + // Secure-response questions mask more than the prompt: option labels and + // descriptions can restate the secret, so the whole option list is hidden. + if !question.isSecret, !question.options.isEmpty { VStack(alignment: .leading, spacing: 6) { ForEach(Array(question.options.enumerated()), id: \.offset) { _, option in optionRow(option) From 3b46cdd3aa5498d38972352c32ec7b21c518a219 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:22:02 -0400 Subject: [PATCH 3/8] Drain queued foreign-scope commands with their stored project scope Offline-queued commands targeting a foreign project (cross-project quick-look chat sends) could never drain: the flush loop only picked operations matching the ACTIVE project, and replayed without the stored scope. Command envelopes route cross-project on the host, so drain them on a host match and replay with the operation's own projectId/rootPath; file requests stay project-gated. Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/Services/SyncService.swift | 38 +++++++++++++++++++++---- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 27945dd82..f3c6d9a80 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -9491,14 +9491,17 @@ final class SyncService: ObservableObject { } } - private func pendingOperationMatchesActiveProject(_ operation: PendingOperation) -> Bool { + private func pendingOperationMatchesActiveHost(_ operation: PendingOperation) -> Bool { let currentHostId = activeHostStorageKey() let operationHostId = normalizedHostStorageKey(operation.hostId) if let currentHostId { - guard operationHostId == currentHostId else { return false } - } else if operationHostId != nil { - return false + return operationHostId == currentHostId } + return operationHostId == nil + } + + private func pendingOperationMatchesActiveProject(_ operation: PendingOperation) -> Bool { + guard pendingOperationMatchesActiveHost(operation) else { return false } if operation.projectId == nil && operation.projectRootPath == nil { return true @@ -9514,6 +9517,20 @@ final class SyncService: ObservableObject { return false } + /// Which queued operations the current connection can drain. Command + /// envelopes carry their own project scope in the payload and the host + /// routes them cross-project (the same mechanism live sends use), so a + /// command queued for a foreign project — e.g. a cross-project quick-look + /// chat send that timed out — drains as soon as the HOST matches, instead of + /// waiting for the phone to switch active projects (which may never happen). + /// File requests have no cross-project routing and stay active-project-gated. + private func pendingOperationIsDrainable(_ operation: PendingOperation) -> Bool { + if operation.kind == "command" { + return pendingOperationMatchesActiveHost(operation) + } + return pendingOperationMatchesActiveProject(operation) + } + private func decodeQueuedArgs(_ operation: PendingOperation) throws -> [String: Any] { let raw = try JSONSerialization.jsonObject(with: operation.payload, options: []) guard let dict = raw as? [String: Any] else { @@ -9557,7 +9574,7 @@ final class SyncService: ObservableObject { return false } - while let operation = queued.first(where: pendingOperationMatchesActiveProject) { + while let operation = queued.first(where: pendingOperationIsDrainable) { do { let args = try decodeQueuedArgs(operation) switch operation.kind { @@ -9565,7 +9582,16 @@ final class SyncService: ObservableObject { guard commandPolicy(for: operation.action) != nil else { throw NSError(domain: "ADE", code: 16, userInfo: [NSLocalizedDescriptionKey: "Queued action \(operation.action) is no longer available on this machine."]) } - _ = try await performCommandRequest(action: operation.action, args: args, commandId: operation.id) + // Replay with the operation's stored scope — a queued foreign-project + // command must not silently retarget to whatever project is active + // at drain time. + _ = try await performCommandRequest( + action: operation.action, + args: args, + commandId: operation.id, + targetProjectId: operation.projectId, + targetProjectRootPath: operation.projectRootPath + ) case "file": guard queueableFileActions.contains(operation.action) else { throw NSError(domain: "ADE", code: 17, userInfo: [NSLocalizedDescriptionKey: "Queued file action \(operation.action) is no longer supported."]) From 9230528408f6f0d071604b3d4a82493b2d8868b5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 2 Jul 2026 23:49:37 -0400 Subject: [PATCH 4/8] Harden conditional lane responses + foreign chat pump against review findings - notModified lane-detail shells skip presence decoration (decorating the fieldless shell dereferenced detail.lane and failed the command) - conditional signatures fold in a lane-presence stamp so a presence-only change (another device opening a lane) invalidates cached copies instead of serving notModified with stale devicesOpen - rejected foreign chat subscriptions never register in the pump (and foreign quick-look subscriptions are dropped from shared-listener handoff snapshots) so the pump can never stream a same-id local session's transcript Co-Authored-By: Claude Fable 5 --- .../src/services/sync/syncHostService.ts | 32 ++++++++++++++++--- .../services/sync/syncRemoteCommandService.ts | 19 +++++++++-- apps/ade-cli/src/services/sync/syncService.ts | 2 ++ 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index e194e044c..9f5599367 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -1546,7 +1546,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }; } case "lanes.getDetail": - return result && typeof result === "object" + // A notModified cache-hit shell carries no lane fields — decorating it + // would dereference detail.lane and turn the response into command_failed. + return result && typeof result === "object" && (result as Partial).lane ? decorateLaneDetailPayload(result as LaneDetailPayload) : result; case "lanes.create": @@ -4395,7 +4397,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const payload = envelope.payload as SyncChatSubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); if (!sessionId) break; - peer.subscribedChatSessionIds.add(sessionId); // Cross-project "quick look": a payload targeting a registered FOREIGN // project is served read-only from that project's `.ade` transcript @@ -4403,9 +4404,17 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // same path for live events. The provider is the security boundary // (validates the project, sandboxes the path). An explicitly-foreign // scope the provider can't confirm fails CLOSED (served as unknown), - // never falls back to a local session that happens to share the id. + // never falls back to a local session that happens to share the id — + // including in the pump: a rejected scope must not register a live + // subscription at all, or the periodic pump would stream the ACTIVE + // project's transcript for the same session id after the empty ack. const foreignScope = resolveForeignChatScope(payload, sessionId); const foreignTranscriptPath = foreignScope.kind === "foreign" ? foreignScope.transcriptPath : null; + if (foreignScope.kind === "rejected") { + peer.subscribedChatSessionIds.delete(sessionId); + } else { + peer.subscribedChatSessionIds.add(sessionId); + } if (foreignTranscriptPath) { peer.foreignChatTranscriptPaths.set(sessionId, foreignTranscriptPath); } else { @@ -4696,6 +4705,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { return getLanePresenceSnapshot(); }, + // Deterministic digest of lane presence for conditional-response + // signatures (see SyncRemoteCommandServiceArgs.getLanePresenceStamp). + getLanePresenceStamp(): string { + return getLanePresenceSnapshot() + .map((entry) => `${entry.laneId}:${entry.devicesOpen.map((d) => `${d.deviceId}|${d.displayName}|${d.platform}`).sort().join(",")}`) + .sort() + .join(";"); + }, + getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> { return [...peers] .map((peer) => { @@ -4807,7 +4825,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { serverDbSiteId: args.db.sync.getSiteId(), lastKnownServerDbVersion: peer.lastKnownServerDbVersion, subscribedSessionIds: [...peer.subscribedSessionIds], - subscribedChatSessionIds: [...peer.subscribedChatSessionIds], + // Foreign quick-look subscriptions do NOT survive the handoff: the + // resolved transcript path isn't carried, and restoring the bare + // session id would make the pump fall back to a same-id LOCAL + // session (the exact fallback the subscribe path fails closed + // against). The client re-subscribes with its scope after handoff. + subscribedChatSessionIds: [...peer.subscribedChatSessionIds] + .filter((sessionId) => !peer.foreignChatTranscriptPaths.has(sessionId)), chatTranscriptOffsets: Object.fromEntries(peer.chatTranscriptOffsets), rosterSubscribed: peer.rosterSubscribed, }); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 380f5a601..d42687804 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -212,6 +212,15 @@ type SyncRemoteCommandServiceArgs = { laneTemplateService?: ReturnType | null; rebaseSuggestionService?: ReturnType | null; autoRebaseService?: ReturnType | null; + /** + * Deterministic stamp of the sync host's in-memory lane presence + * (`devicesOpen`). The host decorates lane list/detail payloads with + * presence AFTER this service builds them, so the conditional-response + * signatures fold the stamp in — otherwise a presence-only change (another + * device opening a lane) would keep matching `ifNoneMatch` and the client + * would hold a stale presence indicator until an unrelated lane change. + */ + getLanePresenceStamp?: () => string; /** * Lazy accessor for the model picker store (favorites + recents, backed by * the per-project cr-sqlite DB). iOS hits these via the `modelPicker.*` sync @@ -285,8 +294,12 @@ function respondWithSignature( response: T, ifNoneMatch: string | null | undefined, emptyResponse: E, + signatureSalt = "", ): (T | E) & { signature: string; notModified: boolean } { - const signature = payloadSignature(response); + // The salt folds host-decorated state (lane presence) into the signature so + // a presence-only change invalidates the client's cached copy even though + // the undecorated payload is byte-identical. + const signature = payloadSignature(signatureSalt ? { response, signatureSalt } : response); if (ifNoneMatch && ifNoneMatch === signature) { return { ...emptyResponse, signature, notModified: true }; } @@ -2054,12 +2067,12 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio refreshedCount: 0, lanes: [], snapshots: null, - }); + }, args.getLanePresenceStamp?.() ?? ""); }); register("lanes.getDetail", { viewerAllowed: true }, async (payload) => { const detailArgs = parseLaneDetailRequestArgs(payload); const response = await buildLaneDetailPayload(args, detailArgs.laneId); - return respondWithSignature(response, detailArgs.ifNoneMatch, {}); + return respondWithSignature(response, detailArgs.ifNoneMatch, {}, args.getLanePresenceStamp?.() ?? ""); }); register("lanes.create", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.create(parseCreateLaneArgs(payload))); // Background lane naming for mobile auto-create. Deliberately NOT queueable: diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index b7814d6a9..471c69271 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -639,6 +639,8 @@ export function createSyncService(args: SyncServiceArgs) { laneTemplateService: args.laneTemplateService, rebaseSuggestionService: args.rebaseSuggestionService ?? undefined, autoRebaseService: args.autoRebaseService ?? undefined, + // Late-bound: the host service starts after this command service exists. + getLanePresenceStamp: () => hostService?.getLanePresenceStamp() ?? "", getModelPickerStore: args.getModelPickerStore, dispatchDeeplinkUrl: args.dispatchDeeplinkUrl, logger: args.logger, From 0dd9e1eac5a53daa006581ff5830038ccc679792 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:15:00 -0400 Subject: [PATCH 5/8] Gate quick-look to chat sessions; hash device platforms in row signature - A CLI session created from the hub toast carried no toolType, so the cross-project branch treated it as a chat quick-look and streamed a chat transcript that CLI sessions never write. The stub now carries the CLI marker and non-chat sessions take the activation path (terminal UI). - The lane row's presence icon derives from device platforms; the render signature hashed only the count, so a same-count platform swap left a stale icon. Hash the sorted platform list (+ regression coverage). Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift | 6 ++++-- apps/ios/ADE/Views/Hub/HubScreen.swift | 6 +++++- apps/ios/ADE/Views/Lanes/LaneComponents.swift | 5 ++++- apps/ios/ADETests/ADETests.swift | 11 +++++++++++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index 1483f4e7b..dc6064b94 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -158,8 +158,10 @@ private struct HubChatCover: View { return } // Cross-project quick look when the host supports it (newer brain): stream - // the foreign chat in place, no project switch. - if syncService.supportsCrossProjectChat { + // the foreign chat in place, no project switch. Chat sessions only — CLI + // sessions have no chat transcript JSONL to stream, so they take the + // activation path below and open as a terminal. + if syncService.supportsCrossProjectChat, target.chat.isChatTool { mode = .crossProject( WorkChatCrossProjectContext( projectId: target.project.id, diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index b718961e1..0dc054a4b 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -107,8 +107,12 @@ struct HubScreen: View { private func openCreated(_ created: HubCreatedChat) { dismissToast() guard let project = syncService.projects.first(where: { $0.id == created.projectId }) else { return } + // Carry the CLI marker: toolType nil reads as a chat (isChatTool defaults + // true), and a CLI session must not take the cross-project chat quick-look + // (CLI sessions have no chat transcript to stream — they need activation). let chat = RemoteRosterChat( - id: created.sessionId, laneId: "", chatSessionId: nil, title: nil, provider: nil, model: nil, toolType: nil, + id: created.sessionId, laneId: "", chatSessionId: nil, title: nil, provider: nil, model: nil, + toolType: created.isCli ? "cli" : nil, status: .running, awaitingInput: nil, pinned: nil, archived: nil, lastActivityAt: nil, preview: nil ) openChatTarget = HubChatTarget(project: project, lane: nil, chat: chat) diff --git a/apps/ios/ADE/Views/Lanes/LaneComponents.swift b/apps/ios/ADE/Views/Lanes/LaneComponents.swift index 6ad63b434..062f50fe6 100644 --- a/apps/ios/ADE/Views/Lanes/LaneComponents.swift +++ b/apps/ios/ADE/Views/Lanes/LaneComponents.swift @@ -671,7 +671,10 @@ func laneStackCardRenderSignature( hasher.combine(lane.status.ahead) hasher.combine(lane.status.behind) hasher.combine(lane.childCount) - hasher.combine(lane.devicesOpen?.count ?? 0) + // The card's presence icon derives from device PLATFORMS, not just how many + // devices are open — hash the sorted platform list so swapping a mac peer + // for an iPhone (same count) still re-renders the row. + hasher.combine((lane.devicesOpen ?? []).map(\.platform).sorted()) hasher.combine(primaryLaneLinearIssue(for: lane)?.identifier) hasher.combine(laneLinearIssueLinkCount(for: lane)) hasher.combine(isPinned) diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ac47332e4..8e0a58af2 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -6657,6 +6657,17 @@ final class ADETests: XCTestCase { ("linearIssue", { $0.lane.linearIssue = issue }), ("linearIssueLinks", { $0.lane.linearIssueLinks = [link, secondLink] }), ] + // Same device COUNT, different platform — the presence icon derives from + // the platform, so the signature must still flip. + var macSnapshot = makeSnapshot() + macSnapshot.lane.devicesOpen = [DeviceMarker(deviceId: "d1", displayName: "Studio", platform: "macos")] + var iosSnapshot = makeSnapshot() + iosSnapshot.lane.devicesOpen = [DeviceMarker(deviceId: "d1", displayName: "Phone", platform: "ios")] + XCTAssertNotEqual( + signature(macSnapshot), + signature(iosSnapshot), + "renderSignature must change when a device platform swaps at the same count" + ) for (field, mutate) in laneMutations { var mutated = makeSnapshot() mutate(&mutated) From 02e8d529507528cdab9c2de7ad54c1696d33f82f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 3 Jul 2026 00:42:16 -0400 Subject: [PATCH 6/8] Keep cross-project quick-look reads boot-free; legacy transcript fallback; scoped session meta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Quick-look reads (summary, canonical history, older-page loads) no longer issue project-scoped commands that boot the foreign runtime — the chat_subscribe snapshot + live tail serve the read path; sends/approvals still route scoped (a mutation legitimately needs the runtime) - Foreign transcript resolver falls back to the legacy /.chat.jsonl location so older sessions aren't blank - work.updateSessionMeta (rename) and work.stopRuntime route to the session's project scope instead of the active project Co-Authored-By: Claude Fable 5 --- .../src/services/sync/rosterBuilder.ts | 22 ++++++++++++++----- apps/ios/ADE/Services/SyncService.swift | 20 +++++++++++++++-- .../Work/WorkSessionDestinationView.swift | 18 +++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index bfa21feaf..d6445020a 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -432,12 +432,22 @@ export function createForeignChatTranscriptResolver(args: { }); if (!record) return null; - const transcriptsDir = path.resolve(resolveAdeLayout(record.rootPath).chatTranscriptsDir); - const filePath = path.resolve(path.join(transcriptsDir, `${safeSessionId}.jsonl`)); - // Defense in depth: a validated session id already can't traverse, but - // confirm the resolved path stays inside the transcripts dir. - if (!filePath.startsWith(transcriptsDir + path.sep)) return null; - return filePath; + const layout = resolveAdeLayout(record.rootPath); + const chatTranscriptsDir = path.resolve(layout.chatTranscriptsDir); + const legacyTranscriptsDir = path.resolve(layout.transcriptsDir); + // Mirror the chat service's candidate order: the dedicated chat dir + // first, then the legacy `/.chat.jsonl` location that + // older/restored sessions may still be writing. Defense in depth: a + // validated session id already can't traverse, but confirm each + // resolved path stays inside its transcripts dir. + const dedicatedPath = path.resolve(path.join(chatTranscriptsDir, `${safeSessionId}.jsonl`)); + if (!dedicatedPath.startsWith(chatTranscriptsDir + path.sep)) return null; + const legacyPath = path.resolve(path.join(legacyTranscriptsDir, `${safeSessionId}.chat.jsonl`)); + if (!legacyPath.startsWith(legacyTranscriptsDir + path.sep)) return null; + if (!fs.existsSync(dedicatedPath) && fs.existsSync(legacyPath)) { + return legacyPath; + } + return dedicatedPath; }, }; } diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index f3c6d9a80..4ee358f3e 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -4349,8 +4349,16 @@ final class SyncService: ObservableObject { manuallyNamed: manuallyNamed ) if supportsRemoteAction("work.updateSessionMeta") { + // Route to the session's project (cross-project quick look) — a foreign + // session's rename must not target the active project's scope. + let scope = chatCommandScope(for: sessionId) do { - _ = try await sendCommand(action: "work.updateSessionMeta", args: args) + _ = try await sendCommand( + action: "work.updateSessionMeta", + args: args, + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } catch { syncConnectLog.info( "work updateSessionMeta deferred session=\(sessionId, privacy: .public) error=\(String(describing: error), privacy: .public)" @@ -5194,7 +5202,15 @@ final class SyncService: ObservableObject { } func stopWorkRuntime(sessionId: String) async throws { - _ = try await sendCommand(action: "work.stopRuntime", args: ["sessionId": sessionId]) + // Scope to the session's project so a stop issued from a cross-project + // quick look reaches the right runtime. + let scope = chatCommandScope(for: sessionId) + _ = try await sendCommand( + action: "work.stopRuntime", + args: ["sessionId": sessionId], + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath + ) } func createLane( diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 7a88e87be..5d1a1fc3c 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -971,7 +971,10 @@ struct WorkSessionDestinationView: View { } let resolvedSessionStatus: String? = viewingSubagent ? "ended" : sessionStatus let loadOlderTranscriptAction: (@MainActor () async -> Void)? - if viewingSubagent { + 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. loadOlderTranscriptAction = nil } else { loadOlderTranscriptAction = { await loadOlderTranscriptEntries() } @@ -1148,6 +1151,12 @@ struct WorkSessionDestinationView: View { chatSummary = cached } + // Cross-project quick look reads come from the chat_subscribe snapshot + + // live tail, which the brain serves WITHOUT booting the foreign project. + // The scoped chat.getSummary command routes through the project scope + // registry and would spin up that project's runtime just to look. + guard !isCrossProject else { return } + if syncService.supportsRemoteAction("chat.getSummary"), let fetchedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { if chatSummary != fetchedSummary { @@ -1225,11 +1234,16 @@ struct WorkSessionDestinationView: View { try? await syncService.requestFullChatEventSnapshot(sessionId: sessionId) } - let shouldHydrateCanonicalEventTail = !preferLightweight + // 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). + let shouldHydrateCanonicalEventTail = !isCrossProject && ( + !preferLightweight || transcript.isEmpty || transcriptStatus != "active" || !initialTranscriptTailHydrated || forceOpeningTranscriptRefresh + ) if shouldHydrateCanonicalEventTail { do { if syncService.supportsRemoteAction("chat.getChatEventHistory") { From 19c6e643440bf2c100a30df70be952160c8550df Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:12:44 -0400 Subject: [PATCH 7/8] Isolate foreign quick-look feeds from local broadcast/replay; close last read-boot vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - broadcastChatEvent skips peers whose subscription for the id is a foreign quick-look, and foreign subscribes never resume from the active project's replay buffers — a colliding session id can no longer splice local live events into a foreign feed - quick-look also skips the chat.getTranscript fallback fetch and terminal subscribe (both active-project/boot vectors); reads stay on the chat_subscribe snapshot + pump Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/services/sync/syncHostService.ts | 12 +++++++++++- .../ADE/Views/Work/WorkSessionDestinationView.swift | 11 +++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 9f5599367..1e77cf0f2 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -3265,6 +3265,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; if (isPeerBackpressured(peer)) continue; if (!peer.subscribedChatSessionIds.has(event.sessionId)) continue; + // A peer whose subscription for this id is a FOREIGN quick-look gets its + // events from the transcript pump — never the active project's live + // broadcast, even when a local session shares the session id. + if (peer.foreignChatTranscriptPaths.has(event.sessionId)) continue; if (!rememberChatEventSent(peer, event)) continue; send(peer.ws, "chat_event", { ...event, seq } satisfies SyncChatEventPayload); } @@ -4438,7 +4442,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const liveSummary = await args.agentChatService?.getSessionSummary(sessionId).catch(() => null); return liveSummary ? { turnActive: liveSummary.status === "active" } : {}; }; - const resumePlan = planChatEventResume(chatEventReplayBuffers.get(sessionId), payload?.sinceSeq); + // Replay buffers hold the ACTIVE project's live events — a foreign + // quick-look whose session id collides with a local session must never + // resume from them (it would splice local events into the foreign feed). + const resumePlan = planChatEventResume( + foreignScope.kind === "local" ? chatEventReplayBuffers.get(sessionId) : undefined, + payload?.sinceSeq, + ); if (resumePlan.mode === "replay") { // The replay buffer covers everything the peer missed: skip the // snapshot, fast-forward the transcript pump past content the diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 5d1a1fc3c..3fe706b58 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -1291,11 +1291,16 @@ struct WorkSessionDestinationView: View { // answer, which are useful while streaming but not enough for final copy // or history. let needsInitialTailHydration = forceRemote && !initialTranscriptTailHydrated - let shouldFetchFallback = forceOpeningTranscriptRefresh + // Cross-project quick looks never take the command-based fallback fetch — + // chat.getTranscript routes through the project scope registry and boots + // the foreign runtime for a read. + let shouldFetchFallback = !isCrossProject && ( + forceOpeningTranscriptRefresh || needsInitialTailHydration || !preferLightweight || (liveTranscript.isEmpty && transcript.isEmpty) || (!liveTranscript.isEmpty && transcriptStatus != "active") + ) let fallbackMaxChars = transcriptStatus == "active" ? 240_000 : 600_000 if shouldFetchFallback, let page = try? await syncService.fetchChatTranscriptPage(sessionId: sessionId, maxChars: fallbackMaxChars) { recordTranscriptPage(page, before: nil) @@ -1309,7 +1314,9 @@ struct WorkSessionDestinationView: View { // Terminal sessions own their subscription via TerminalSessionScreen's // offset stream; a preview-budget subscribe here would race a second // replace-snapshot into that stream. - if forceRemote && !preferLightweight, let currentSession = session ?? initialSession, isChatSession(currentSession) { + // Terminal buffers are active-project scoped; a quick-look must not + // subscribe them (wrong project, and another read-path boot vector). + if forceRemote && !preferLightweight && !isCrossProject, let currentSession = session ?? initialSession, isChatSession(currentSession) { try? await syncService.subscribeTerminal(sessionId: sessionId) let raw = syncService.terminalBuffers[sessionId] ?? "" let parsed = parseWorkChatTranscript(raw) From 2cc2a9f4359c9fec5a6cf51c5257bbcc372b137f Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 3 Jul 2026 01:36:41 -0400 Subject: [PATCH 8/8] Wire lane presence stamp into the host's fallback command service The injected-service path (syncService.ts) already salts conditional lane signatures with the presence stamp; the fallback createSyncRemoteCommandService inside createSyncHostService did not, so embedders on that path could serve notModified with stale devicesOpen. Share one hoisted stamp helper. Co-Authored-By: Claude Fable 5 --- .../src/services/sync/syncHostService.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 1e77cf0f2..2c363182b 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -1138,6 +1138,11 @@ export function createSyncHostService(args: SyncHostServiceArgs) { laneTemplateService: args.laneTemplateService, rebaseSuggestionService: args.rebaseSuggestionService, autoRebaseService: args.autoRebaseService, + // Lane presence lives in this closure; without the stamp, a presence-only + // change (devicesOpen) would keep matching ifNoneMatch and serve stale + // presence via notModified. The injected-service path (syncService.ts) + // wires the same stamp from the outside. + getLanePresenceStamp: () => computeLanePresenceStamp(), dispatchDeeplinkUrl: args.dispatchDeeplinkUrl, logger: args.logger, }); @@ -4565,6 +4570,15 @@ export function createSyncHostService(args: SyncHostServiceArgs) { .filter((entry) => entry.devicesOpen.length > 0); }; + // Hoisted so the fallback remoteCommandService args (declared earlier in + // this closure) can reference it lazily without TDZ concerns. + function computeLanePresenceStamp(): string { + return getLanePresenceSnapshot() + .map((entry) => `${entry.laneId}:${entry.devicesOpen.map((d) => `${d.deviceId}|${d.displayName}|${d.platform}`).sort().join(",")}`) + .sort() + .join(";"); + } + function getListeningPort(): number | null { if (!server) return sharedListener?.getPort() ?? null; const address = server.address(); @@ -4718,10 +4732,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { // Deterministic digest of lane presence for conditional-response // signatures (see SyncRemoteCommandServiceArgs.getLanePresenceStamp). getLanePresenceStamp(): string { - return getLanePresenceSnapshot() - .map((entry) => `${entry.laneId}:${entry.devicesOpen.map((d) => `${d.deviceId}|${d.displayName}|${d.platform}`).sort().join(",")}`) - .sort() - .join(";"); + return computeLanePresenceStamp(); }, getChatSubscriptionSnapshot(): Array<{ deviceId: string; subscribedChatSessionIds: string[] }> {