From 8c82114b22f52fbc408aabca1c623893387e9365 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 03:47:45 -0400 Subject: [PATCH 1/7] Checkpoint lane changes before PR badge work --- apps/ade-cli/src/bootstrap.ts | 2 + apps/ade-cli/src/cli.ts | 15 + .../src/services/projects/projectScope.ts | 11 + .../src/services/sync/rosterBuilder.test.ts | 206 +++ .../src/services/sync/rosterBuilder.ts | 413 ++++++ .../src/services/sync/sharedSyncListener.ts | 3 + .../src/services/sync/syncHostService.test.ts | 149 ++ .../src/services/sync/syncHostService.ts | 278 +++- .../sync/syncRemoteCommandService.test.ts | 93 ++ .../services/sync/syncRemoteCommandService.ts | 39 + apps/ade-cli/src/services/sync/syncService.ts | 3 + .../services/chat/agentChatService.test.ts | 158 +++ .../main/services/chat/agentChatService.ts | 152 +- .../sync/syncRemoteCommandService.test.ts | 1 + apps/desktop/src/shared/types/chat.ts | 2 + apps/desktop/src/shared/types/sync.ts | 98 ++ apps/ios/ADE.xcodeproj/project.pbxproj | 32 + apps/ios/ADE/App/ContentView.swift | 386 +---- .../ADEInspectorKit/ADEInspectable.swift | 84 +- apps/ios/ADE/Models/RemoteModels.swift | 121 +- apps/ios/ADE/Models/RemoteRosterModels.swift | 249 ++++ apps/ios/ADE/Services/Database.swift | 195 ++- apps/ios/ADE/Services/SyncService.swift | 1119 +++++++++++++-- .../Views/Components/ADEDesignSystem.swift | 231 +-- apps/ios/ADE/Views/Hub/HubComponents.swift | 994 +++++++++++++ .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 958 +++++++++++++ .../Views/Hub/HubScreen+ChatNavigation.swift | 99 ++ apps/ios/ADE/Views/Hub/HubScreen.swift | 522 +++++++ apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 6 +- .../Settings/ConnectionSettingsView.swift | 46 +- .../Settings/SettingsConnectionHeader.swift | 56 +- .../Settings/SettingsPairingSection.swift | 46 +- .../Views/Work/WorkActivityIndicator.swift | 56 +- .../ADE/Views/Work/WorkBrowserHelpers.swift | 32 +- .../Work/WorkChatComposerAndInputViews.swift | 87 +- .../Work/WorkChatHeaderAndMessageViews.swift | 380 ++++- .../Views/Work/WorkChatRichCardViews.swift | 384 +++++ .../Work/WorkChatSessionView+Actions.swift | 892 ++++++++++-- .../Work/WorkChatSessionView+Timeline.swift | 188 ++- .../ADE/Views/Work/WorkChatSessionView.swift | 1242 ++++++++++++----- .../Work/WorkErrorAndMessageHelpers.swift | 530 ++++++- .../ios/ADE/Views/Work/WorkEventMapping.swift | 37 +- .../ADE/Views/Work/WorkMarkdownParsing.swift | 26 +- .../ADE/Views/Work/WorkMarkdownViews.swift | 95 +- apps/ios/ADE/Views/Work/WorkModels.swift | 127 +- .../WorkNavigationAndTranscriptHelpers.swift | 45 + .../Views/Work/WorkPlanComposerViews.swift | 333 +++++ apps/ios/ADE/Views/Work/WorkPreviews.swift | 27 +- .../ADE/Views/Work/WorkRootComponents.swift | 220 ++- .../Views/Work/WorkRootScreen+Actions.swift | 86 +- apps/ios/ADE/Views/Work/WorkRootScreen.swift | 140 +- .../WorkSessionDestinationView+Actions.swift | 28 +- .../Work/WorkSessionDestinationView.swift | 1166 ++++++++++++++-- .../ADE/Views/Work/WorkSessionGrouping.swift | 291 +++- .../Work/WorkStatusAndFormattingHelpers.swift | 160 ++- .../ADE/Views/Work/WorkTimelineHelpers.swift | 897 ++++++++++-- .../ADE/Views/Work/WorkTranscriptParser.swift | 9 + apps/ios/ADETests/ADETests.swift | 1012 ++++++++++++-- .../WorkMarkdownStreamingParsingTests.swift | 16 + docs/features/chat/composer-and-ui.md | 5 +- 60 files changed, 13366 insertions(+), 1912 deletions(-) create mode 100644 apps/ade-cli/src/services/sync/rosterBuilder.test.ts create mode 100644 apps/ade-cli/src/services/sync/rosterBuilder.ts create mode 100644 apps/ios/ADE/Models/RemoteRosterModels.swift create mode 100644 apps/ios/ADE/Views/Hub/HubComponents.swift create mode 100644 apps/ios/ADE/Views/Hub/HubComposerDrawer.swift create mode 100644 apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift create mode 100644 apps/ios/ADE/Views/Hub/HubScreen.swift create mode 100644 apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 33ece759b..7921e5b01 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -156,6 +156,7 @@ export type AdeRuntimeSyncOptions = { localDeviceIdPath?: string; phonePairingStateDir?: string; projectCatalogProvider?: Parameters[0]["projectCatalogProvider"]; + rosterProvider?: Parameters[0]["rosterProvider"]; remoteCommandExecutor?: Parameters[0]["remoteCommandExecutor"]; /** * Brain-level websocket listener shared by every project scope's sync host @@ -1199,6 +1200,7 @@ export async function createAdeRuntime(args: { hostDiscoveryEnabled: resolvedArgs.syncRuntime.hostDiscoveryEnabled ?? true, forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? false, projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider, + rosterProvider: resolvedArgs.syncRuntime.rosterProvider, 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 da68c44d9..4ce83cc36 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -12833,6 +12833,7 @@ async function runServe( { createSharedSyncListener }, { resolveMobileProjectIconDataUrl }, { createBrainProjectActionsSyncHandler }, + { buildRosterSnapshot }, ] = await Promise.all([ import("./services/projects/machineLayout"), import("./services/projects/projectRegistry"), @@ -12841,6 +12842,7 @@ async function runServe( import("./services/sync/sharedSyncListener"), import("../../desktop/src/main/services/projects/projectIconThumbnail"), import("./services/sync/brainProjectActionsSyncHandler"), + import("./services/sync/rosterBuilder"), ]); const layout = resolveMachineAdeLayout(); @@ -13198,6 +13200,19 @@ async function runServe( localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"), phonePairingStateDir: layout.secretsDir, projectCatalogProvider: machineProjectCatalogProvider, + // All-projects chat roster (mobile hub). Closes over `scopeRegistry`, + // which is assigned by this very `new ProjectScopeRegistry(...)` call — + // safe because `buildSnapshot` only runs later (on `roster_subscribe`), + // by which point the binding is set (mirrors machineProjectCatalogProvider). + rosterProvider: { + buildSnapshot: () => + buildRosterSnapshot({ + projectRegistry, + scopeRegistry, + hostProjectId: preferredSyncProjectId, + logger: headlessProjectLogger, + }), + }, }, }); const previousRole = process.env.ADE_DEFAULT_ROLE; diff --git a/apps/ade-cli/src/services/projects/projectScope.ts b/apps/ade-cli/src/services/projects/projectScope.ts index 64b9c64dc..4a4b43aeb 100644 --- a/apps/ade-cli/src/services/projects/projectScope.ts +++ b/apps/ade-cli/src/services/projects/projectScope.ts @@ -51,6 +51,17 @@ export class ProjectScopeRegistry { }; } + /** + * Non-booting lookup of an already-booted (or currently-booting) scope. + * Returns the cached scope promise when one exists, or `null` when the + * project has never been activated. Unlike `get()` this NEVER boots a scope, + * so the all-projects roster can overlay live fidelity onto the projects that + * happen to be running without spinning up a runtime for every project. + */ + getIfBooted(projectId: ProjectId): Promise | null { + return this.scopes.get(projectId) ?? null; + } + async get(projectId: ProjectId): Promise { const cached = this.scopes.get(projectId); if (cached) return await cached; diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.test.ts b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts new file mode 100644 index 000000000..076248638 --- /dev/null +++ b/apps/ade-cli/src/services/sync/rosterBuilder.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; +import { + buildRosterSnapshot, + type RosterBootedScope, + type RosterLiveSession, + type RosterScopeRegistry, +} from "./rosterBuilder"; + +const require = createRequire(import.meta.url); +const { DatabaseSync } = require("node:sqlite") as { + DatabaseSync: new (p: string) => DatabaseSyncType; +}; + +const PROJECT_ID = "project_test_roster"; + +let projectRoot: string; +let worktreeDir: string; + +function seedDatabase(): void { + const adeDir = path.join(projectRoot, ".ade"); + fs.mkdirSync(adeDir, { recursive: true }); + worktreeDir = path.join(projectRoot, "worktree"); + fs.mkdirSync(worktreeDir, { recursive: true }); + + const db = new DatabaseSync(path.join(adeDir, "ade.db")); + db.exec(` + create table lanes ( + id text primary key, + name text not null, + color text, + icon text, + lane_type text, + branch_ref text, + worktree_path text, + attached_root_path text, + status text, + archived_at text, + created_at text + ); + create table terminal_sessions ( + id text primary key, + lane_id text not null, + chat_session_id text, + tool_type text, + title text, + status text, + last_output_preview text, + last_output_at text, + pinned integer, + exit_code integer, + started_at text, + archived_at text + ); + `); + + const insertLane = db.prepare( + `insert into lanes (id, name, color, icon, lane_type, branch_ref, worktree_path, attached_root_path, status, archived_at, created_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + // Primary lane (worktree == project root) — must sort first. + insertLane.run("lane-primary", "main", "#fff", "star", "primary", "main", null, null, "active", null, "2026-01-01T00:00:00Z"); + // Worktree lane with an existing worktree dir. + insertLane.run("lane-work", "feature", null, null, "worktree", "feat", worktreeDir, null, "active", null, "2026-01-02T00:00:00Z"); + // Archived lane — filtered out. + insertLane.run("lane-arch", "old", null, null, "worktree", "old", worktreeDir, null, "archived", "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"); + // Worktree lane whose path is gone — filtered out. + insertLane.run("lane-gone", "ghost", null, null, "worktree", "ghost", path.join(projectRoot, "missing"), null, "active", null, "2026-01-01T00:00:00Z"); + + const insertChat = db.prepare( + `insert into terminal_sessions (id, lane_id, chat_session_id, tool_type, title, status, last_output_preview, last_output_at, pinned, exit_code, started_at, archived_at) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + const longPreview = "x".repeat(130); + // Chat in primary lane, DB says running but no live runtime → idle. + insertChat.run("chat-run", "lane-primary", null, "claude-chat", "Running chat", "running", longPreview, "2026-01-02T00:00:00Z", 1, null, "2026-01-02T00:00:00Z", null); + // Chat awaiting input (from sidecar) — attention. + insertChat.run("chat-await", "lane-primary", null, "cursor", "Awaiting chat", "running", "needs input", "2026-01-03T00:00:00Z", 0, null, "2026-01-03T00:00:00Z", null); + // Standalone CLI session without a parent chat — hidden from the hub roster. + insertChat.run("cli-fail", "lane-work", null, "shell", "Build", "ended", "boom", "2026-01-01T12:00:00Z", 0, 1, "2026-01-01T00:00:00Z", null); + // CLI session that exited cleanly → ended; owned by chat-run for child shell grouping. + insertChat.run("cli-end", "lane-work", "chat-run", "shell", "Lint", "ended", "ok", "2026-01-01T06:00:00Z", 0, 0, "2026-01-01T00:00:00Z", null); + // Legacy provider-name CLI row — desktop no longer treats raw providers as + // Work chat rows, so the mobile hub must not surface it as a chat either. + insertChat.run("legacy-codex", "lane-work", null, "codex", "Legacy Codex", "running", "legacy", "2026-01-04T00:00:00Z", 0, null, "2026-01-04T00:00:00Z", null); + // Archived chat — filtered out. + insertChat.run("chat-arch", "lane-primary", null, "claude-chat", "Old", "ended", null, "2026-01-01T00:00:00Z", 0, 0, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"); + // Chat whose lane was filtered out — orphan, dropped. + insertChat.run("chat-orphan", "lane-gone", null, "claude-chat", "Orphan", "running", null, "2026-01-05T00:00:00Z", 0, null, "2026-01-05T00:00:00Z", null); + + db.close(); + + // Sidecar: marks chat-await as awaiting + carries provider/model. + const sidecarDir = path.join(adeDir, "cache", "chat-sessions"); + fs.mkdirSync(sidecarDir, { recursive: true }); + fs.writeFileSync( + path.join(sidecarDir, "chat-await.json"), + JSON.stringify({ provider: "cursor", model: "gpt-5", awaitingInput: true }), + ); +} + +const projectRegistry = { + list: () => [ + { projectId: PROJECT_ID, rootPath: projectRoot, displayName: "Test", lastOpenedAt: 1_700_000_000_000 }, + ], +}; + +const unbootedScopes: RosterScopeRegistry = { getIfBooted: () => null }; + +function bootedScopes(liveSessions: RosterLiveSession[]): RosterScopeRegistry { + const scope: RosterBootedScope = { + runtime: { + agentChatService: { listSessions: async () => liveSessions }, + }, + }; + return { getIfBooted: (id) => (id === PROJECT_ID ? Promise.resolve(scope) : null) }; +} + +beforeEach(() => { + projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-roster-")); + seedDatabase(); +}); + +afterEach(() => { + fs.rmSync(projectRoot, { recursive: true, force: true }); +}); + +describe("buildRosterSnapshot", () => { + it("maps lanes and chats from disk for an un-booted project", async () => { + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + expect(projects).toHaveLength(1); + const project = projects[0]!; + + expect(project.projectId).toBe(PROJECT_ID); + expect(project.booted).toBe(false); + expect(project.lastOpenedAt).toBe(new Date(1_700_000_000_000).toISOString()); + + // Archived + worktree-gone lanes are filtered; primary sorts first. + expect(project.lanes.map((lane) => lane.id)).toEqual(["lane-primary", "lane-work"]); + + // Orphan, archived, standalone shell, and legacy provider CLI rows are + // dropped; child shells owned by a visible chat remain. + expect(project.chats.map((chat) => chat.id)).toEqual(["chat-await", "chat-run", "cli-end"]); + }); + + it("maps disk status truthfully (running→idle, awaiting, failed) when un-booted", async () => { + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + const byId = new Map(projects[0]!.chats.map((chat) => [chat.id, chat])); + + expect(byId.get("chat-run")!.status).toBe("idle"); // DB running, no live runtime + expect(byId.get("chat-await")!.status).toBe("awaiting"); + expect(byId.get("chat-await")!.awaitingInput).toBe(true); + expect(byId.get("cli-end")!.status).toBe("ended"); + + expect(projects[0]!.runningCount).toBe(0); + expect(projects[0]!.attentionCount).toBe(1); // awaiting + }); + + it("hard-truncates the preview to ~120 chars and reads sidecar provider/model", async () => { + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry: unbootedScopes }); + const byId = new Map(projects[0]!.chats.map((chat) => [chat.id, chat])); + + const preview = byId.get("chat-run")!.preview!; + expect(preview.length).toBe(120); + expect(preview.endsWith("…")).toBe(true); + + expect(byId.get("chat-await")!.provider).toBe("cursor"); + expect(byId.get("chat-await")!.model).toBe("gpt-5"); + expect(byId.get("chat-await")!.toolType).toBe("cursor"); + expect(byId.get("cli-end")!.chatSessionId).toBe("chat-run"); + }); + + it("overlays live running/awaiting fidelity for a booted scope", async () => { + const scopeRegistry = bootedScopes([ + { sessionId: "chat-run", status: "active", awaitingInput: false, provider: "claude", model: "opus" }, + ]); + const projects = await buildRosterSnapshot({ projectRegistry, scopeRegistry, hostProjectId: PROJECT_ID }); + const project = projects[0]!; + const byId = new Map(project.chats.map((chat) => [chat.id, chat])); + + expect(project.booted).toBe(true); + expect(byId.get("chat-run")!.status).toBe("running"); + expect(byId.get("chat-run")!.provider).toBe("claude"); + expect(project.runningCount).toBe(1); + }); + + it("tolerates a project with no ADE database (empty lanes/chats)", async () => { + const emptyRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-roster-empty-")); + try { + const registry = { + list: () => [{ projectId: "project_empty", rootPath: emptyRoot, displayName: "Empty", lastOpenedAt: 0 }], + }; + const projects = await buildRosterSnapshot({ projectRegistry: registry, scopeRegistry: unbootedScopes }); + expect(projects).toHaveLength(1); + expect(projects[0]!.lanes).toEqual([]); + expect(projects[0]!.chats).toEqual([]); + expect(projects[0]!.lastOpenedAt).toBeNull(); + } finally { + fs.rmSync(emptyRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts new file mode 100644 index 000000000..a9d3b0d60 --- /dev/null +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -0,0 +1,413 @@ +import fs from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; +import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; +import type { + SyncRosterChat, + SyncRosterChatStatus, + SyncRosterLane, + SyncRosterProject, +} from "../../../../desktop/src/shared/types"; +import type { Logger } from "../../../../desktop/src/main/services/logging/logger"; + +// Anchor builtin resolution to the active runtime, mirroring kvDb / the cheap +// cross-project read in recentProjectSummary.ts. The roster opens each +// project's `.ade/ade.db` read-only with `node:sqlite` — NO cr-sqlite, NO +// runtime boot — so an all-projects feed never has to activate every project. +type DatabaseSyncConstructor = new (dbPath: string, options?: { allowExtension?: boolean }) => DatabaseSyncType; +const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); +const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; + +const PREVIEW_MAX_CHARS = 120; + +// --- Narrow structural inputs (the concrete ProjectRegistry / ---------------- +// ProjectScopeRegistry satisfy these; keeping them structural lets the unit +// tests seed a project dir + a stub scope registry without a runtime). -------- + +export type RosterProjectRecord = { + projectId: string; + rootPath: string; + displayName: string; + lastOpenedAt: number; +}; + +export type RosterProjectRegistry = { + list(): RosterProjectRecord[]; +}; + +export type RosterLiveSession = { + sessionId: string; + status: "active" | "idle" | "ended"; + awaitingInput?: boolean; + provider?: string | null; + model?: string | null; + lastActivityAt?: string | null; +}; + +export type RosterAgentChatService = { + listSessions( + laneId?: string, + options?: { includeArchived?: boolean }, + ): Promise; +}; + +export type RosterBootedScope = { + runtime: { agentChatService?: RosterAgentChatService | null }; +}; + +export type RosterScopeRegistry = { + /** Non-booting lookup; null when the project is not currently booted. */ + getIfBooted(projectId: string): Promise | null; +}; + +export type BuildRosterSnapshotArgs = { + projectRegistry: RosterProjectRegistry; + scopeRegistry: RosterScopeRegistry; + /** The host's own project is always booted — included with live fidelity. */ + hostProjectId?: string | null; + logger?: Pick | null; +}; + +// --- Raw DB row shapes ------------------------------------------------------- + +type LaneRow = { + id: string; + name: string; + color: string | null; + icon: string | null; + lane_type: string | null; + branch_ref: string | null; + worktree_path: string | null; + attached_root_path: string | null; + status: string | null; + archived_at: string | null; + created_at: string | null; +}; + +type TerminalSessionRow = { + id: string; + lane_id: string; + chat_session_id: string | null; + tool_type: string | null; + title: string | null; + status: string | null; + last_output_preview: string | null; + last_output_at: string | null; + pinned: number | null; + exit_code: number | null; + started_at: string | null; +}; + +type DiskProjectData = { + lanes: LaneRow[]; + chats: TerminalSessionRow[]; +}; + +// --- Helpers ----------------------------------------------------------------- + +function normalizePath(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed ? path.resolve(trimmed) : null; +} + +// A lane is renderable only if its worktree still exists on disk — mirrors +// `laneExistsOnDisk` in recentProjectSummary.ts so the roster never advertises +// lanes whose worktree was deleted out from under ADE. +function laneExistsOnDisk(row: LaneRow, projectRoot: string): boolean { + if ((row.lane_type ?? "").trim() === "primary") { + return fs.existsSync(projectRoot); + } + const candidate = normalizePath(row.worktree_path) ?? normalizePath(row.attached_root_path); + return candidate ? fs.existsSync(candidate) : false; +} + +function hasTable(db: DatabaseSyncType, tableName: string): boolean { + return Boolean( + db + .prepare("select 1 as present from sqlite_master where type = 'table' and name = ? limit 1") + .get<{ present?: number }>(tableName)?.present, + ); +} + +function hasColumn(db: DatabaseSyncType, tableName: string, columnName: string): boolean { + return db + .prepare(`pragma table_info(${tableName})`) + .all<{ name?: string }>() + .some((row) => row.name === columnName); +} + +function truncatePreview(text: string | null | undefined): string | null { + if (typeof text !== "string") return null; + const trimmed = text.trim(); + if (!trimmed) return null; + return trimmed.length > PREVIEW_MAX_CHARS + ? `${trimmed.slice(0, PREVIEW_MAX_CHARS - 1)}…` + : trimmed; +} + +function normalizedToolType(value: string | null | undefined): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function isRosterTopLevelToolType(toolType: string | null | undefined): boolean { + const raw = normalizedToolType(toolType); + if (!raw) return false; + if (raw === "codex-chat" || raw === "claude-chat" || raw === "opencode-chat" || raw === "cursor") { + return true; + } + return raw.endsWith("-chat"); +} + +function normalizedParentSessionId(row: TerminalSessionRow): string | null { + const parentId = row.chat_session_id?.trim() ?? ""; + if (!parentId || parentId === row.id) return null; + return parentId; +} + +function desktopVisibleRosterRows(rows: TerminalSessionRow[], visibleLaneIds: Set): TerminalSessionRow[] { + const scopedRows = rows.filter((row) => visibleLaneIds.has(row.lane_id)); + const topLevelIds = new Set( + scopedRows + .filter((row) => isRosterTopLevelToolType(row.tool_type)) + .map((row) => row.id), + ); + + return scopedRows.filter((row) => { + if (isRosterTopLevelToolType(row.tool_type)) return true; + const parentId = normalizedParentSessionId(row); + return parentId != null && topLevelIds.has(parentId); + }); +} + +/** + * Read a project's lanes + chat sessions straight off disk (read-only). Never + * throws: a missing, locked, or schema-shifted DB yields empty lanes/chats so + * the project still appears in the roster (just without rows). + */ +function readProjectFromDisk(projectRoot: string, logger?: Pick | null): DiskProjectData { + const empty: DiskProjectData = { lanes: [], chats: [] }; + const dbPath = resolveAdeLayout(projectRoot).dbPath; + if (!fs.existsSync(dbPath)) return empty; + + let db: DatabaseSyncType | null = null; + try { + db = new DatabaseSync(dbPath); + db.exec("PRAGMA busy_timeout = 2000"); + if (!hasTable(db, "lanes")) return empty; + + const lanes = db + .prepare( + ` + select id, name, color, icon, lane_type, branch_ref, + worktree_path, attached_root_path, status, archived_at, created_at + from lanes + where coalesce(status, 'active') != 'archived' + and archived_at is null + `, + ) + .all(); + + const chats = hasTable(db, "terminal_sessions") + ? (() => { + const activeDb = db!; + const chatSessionIdColumn = hasColumn(activeDb, "terminal_sessions", "chat_session_id") + ? "chat_session_id" + : "null as chat_session_id"; + return activeDb + .prepare( + ` + select id, lane_id, ${chatSessionIdColumn}, tool_type, title, status, last_output_preview, + last_output_at, pinned, exit_code, started_at + from terminal_sessions + where archived_at is null + `, + ) + .all(); + })() + : []; + + return { lanes, chats }; + } catch (error) { + logger?.warn?.("sync_host.roster_project_read_failed", { + projectRoot, + error: error instanceof Error ? error.message : String(error), + }); + return empty; + } finally { + db?.close(); + } +} + +type Sidecar = { + provider?: string | null; + model?: string | null; + awaitingInput?: boolean; +}; + +// Best-effort read of a chat's persisted sidecar for provider/model/awaiting. +// A missing or unparsable sidecar leaves those fields null — never throws. +function readChatSidecar(chatSessionsDir: string, sessionId: string): Sidecar | null { + try { + const raw = fs.readFileSync(path.join(chatSessionsDir, `${sessionId}.json`), "utf8"); + const parsed = JSON.parse(raw) as Record; + return { + provider: typeof parsed.provider === "string" ? parsed.provider : null, + model: typeof parsed.model === "string" ? parsed.model : null, + awaitingInput: parsed.awaitingInput === true, + }; + } catch { + return null; + } +} + +// Disk-only status (un-booted project): the truthful persisted state. `running` +// collapses to `idle` because no live runtime is streaming the turn. +function diskChatStatus(row: TerminalSessionRow, sidecarAwaiting: boolean): SyncRosterChatStatus { + if (sidecarAwaiting) return "awaiting"; + const status = (row.status ?? "").trim(); + if (status !== "running" && row.exit_code != null && row.exit_code !== 0) return "failed"; + return status === "running" ? "idle" : "ended"; +} + +// Live status from a booted scope's agentChatService — true running/awaiting. +function liveChatStatus(live: RosterLiveSession): SyncRosterChatStatus { + if (live.awaitingInput) return "awaiting"; + if (live.status === "active") return "running"; + if (live.status === "idle") return "idle"; + return "ended"; +} + +function mapLane(row: LaneRow): SyncRosterLane { + return { + id: row.id, + name: row.name, + color: row.color, + icon: row.icon, + laneType: row.lane_type, + branchRef: row.branch_ref, + }; +} + +// Primary lane first, then oldest-created first, then name — loosely mirrors +// `sortWorkLanesForTabs` (primary pinned to the front of the tab strip). +function compareLanes(left: LaneRow, right: LaneRow): number { + const leftPrimary = (left.lane_type ?? "").trim() === "primary" ? 0 : 1; + const rightPrimary = (right.lane_type ?? "").trim() === "primary" ? 0 : 1; + if (leftPrimary !== rightPrimary) return leftPrimary - rightPrimary; + const createdDelta = (left.created_at ?? "").localeCompare(right.created_at ?? ""); + if (createdDelta !== 0) return createdDelta; + return left.name.localeCompare(right.name); +} + +async function buildRosterProject( + record: RosterProjectRecord, + scopeRegistry: RosterScopeRegistry, + logger?: Pick | null, +): Promise { + const disk = readProjectFromDisk(record.rootPath, logger); + const chatSessionsDir = resolveAdeLayout(record.rootPath).chatSessionsDir; + + // Path A overlay: only for projects already booted on the runtime. NEVER + // boots a scope (getIfBooted is non-booting). When booted, listSessions() + // carries true running/awaiting fidelity keyed by sessionId. + const liveBySessionId = new Map(); + let booted = false; + try { + const scope = await scopeRegistry.getIfBooted(record.projectId)?.catch(() => null); + const agentChatService = scope?.runtime.agentChatService; + if (agentChatService) { + booted = true; + const liveSessions = await agentChatService + .listSessions(undefined, { includeArchived: false }) + .catch(() => [] as RosterLiveSession[]); + for (const live of liveSessions) { + if (live?.sessionId) liveBySessionId.set(live.sessionId, live); + } + } + } catch { + // A booted-scope overlay is best-effort; fall back to disk-only fidelity. + } + + const visibleLanes = disk.lanes + .filter((lane) => laneExistsOnDisk(lane, record.rootPath)) + .sort(compareLanes); + const visibleLaneIds = new Set(visibleLanes.map((lane) => lane.id)); + + const chats: SyncRosterChat[] = []; + let runningCount = 0; + let attentionCount = 0; + for (const row of desktopVisibleRosterRows(disk.chats, visibleLaneIds)) { + const live = liveBySessionId.get(row.id); + const sidecar = readChatSidecar(chatSessionsDir, row.id); + const status = live ? liveChatStatus(live) : diskChatStatus(row, Boolean(sidecar?.awaitingInput)); + const awaitingInput = status === "awaiting"; + if (status === "running") runningCount += 1; + if (status === "awaiting" || status === "failed") attentionCount += 1; + const lastActivityAt = live?.lastActivityAt ?? row.last_output_at ?? row.started_at ?? null; + chats.push({ + id: row.id, + laneId: row.lane_id, + chatSessionId: row.chat_session_id, + title: row.title, + provider: live?.provider ?? sidecar?.provider ?? null, + model: live?.model ?? sidecar?.model ?? null, + toolType: row.tool_type, + status, + ...(awaitingInput ? { awaitingInput: true } : {}), + ...(row.pinned ? { pinned: true } : {}), + lastActivityAt, + preview: truncatePreview(row.last_output_preview), + }); + } + + chats.sort((left, right) => (right.lastActivityAt ?? "").localeCompare(left.lastActivityAt ?? "")); + + return { + projectId: record.projectId, + rootPath: record.rootPath, + displayName: record.displayName, + lastOpenedAt: record.lastOpenedAt > 0 ? new Date(record.lastOpenedAt).toISOString() : null, + booted, + runningCount, + attentionCount, + lanes: visibleLanes.map(mapLane), + chats, + }; +} + +/** + * Build the all-projects chat roster: every registered project's lanes + chat + * sessions, sourced cheaply from disk, with live status overlaid for any + * already-booted scope. Projects are sorted most-recently-opened first. + */ +export async function buildRosterSnapshot(args: BuildRosterSnapshotArgs): Promise { + const records = args.projectRegistry + .list() + .slice() + .sort((left, right) => right.lastOpenedAt - left.lastOpenedAt); + + const projects = await Promise.all( + records.map((record) => + buildRosterProject(record, args.scopeRegistry, args.logger).catch((error) => { + args.logger?.warn?.("sync_host.roster_project_build_failed", { + projectId: record.projectId, + error: error instanceof Error ? error.message : String(error), + }); + const fallback: SyncRosterProject = { + projectId: record.projectId, + rootPath: record.rootPath, + displayName: record.displayName, + lastOpenedAt: record.lastOpenedAt > 0 ? new Date(record.lastOpenedAt).toISOString() : null, + booted: false, + runningCount: 0, + attentionCount: 0, + lanes: [], + chats: [], + }; + return fallback; + }), + ), + ); + return projects; +} diff --git a/apps/ade-cli/src/services/sync/sharedSyncListener.ts b/apps/ade-cli/src/services/sync/sharedSyncListener.ts index 66b5704c6..666745581 100644 --- a/apps/ade-cli/src/services/sync/sharedSyncListener.ts +++ b/apps/ade-cli/src/services/sync/sharedSyncListener.ts @@ -76,6 +76,9 @@ export type SyncPeerHandoffSnapshot = { subscribedSessionIds?: string[]; subscribedChatSessionIds?: string[]; chatTranscriptOffsets?: Record; + /** All-projects roster (mobile hub) subscription, restored on adoption so a + * hosted-project switch does not silently stop the hub feed. */ + rosterSubscribed?: boolean; bufferedMessages?: Array<{ data: RawData; isBinary: boolean }>; }; diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 9f8e84aca..f99c0c98f 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -2009,3 +2009,152 @@ describe("terminal byte-offset streaming, history paging, and resize ownership", } }); }); + +describe("createSyncHostService all-projects roster", () => { + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + function rosterProject(projectId: string, runningCount: number) { + return { + projectId, + rootPath: `/tmp/${projectId}`, + displayName: projectId, + booted: false, + runningCount, + attentionCount: 0, + lanes: [], + chats: [], + }; + } + + function createRosterHost( + projectRoot: string, + rosterState: { projects: ReturnType[] }, + options: { withRosterProvider?: boolean } = {}, + ) { + const base = createHostArgs(projectRoot, []); + const args = { + ...base, + projectId: "project-host", + db: { + sync: { + getSiteId: () => "site-host-roster", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + projectCatalogProvider: { + listProjects: vi.fn(async () => ({ projects: [] })), + prepareProjectConnection: vi.fn(), + forgetProject: vi.fn(async () => ({ ok: true })), + }, + ...(options.withRosterProvider === false + ? {} + : { rosterProvider: { buildSnapshot: async () => rosterState.projects } }), + } as unknown as Parameters[0]; + return createSyncHostService(args); + } + + it("answers roster_subscribe with a seq:1 snapshot and bumps per-peer seq on re-subscribe", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const rosterState = { projects: [rosterProject("project-a", 1), rosterProject("project-b", 0)] }; + const host = createRosterHost(projectRoot, rosterState); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-roster-1"); + + peer.ws.send(encodeSyncEnvelope({ type: "roster_subscribe", requestId: "roster-1", payload: {} })); + const snapshot = await waitForEnvelope(peer.envelopes, "roster_snapshot", "roster-1"); + expect(snapshot.payload).toMatchObject({ seq: 1 }); + expect((snapshot.payload as { projects: unknown[] }).projects).toHaveLength(2); + + peer.ws.send(encodeSyncEnvelope({ type: "roster_subscribe", requestId: "roster-2", payload: {} })); + const second = await waitForEnvelope(peer.envelopes, "roster_snapshot", "roster-2"); + expect(second.payload).toMatchObject({ seq: 2 }); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("pushes a coalesced roster_delta carrying only the changed project", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const rosterState = { projects: [rosterProject("project-a", 1), rosterProject("project-b", 0)] }; + const host = createRosterHost(projectRoot, rosterState); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-roster-2"); + + peer.ws.send(encodeSyncEnvelope({ type: "roster_subscribe", requestId: "roster-1", payload: {} })); + await waitForEnvelope(peer.envelopes, "roster_snapshot", "roster-1"); + + // Mutate one project, then dirty the roster via a project-catalog change. + rosterState.projects = [rosterProject("project-a", 5), rosterProject("project-b", 0)]; + peer.ws.send(encodeSyncEnvelope({ + type: "project_forget_request", + requestId: "forget-1", + payload: { projectId: "project-x" }, + })); + + const delta = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "roster_delta"), + "roster_delta after dirty", + ); + expect(delta.payload).toMatchObject({ seq: 2 }); + const changed = (delta.payload as { changed?: Array<{ projectId: string; runningCount: number }> }).changed ?? []; + expect(changed).toHaveLength(1); + expect(changed[0]).toMatchObject({ projectId: "project-a", runningCount: 5 }); + expect((delta.payload as { removed?: string[] }).removed).toBeUndefined(); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); + + it("stays silent on roster_subscribe when no roster provider is wired (older host)", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const rosterState = { projects: [] as ReturnType[] }; + const host = createRosterHost(projectRoot, rosterState, { withRosterProvider: false }); + let peer: Awaited> | null = null; + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-roster-3"); + + peer.ws.send(encodeSyncEnvelope({ type: "roster_subscribe", requestId: "roster-1", payload: {} })); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(peer.envelopes.some((envelope) => envelope.type === "roster_snapshot")).toBe(false); + expect(peer.envelopes.some((envelope) => envelope.type === "roster_delta")).toBe(false); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); +}); diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 0a3b1b05c..3e8d45c66 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -10,6 +10,7 @@ import { WebSocketServer, WebSocket, type RawData } from "ws"; import { resolveAdeLayout } from "../../../../desktop/src/shared/adeLayout"; import type { AgentChatEventEnvelope, + AgentChatEventHistorySnapshot, CrsqlChangeRow, DeviceMarker, FileContent, @@ -54,6 +55,10 @@ import type { SyncProjectForgetResultPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, + SyncRosterProject, + SyncRosterSnapshotPayload, + SyncRosterDeltaPayload, + SyncRosterSubscribePayload, ListMyGitHubReposInput, ListMyGitHubReposResult, ProjectBrowseInput, @@ -179,6 +184,24 @@ const MOBILE_COMMAND_RESULT_CACHE_TTL_MS = 30 * 60 * 1000; const MOBILE_COMMAND_RESULT_CACHE_MAX_ENTRIES = 512; const CHANGESET_ACK_TIMEOUT_MS = 10_000; const SYNC_HOST_AUTH_TIMEOUT_MS = 15_000; +// All-projects roster (mobile hub) push cadence: trailing-edge debounce, hard +// max-wait cap so a steady event stream still flushes, and a slow safety poll +// that runs only while ≥1 peer is subscribed. +const ROSTER_DEBOUNCE_MS = 250; +const ROSTER_MAX_WAIT_MS = 1_000; +const ROSTER_SAFETY_POLL_MS = 15_000; +// Remote commands that add/remove a roster-visible lane or chat row (possibly +// in a non-active project via projectId routing). A successful one nudges the +// coalesced roster flush; everything else relies on chat events + safety poll. +const ROSTER_DIRTYING_COMMAND_ACTIONS = new Set([ + "chat.create", + "work.startCliSession", + "work.resumeCliSession", + "lanes.create", + "lanes.createChild", + "lanes.archive", + "lanes.delete", +]); const MAX_CHANGESET_ACK_RETRIES = 6; const LANE_PRESENCE_TTL_MS = 60_000; const SYNC_MDNS_SERVICE_TYPE = "ade-sync"; @@ -268,6 +291,13 @@ type PeerState = { chatTranscriptOffsets: Map; chatEventIdsSent: 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 + // no-change flush never sees a seq gap), and the per-project serialized + // baseline it last received (projectId → JSON) for changed/removed diffing. + rosterSubscribed: boolean; + rosterSeq: number; + rosterBaseline: Map; }; type PendingChangesetBatch = { @@ -415,6 +445,17 @@ export type SyncProjectCatalogProvider = { forgetProject?: (args: SyncProjectForgetRequestPayload) => Promise; }; +/** + * Builds the machine-wide all-projects chat roster (mobile hub). Lives where + * the project registry + project scope registry are both in scope (ade-cli + * brain). Optional: a host without a roster provider (e.g. single-project + * desktop) simply never answers `roster_subscribe`, so the phone falls back to + * the project catalog with no cross-project chats. + */ +export type SyncRosterProvider = { + buildSnapshot: () => Promise; +}; + type SyncHostServiceArgs = { db: AdeDb; logger: Logger; @@ -478,6 +519,7 @@ type SyncHostServiceArgs = { compressionThresholdBytes?: number; deviceRegistryService?: DeviceRegistryService; projectCatalogProvider?: SyncProjectCatalogProvider; + rosterProvider?: SyncRosterProvider; onStateChanged?: () => void; remoteCommandService?: SyncRemoteCommandService; remoteCommandExecutor?: Pick; @@ -1532,6 +1574,13 @@ export function createSyncHostService(args: SyncHostServiceArgs) { let discoveryEnabled = args.discoveryEnabled !== false; let chatPumpInFlight = false; let changesPumpInFlight = false; + // All-projects roster (mobile hub) coalescing state. Each subscribed peer + // carries its own monotonic seq (PeerState.rosterSeq); clients re-snapshot on + // any seq discontinuity. + let rosterFlushTimer: ReturnType | null = null; + let rosterMaxWaitTimer: ReturnType | null = null; + let rosterSafetyPollTimer: ReturnType | null = null; + let rosterFlushInFlight = false; let tailnetDiscoveryStatus: SyncTailnetDiscoveryStatus = { state: !discoveryEnabled ? "disabled" @@ -1683,6 +1732,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { chatTranscriptOffsets: new Map(), chatEventIdsSent: new Map(), pendingChangesetBatch: null, + rosterSubscribed: false, + rosterSeq: 0, + rosterBaseline: new Map(), }; peers.add(peer); peer.authTimeout = setTimeout(() => { @@ -1726,6 +1778,10 @@ export function createSyncHostService(args: SyncHostServiceArgs) { broadcastBrainStatus(); } peers.delete(peer); + if (peer.rosterSubscribed && rosterSubscriberPeers().length === 0) { + stopRosterSafetyPoll(); + clearRosterFlushTimers(); + } for (const sessionId of peer.subscribedSessionIds) { restoreDesktopTerminalSizeIfUnwatched(sessionId); } @@ -1845,6 +1901,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } satisfies SyncChatSubscribeSnapshotPayload); peer.subscribedChatSessionIds.add(sessionId); } + peer.rosterSubscribed = snapshot.rosterSubscribed === true; args.deviceRegistryService?.upsertPeerMetadata(snapshot.metadata, { lastSeenAt: nowIso(), lastHost: peer.remoteAddress, @@ -1872,6 +1929,18 @@ export function createSyncHostService(args: SyncHostServiceArgs) { send(peer.ws, "brain_status", brainStatus); sendProjectCatalog(peer, projectCatalog); } + // Re-prime any roster subscription carried across the host switch: a fresh + // snapshot (new seq epoch) re-seeds the peer's baseline on this host. + if (args.rosterProvider && adopted.some((peer) => peer.rosterSubscribed)) { + ensureRosterSafetyPoll(); + const projects = await buildRosterProjects(); + if (projects != null) { + for (const peer of adopted) { + if (!peer.rosterSubscribed || peer.ws.readyState !== WebSocket.OPEN) continue; + sendRosterSnapshotToPeer(peer, projects); + } + } + } await pumpChanges(); } @@ -2645,6 +2714,184 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!peer.authenticated || peer.ws.readyState !== WebSocket.OPEN) continue; sendProjectCatalog(peer, projectCatalog); } + // A catalog change (open/create/clone/forget/switch) reshapes the roster's + // project set; recompute + push it too. + markRosterDirty(); + } + + // --- All-projects roster (mobile hub) -------------------------------------- + + function rosterSubscriberPeers(): PeerState[] { + const subscribers: PeerState[] = []; + for (const peer of peers) { + if (peer.rosterSubscribed && peer.authenticated && peer.ws.readyState === WebSocket.OPEN) { + subscribers.push(peer); + } + } + return subscribers; + } + + function ensureRosterSafetyPoll(): void { + if (rosterSafetyPollTimer || disposed) return; + // While ≥1 peer is subscribed, a slow poll catches out-of-band on-disk + // changes in un-booted projects (e.g. a direct `ade` CLI run elsewhere) + // that emit no in-process event. + rosterSafetyPollTimer = setInterval(() => { + if (rosterSubscriberPeers().length === 0) { + stopRosterSafetyPoll(); + return; + } + markRosterDirty(); + }, ROSTER_SAFETY_POLL_MS); + rosterSafetyPollTimer.unref?.(); + } + + function stopRosterSafetyPoll(): void { + if (!rosterSafetyPollTimer) return; + clearInterval(rosterSafetyPollTimer); + rosterSafetyPollTimer = null; + } + + function clearRosterFlushTimers(): void { + if (rosterFlushTimer) { + clearTimeout(rosterFlushTimer); + rosterFlushTimer = null; + } + if (rosterMaxWaitTimer) { + clearTimeout(rosterMaxWaitTimer); + rosterMaxWaitTimer = null; + } + } + + // Coalesced recompute+push: trailing-edge debounce with a hard max-wait cap + // so a steady stream of events still flushes at least once per cap. + function markRosterDirty(): void { + if (disposed || !args.rosterProvider) return; + if (rosterSubscriberPeers().length === 0) return; + if (rosterFlushTimer) clearTimeout(rosterFlushTimer); + rosterFlushTimer = setTimeout(() => { + void flushRoster(); + }, ROSTER_DEBOUNCE_MS); + rosterFlushTimer.unref?.(); + if (!rosterMaxWaitTimer) { + rosterMaxWaitTimer = setTimeout(() => { + void flushRoster(); + }, ROSTER_MAX_WAIT_MS); + rosterMaxWaitTimer.unref?.(); + } + } + + async function buildRosterProjects(): Promise { + if (!args.rosterProvider) return null; + try { + return await args.rosterProvider.buildSnapshot(); + } catch (error) { + args.logger.warn("sync_host.roster_build_failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } + + // Send a full snapshot and (re)seed the peer's per-project baseline so the + // next flush can diff against it. A snapshot resets the peer's seq epoch (the + // client adopts snapshot.seq as its new watermark), so it is always safe. + function sendRosterSnapshotToPeer( + peer: PeerState, + projects: SyncRosterProject[], + requestId?: string | null, + ): void { + const seq = ++peer.rosterSeq; + const sent = send(peer.ws, "roster_snapshot", { seq, projects } satisfies SyncRosterSnapshotPayload, requestId); + if (!sent) { + // Backpressured/closed: drop the baseline so the next flush re-snapshots. + peer.rosterBaseline.clear(); + return; + } + peer.rosterBaseline = new Map(projects.map((project) => [project.projectId, JSON.stringify(project)])); + } + + async function flushRoster(): Promise { + clearRosterFlushTimers(); + if (disposed || rosterFlushInFlight) return; + const subscribers = rosterSubscriberPeers(); + if (subscribers.length === 0) { + stopRosterSafetyPoll(); + return; + } + rosterFlushInFlight = true; + try { + const projects = await buildRosterProjects(); + if (projects == null) return; + const subscribersNow = rosterSubscriberPeers(); + if (subscribersNow.length === 0) return; + const serialized = new Map(projects.map((project) => [project.projectId, JSON.stringify(project)])); + for (const peer of subscribersNow) { + if (peer.rosterBaseline.size === 0) { + // No baseline (fresh subscribe / prior drop) → full snapshot. + sendRosterSnapshotToPeer(peer, projects); + continue; + } + const changed: SyncRosterProject[] = []; + for (const project of projects) { + if (peer.rosterBaseline.get(project.projectId) !== serialized.get(project.projectId)) { + changed.push(project); + } + } + const removed: string[] = []; + for (const projectId of peer.rosterBaseline.keys()) { + if (!serialized.has(projectId)) removed.push(projectId); + } + if (changed.length === 0 && removed.length === 0) { + // Nothing changed for this peer: skip the send WITHOUT advancing its + // seq, so its next delta still arrives as lastSeq+1 (no false gap). + continue; + } + const seq = ++peer.rosterSeq; + const delta: SyncRosterDeltaPayload = { + seq, + ...(changed.length > 0 ? { changed } : {}), + ...(removed.length > 0 ? { removed } : {}), + }; + const sent = send(peer.ws, "roster_delta", delta); + if (!sent) { + // Backpressured: roll back the seq + force a fresh snapshot next flush. + peer.rosterSeq -= 1; + peer.rosterBaseline.clear(); + continue; + } + peer.rosterBaseline = new Map(serialized); + } + } finally { + rosterFlushInFlight = false; + } + } + + async function handleRosterSubscribe( + peer: PeerState, + requestId: string | null | undefined, + _payload: SyncRosterSubscribePayload | null, + ): Promise { + if (!args.rosterProvider) { + // No roster on this host — stay silent so the phone falls back to the + // project catalog (the contract treats a non-answering host gracefully). + return; + } + peer.rosterSubscribed = true; + peer.rosterBaseline.clear(); + ensureRosterSafetyPoll(); + const projects = await buildRosterProjects(); + if (projects == null) return; + sendRosterSnapshotToPeer(peer, projects, requestId ?? null); + } + + function handleRosterUnsubscribe(peer: PeerState): void { + peer.rosterSubscribed = false; + peer.rosterBaseline.clear(); + if (rosterSubscriberPeers().length === 0) { + stopRosterSafetyPoll(); + clearRosterFlushTimers(); + } } async function handleProjectBrowseRequest( @@ -2920,6 +3167,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { if (!rememberChatEventSent(peer, event)) continue; send(peer.ws, "chat_event", { ...event, seq } satisfies SyncChatEventPayload); } + // A chat lifecycle event for the host project updates its roster status + // live (other booted scopes are covered by the safety poll + live overlay). + markRosterDirty(); } async function pumpChanges(): Promise { @@ -3454,6 +3704,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ? { ...payload, projectId: hostProjectId } : payload; const created = await executor.execute(routedPayload); + // Create-in-place (possibly into another project) adds a lane/chat row the + // hub must see; nudge the roster (coalesced, no-op without subscribers). + if (ROSTER_DIRTYING_COMMAND_ACTIONS.has(payload.action)) markRosterDirty(); sendResult(acceptedRecord, { commandId, ok: true, @@ -4123,14 +4376,10 @@ 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 raw = session?.transcriptPath - ? await args.sessionService.readTranscriptTail( - session.transcriptPath, - maxBytes, - { raw: true, alignToLineBoundary: true }, - ) - : ""; - const events = parseAgentChatTranscript(raw).filter((event) => event.sessionId === sessionId); + const history: AgentChatEventHistorySnapshot | null = args.agentChatService?.getChatEventHistory(sessionId, { + maxEvents: CHAT_EVENT_REPLAY_MAX_EVENTS, + }) ?? null; + const events = history?.events ?? []; const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) ? fs.statSync(session.transcriptPath).size : 0; @@ -4138,7 +4387,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), - truncated: transcriptSize > maxBytes, + truncated: history?.truncated ?? (transcriptSize > maxBytes), events, ...(await resolveLiveStatusFields()), }; @@ -4155,6 +4404,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { } break; } + case "roster_subscribe": { + await handleRosterSubscribe(peer, envelope.requestId, envelope.payload as SyncRosterSubscribePayload | null); + break; + } + case "roster_unsubscribe": { + handleRosterUnsubscribe(peer); + break; + } case "command": await handleCommand(peer, envelope.requestId, { ...(envelope.payload as SyncCommandPayload), @@ -4394,6 +4651,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { clearInterval(pollTimer); clearInterval(heartbeatTimer); clearInterval(brainStatusTimer); + stopRosterSafetyPoll(); + clearRosterFlushTimers(); unpublishLanDiscovery(); try { await unpublishTailnetDiscovery(); @@ -4439,6 +4698,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { subscribedSessionIds: [...peer.subscribedSessionIds], subscribedChatSessionIds: [...peer.subscribedChatSessionIds], chatTranscriptOffsets: Object.fromEntries(peer.chatTranscriptOffsets), + rosterSubscribed: peer.rosterSubscribed, }); } peers.clear(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 5b795ec5d..595658616 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -118,6 +118,99 @@ describe("createSyncRemoteCommandService", () => { sessionFound: true, }); }); + + it("routes the canonical chat history snapshot command to the chat service", async () => { + const getChatEventHistory = vi.fn().mockReturnValue({ + sessionId: "chat-1", + events: [], + truncated: false, + sessionFound: true, + tailStartOffset: null, + }); + const { service } = createService({ + agentChatService: { getChatEventHistory }, + }); + + expect(service.getDescriptor("chat.getChatEventHistory")).toEqual({ + action: "chat.getChatEventHistory", + scope: "project", + policy: { viewerAllowed: true }, + }); + + const result = await service.execute(makePayload("chat.getChatEventHistory", { + sessionId: "chat-1", + maxEvents: 128, + })); + + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 128 }); + expect(result).toEqual({ + sessionId: "chat-1", + events: [], + truncated: false, + sessionFound: true, + tailStartOffset: null, + }); + }); + + it("routes subagent transcript fetches to the chat service", async () => { + const getSubagentTranscript = vi.fn().mockResolvedValue([ + { type: "assistant", uuid: "msg-1", sessionId: "child-1", parentToolUseId: null, message: {}, text: "done" }, + ]); + const { service } = createService({ + agentChatService: { getSubagentTranscript }, + }); + + expect(service.getDescriptor("chat.getSubagentTranscript")).toEqual({ + action: "chat.getSubagentTranscript", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + + const result = await service.execute(makePayload("chat.getSubagentTranscript", { + sessionId: "chat-1", + agentId: "agent-1", + taskId: "task-1", + laneId: "lane-1", + limit: 1, + offset: 2, + })); + + expect(getSubagentTranscript).toHaveBeenCalledWith({ + sessionId: "chat-1", + agentId: "agent-1", + taskId: "task-1", + laneId: "lane-1", + limit: 1, + offset: 2, + }); + expect(result).toEqual([ + { type: "assistant", uuid: "msg-1", sessionId: "child-1", parentToolUseId: null, message: {}, text: "done" }, + ]); + }); + + it("routes subagent roster fetches to the chat service", async () => { + const listSubagents = vi.fn().mockReturnValue([ + { taskId: "agent-1", agentId: "agent-1", agentType: "Sagan", description: "Read files", status: "stopped" }, + ]); + const { service } = createService({ + agentChatService: { listSubagents }, + }); + + expect(service.getDescriptor("chat.listSubagents")).toEqual({ + action: "chat.listSubagents", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + + const result = await service.execute(makePayload("chat.listSubagents", { + sessionId: "chat-1", + })); + + expect(listSubagents).toHaveBeenCalledWith({ sessionId: "chat-1" }); + expect(result).toEqual([ + { taskId: "agent-1", agentId: "agent-1", agentType: "Sagan", description: "Read files", status: "stopped" }, + ]); + }); }); describe("prs.land", () => { diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 2315bb17a..1fac99574 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3,6 +3,7 @@ import type { AgentChatCreateArgs, AgentChatArchiveArgs, AgentChatTranscriptEntry, + AgentChatEventHistorySnapshot, AgentChatApproveArgs, AgentChatCodexClearGoalArgs, AgentChatCodexGetGoalArgs, @@ -21,6 +22,8 @@ import type { AgentChatSession, AgentChatSessionSummary, AgentChatSteerArgs, + AgentChatSubagentListArgs, + AgentChatSubagentTranscriptArgs, AgentChatCancelSteerArgs, AgentChatEditSteerArgs, AgentChatDispatchSteerArgs, @@ -939,6 +942,28 @@ function parseGetTranscriptArgs(value: Record): { }; } +function parseAgentChatSubagentTranscriptArgs(value: Record): AgentChatSubagentTranscriptArgs { + const parsed: AgentChatSubagentTranscriptArgs = { + sessionId: requireString(value.sessionId, "chat.getSubagentTranscript requires sessionId."), + agentId: requireString(value.agentId, "chat.getSubagentTranscript requires agentId."), + }; + const taskId = asTrimmedString(value.taskId); + const laneId = asTrimmedString(value.laneId); + const limit = asOptionalNumber(value.limit); + const offset = asOptionalNumber(value.offset); + if (taskId) parsed.taskId = taskId; + if (laneId) parsed.laneId = laneId; + if (limit !== undefined) parsed.limit = limit; + if (offset !== undefined) parsed.offset = offset; + return parsed; +} + +function parseAgentChatSubagentListArgs(value: Record): AgentChatSubagentListArgs { + return { + sessionId: requireString(value.sessionId, "chat.listSubagents requires sessionId."), + }; +} + // Pagination cursor for chat.getTranscript. The cursor is the index (within // the session's full, append-only entry list) of the oldest entry returned by // the previous page; a request with `cursor` returns the page strictly BEFORE @@ -2283,6 +2308,12 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio }); register("chat.getSummary", { viewerAllowed: true }, async (payload) => requireService(args.agentChatService, "Agent chat service not available.").getSessionSummary(parseAgentChatGetSummaryArgs(payload).sessionId)); + register("chat.getChatEventHistory", { viewerAllowed: true }, async (payload): Promise => { + const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); + const sessionId = requireString(payload.sessionId, "chat.getChatEventHistory requires sessionId."); + const maxEvents = asOptionalNumber(payload.maxEvents); + return agentChatService.getChatEventHistory(sessionId, maxEvents == null ? undefined : { maxEvents }); + }); register("chat.getTranscript", { viewerAllowed: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const parsed = parseGetTranscriptArgs(payload); @@ -2316,6 +2347,14 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio nextCursor: hasMore ? String(oldestReturnedIndex) : null, }; }); + register("chat.getSubagentTranscript", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").getSubagentTranscript( + parseAgentChatSubagentTranscriptArgs(payload), + )); + register("chat.listSubagents", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.").listSubagents( + parseAgentChatSubagentListArgs(payload), + )); const getChatEventHistoryPage = async (payload: Record) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const sessionId = requireString(payload.sessionId, "chat.getChatEventHistoryPage requires sessionId."); diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 3deb0fbd8..92366ab03 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -51,6 +51,7 @@ import { SYNC_TAILNET_DISCOVERY_SERVICE_PORT, type SyncHostService, type SyncProjectCatalogProvider, + type SyncRosterProvider, type SyncRuntimeKind, } from "./syncHostService"; import { createSyncPairingStore } from "./syncPairingStore"; @@ -127,6 +128,7 @@ type SyncServiceArgs = { forceHostRole?: boolean; onStatusChanged?: (snapshot: SyncRoleSnapshot) => void; projectCatalogProvider?: SyncProjectCatalogProvider; + rosterProvider?: SyncRosterProvider; remoteCommandExecutor?: Pick; /** * Lazy accessor for the model picker store. iOS uses the `modelPicker.*` @@ -722,6 +724,7 @@ export function createSyncService(args: SyncServiceArgs) { runtimeVersion: args.appVersion ?? "", deviceRegistryService, projectCatalogProvider: args.projectCatalogProvider, + rosterProvider: args.rosterProvider, 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 0ce548b80..88c9a31c7 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5856,6 +5856,75 @@ describe("createAgentChatService", () => { const subagents = service.listSubagents({ sessionId: "unknown-id" }); expect(subagents).toEqual([]); }); + + it("hydrates stopped subagents from the persisted chat transcript", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const transcriptFile = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.mkdirSync(path.dirname(transcriptFile), { recursive: true }); + const placeholderStarted: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-06-30T01:00:00.000Z", + event: { + type: "subagent_started", + taskId: "call-spawn-1", + parentToolUseId: "call-spawn-1", + description: "Inspect the shared chat renderer", + turnId: "turn-1", + }, + }; + const agentStarted: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-06-30T01:00:01.000Z", + event: { + type: "subagent_started", + taskId: "agent-thread-1", + agentId: "agent-thread-1", + agentType: "Sagan", + parentToolUseId: "call-spawn-1", + description: "Inspect the shared chat renderer", + turnId: "turn-1", + }, + }; + const stopped: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-06-30T01:02:00.000Z", + event: { + type: "subagent_result", + taskId: "agent-thread-1", + agentId: "agent-thread-1", + agentType: "Sagan", + parentToolUseId: "call-spawn-1", + status: "stopped", + summary: "Halted by parent turn.", + turnId: "turn-1", + }, + }; + fs.writeFileSync( + transcriptFile, + `${JSON.stringify(placeholderStarted)}\n${JSON.stringify(agentStarted)}\n${JSON.stringify(stopped)}\n`, + "utf8", + ); + + const subagents = service.listSubagents({ sessionId: session.id }); + + expect(subagents).toEqual([ + expect.objectContaining({ + taskId: "agent-thread-1", + agentId: "agent-thread-1", + agentType: "Sagan", + parentToolUseId: "call-spawn-1", + description: "Inspect the shared chat renderer", + status: "stopped", + summary: "Halted by parent turn.", + endTimestamp: "2026-06-30T01:02:00.000Z", + }), + ]); + }); }); // -------------------------------------------------------------------------- @@ -11990,6 +12059,95 @@ describe("createAgentChatService", () => { turnId: "turn-other", }); }); + + it("keeps paragraph boundaries when same-turn assistant text resumes after another event", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const events: AgentChatEventEnvelope[] = [ + { + sessionId: session.id, + timestamp: "2026-05-18T23:40:00.000Z", + sequence: 1, + event: { + type: "text", + text: "The fake bottom row is now a 1-point sentinel.", + turnId: "turn-formatting", + }, + }, + { + sessionId: session.id, + timestamp: "2026-05-18T23:40:01.000Z", + sequence: 2, + event: { + type: "tool_call", + tool: "shell", + args: {}, + itemId: "tool-1", + turnId: "turn-formatting", + }, + }, + { + sessionId: session.id, + timestamp: "2026-05-18T23:40:02.000Z", + sequence: 3, + event: { + type: "text", + text: "Next I am threading status through the end marker.", + turnId: "turn-formatting", + }, + }, + ]; + fs.writeFileSync(path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`), "ignored\n", "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue(events); + + const transcript = await service.getChatTranscript({ sessionId: session.id }); + + expect(transcript.entries).toHaveLength(1); + expect(transcript.entries[0]).toMatchObject({ + role: "assistant", + text: "The fake bottom row is now a 1-point sentinel.\n\nNext I am threading status through the end marker.", + turnId: "turn-formatting", + }); + }); + + it("includes assistant message ids in transcript entries", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + const events: AgentChatEventEnvelope[] = [ + { + sessionId: session.id, + timestamp: "2026-05-18T23:40:00.000Z", + sequence: 1, + event: { + type: "text", + text: "Stable identified message.", + messageId: "message-1", + itemId: "item-1", + turnId: "turn-ids", + }, + }, + ]; + fs.writeFileSync(path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`), "ignored\n", "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue(events); + + const transcript = await service.getChatTranscript({ sessionId: session.id }); + + expect(transcript.entries[0]).toMatchObject({ + role: "assistant", + text: "Stable identified message.", + messageId: "message-1", + itemId: "item-1", + turnId: "turn-ids", + }); + }); }); describe("getChatEventHistory", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index eb33ec304..c869d8649 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -5692,6 +5692,13 @@ export function createAgentChatService(args: { envelopes: AgentChatEventEnvelope[]; }; const transcriptHistoryCacheBySession = new Map>(); + type TranscriptSubagentSnapshotCacheEntry = { + transcriptPath: string; + size: number; + mtimeMs: number; + snapshots: AgentChatSubagentSnapshot[]; + }; + const transcriptSubagentSnapshotCacheBySession = new Map(); const recordChatEventInHistory = (envelope: AgentChatEventEnvelope): void => { const current = eventHistoryBySession.get(envelope.sessionId) ?? []; @@ -6431,9 +6438,16 @@ export function createAgentChatService(args: { return null; }; - const trackSubagentEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { - if (event.type !== "subagent_started" && event.type !== "subagent_progress" && event.type !== "subagent_result") return; - const map = ensureSubagentSnapshotMap(managed.session.id); + const isSubagentLifecycleEvent = ( + event: AgentChatEvent, + ): event is Extract => + event.type === "subagent_started" || event.type === "subagent_progress" || event.type === "subagent_result"; + + const trackSubagentEventInMap = ( + map: Map, + event: Extract, + timestamp: string, + ): void => { if (event.type === "subagent_started") { const key = event.agentId ?? event.taskId; const parentMatch = findSubagentSnapshotByParent(map, event.parentToolUseId); @@ -6448,7 +6462,7 @@ export function createAgentChatService(args: { description: event.description, status: "running", turnId: event.turnId ?? undefined, - startTimestamp: previous?.startTimestamp ?? nowIso(), + startTimestamp: previous?.startTimestamp ?? timestamp, background: event.background ?? false, }); return; @@ -6468,7 +6482,7 @@ export function createAgentChatService(args: { description: event.description?.trim() || previous?.description || "Subagent task", status: "running", turnId: event.turnId ?? previous?.turnId, - startTimestamp: previous?.startTimestamp ?? nowIso(), + startTimestamp: previous?.startTimestamp ?? timestamp, summary: event.summary.trim() || previous?.summary, lastToolName: event.lastToolName ?? previous?.lastToolName, background: previous?.background, @@ -6496,7 +6510,7 @@ export function createAgentChatService(args: { status, turnId: event.turnId ?? previous?.turnId, startTimestamp: previous?.startTimestamp, - endTimestamp: nowIso(), + endTimestamp: timestamp, summary: event.summary ?? previous?.summary, finalSummary: event.finalSummary ?? event.summary ?? previous?.finalSummary, lastToolName: previous?.lastToolName, @@ -6505,10 +6519,10 @@ export function createAgentChatService(args: { }); }; - const getTrackedSubagents = (sessionId: string): AgentChatSubagentSnapshot[] => { - const snapshots = subagentStates.get(sessionId); - if (!snapshots) return []; - return Array.from(snapshots.values()); + const trackSubagentEvent = (managed: ManagedChatSession, event: AgentChatEvent): void => { + if (!isSubagentLifecycleEvent(event)) return; + const map = ensureSubagentSnapshotMap(managed.session.id); + trackSubagentEventInMap(map, event, nowIso()); }; const previewSessionToolNames = ({ @@ -6685,6 +6699,7 @@ export function createAgentChatService(args: { const entries: TranscriptDraftEntry[] = []; const assistantDraftsByKey = new Map(); let assistantDraft: (AgentChatTranscriptEntry & BufferedAssistantText) | null = null; + let lastAssistantTranscriptMergeKey: string | null = null; const flushAssistantDraft = (): void => { if (!assistantDraft) return; const text = assistantDraft.text.trim(); @@ -6694,6 +6709,8 @@ export function createAgentChatService(args: { text, timestamp: assistantDraft.timestamp, ...(assistantDraft.turnId ? { turnId: assistantDraft.turnId } : {}), + ...(assistantDraft.messageId ? { messageId: assistantDraft.messageId } : {}), + ...(assistantDraft.itemId ? { itemId: assistantDraft.itemId } : {}), }); } assistantDraft = null; @@ -6705,11 +6722,19 @@ export function createAgentChatService(args: { if (turnId) return `turn:${turnId}`; return null; }; + const mergeAssistantTranscriptText = (existing: string, incoming: string, contiguous: boolean): string => { + if (contiguous) return `${existing}${incoming}`; + if (!existing.trim().length) return incoming; + if (!incoming.trim().length) return existing; + if (existing.endsWith("\n") || incoming.startsWith("\n")) return `${existing}${incoming}`; + return `${existing.trimEnd()}\n\n${incoming.trimStart()}`; + }; for (const entry of envelopes) { if (entry.sessionId !== sessionId) continue; if (entry.event.type === "user_message") { flushAssistantDraft(); + lastAssistantTranscriptMergeKey = null; const text = entry.event.text.trim(); if (!text.length) continue; const displayText = typeof entry.event.displayText === "string" && entry.event.displayText.trim().length > 0 @@ -6731,7 +6756,12 @@ export function createAgentChatService(args: { flushAssistantDraft(); const existing = assistantDraftsByKey.get(mergeKey); if (existing) { - existing.text = `${existing.text}${entry.event.text}`; + existing.text = mergeAssistantTranscriptText( + existing.text, + entry.event.text, + lastAssistantTranscriptMergeKey === mergeKey, + ); + lastAssistantTranscriptMergeKey = mergeKey; continue; } const draft: TranscriptDraftEntry = { @@ -6744,10 +6774,12 @@ export function createAgentChatService(args: { }; assistantDraftsByKey.set(mergeKey, draft); entries.push(draft); + lastAssistantTranscriptMergeKey = mergeKey; continue; } if (assistantDraft && canAppendBufferedAssistantText(assistantDraft, entry.event)) { assistantDraft.text = `${assistantDraft.text}${entry.event.text}`; + lastAssistantTranscriptMergeKey = null; continue; } flushAssistantDraft(); @@ -6759,9 +6791,11 @@ export function createAgentChatService(args: { ...(entry.event.turnId ? { turnId: entry.event.turnId } : {}), ...(entry.event.itemId ? { itemId: entry.event.itemId } : {}), }; + lastAssistantTranscriptMergeKey = null; continue; } flushAssistantDraft(); + lastAssistantTranscriptMergeKey = null; } flushAssistantDraft(); return entries @@ -6771,6 +6805,8 @@ export function createAgentChatService(args: { ...(entry.displayText ? { displayText: entry.displayText } : {}), timestamp: entry.timestamp, ...(entry.turnId ? { turnId: entry.turnId } : {}), + ...(entry.messageId ? { messageId: entry.messageId } : {}), + ...(entry.itemId ? { itemId: entry.itemId } : {}), })) .filter((entry) => entry.text.length > 0); }; @@ -7039,6 +7075,100 @@ export function createAgentChatService(args: { } }; + const readSubagentSnapshotsFromTranscript = (sessionId: string): AgentChatSubagentSnapshot[] => { + const transcriptPath = resolveBestTranscriptPathForSessionId(sessionId, managedSessions.get(sessionId)); + if (!transcriptPath) return []; + try { + const stat = fs.statSync(transcriptPath); + const cached = transcriptSubagentSnapshotCacheBySession.get(sessionId); + if ( + cached + && cached.transcriptPath === transcriptPath + && cached.size === stat.size + && cached.mtimeMs === stat.mtimeMs + ) { + return cached.snapshots.slice(); + } + + const map = new Map(); + const raw = fs.readFileSync(transcriptPath, "utf8"); + for (const line of raw.split(/\r?\n/)) { + if (!line.includes("subagent_")) continue; + let envelope: AgentChatEventEnvelope | null = null; + try { + const parsed = JSON.parse(line) as AgentChatEventEnvelope; + envelope = parsed && typeof parsed === "object" ? parsed : null; + } catch { + envelope = null; + } + if (!envelope || envelope.sessionId !== sessionId || isCodexSubagentTranscriptEnvelope(envelope)) continue; + const event = envelope.event; + if (!event || typeof event !== "object" || !isSubagentLifecycleEvent(event)) continue; + trackSubagentEventInMap(map, event, envelope.timestamp || nowIso()); + } + const snapshots = Array.from(map.values()); + transcriptSubagentSnapshotCacheBySession.set(sessionId, { + transcriptPath, + size: stat.size, + mtimeMs: stat.mtimeMs, + snapshots, + }); + while (transcriptSubagentSnapshotCacheBySession.size > CHAT_EVENT_HISTORY_TRANSCRIPT_CACHE_MAX_SESSIONS) { + const oldestSessionId = transcriptSubagentSnapshotCacheBySession.keys().next().value; + if (typeof oldestSessionId !== "string") break; + transcriptSubagentSnapshotCacheBySession.delete(oldestSessionId); + } + return snapshots.slice(); + } catch { + return []; + } + }; + + const subagentSnapshotKey = (snapshot: AgentChatSubagentSnapshot): string => + snapshot.agentId?.trim() || snapshot.taskId; + + const mergeSubagentSnapshots = ( + historical: AgentChatSubagentSnapshot[], + live: AgentChatSubagentSnapshot[], + ): AgentChatSubagentSnapshot[] => { + if (!historical.length) return live.slice(); + if (!live.length) return historical.slice(); + const order: string[] = []; + const byKey = new Map(); + const put = (snapshot: AgentChatSubagentSnapshot): void => { + const key = subagentSnapshotKey(snapshot); + if (!byKey.has(key)) order.push(key); + byKey.set(key, { ...byKey.get(key), ...snapshot }); + }; + historical.forEach(put); + live.forEach(put); + return order.flatMap((key) => { + const snapshot = byKey.get(key); + return snapshot ? [snapshot] : []; + }); + }; + + const compareSubagentSnapshotsNewestFirst = ( + left: AgentChatSubagentSnapshot, + right: AgentChatSubagentSnapshot, + ): number => { + const leftTime = Date.parse(left.startTimestamp ?? left.endTimestamp ?? ""); + const rightTime = Date.parse(right.startTimestamp ?? right.endTimestamp ?? ""); + const leftValue = Number.isFinite(leftTime) ? leftTime : 0; + const rightValue = Number.isFinite(rightTime) ? rightTime : 0; + return rightValue - leftValue; + }; + + const getTrackedSubagents = (sessionId: string): AgentChatSubagentSnapshot[] => { + const trimmedId = sessionId.trim(); + if (!trimmedId.length) return []; + const row = sessionService.get(trimmedId); + if (!row || !isChatToolType(row.toolType)) return []; + const live = Array.from(subagentStates.get(trimmedId)?.values() ?? []); + const historical = readSubagentSnapshotsFromTranscript(trimmedId); + return mergeSubagentSnapshots(historical, live).sort(compareSubagentSnapshotsNewestFirst); + }; + const envelopeDedupKey = (entry: AgentChatEventEnvelope): string => { // Cross-run-safe key: two envelopes are true duplicates iff timestamp, // type, AND payload all match. Sequence numbers can't be trusted (they diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 6674eae04..5aba3dd50 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -90,6 +90,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "chat.create", "chat.getSummary", "chat.getTranscript", + "chat.getChatEventHistory", "chat.send", "chat.interrupt", "chat.steer", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 31c622d8f..fe5015b69 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -1055,6 +1055,8 @@ export type AgentChatTranscriptEntry = { displayText?: string; timestamp: string; turnId?: string; + messageId?: string; + itemId?: string; }; export type AgentChatSubagentSnapshot = { diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 2243859f5..63b24f000 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -291,6 +291,93 @@ export type SyncProjectCatalogChunkPayload = { projects: SyncMobileProjectSummary[]; }; +// --------------------------------------------------------------------------- +// All-projects chat roster (mobile hub) +// +// A lightweight, machine-wide projection of every project's lanes + chat +// sessions, so the mobile hub can render all projects' chats-grouped-by-lane at +// once without activating each project. Sourced cheaply from disk (each +// project's `/.ade/ade.db` + `.ade/cache/chat-sessions/*.json`) for +// un-booted projects, with live running/awaiting fidelity overlaid for any +// project scope currently booted on the runtime. Transcripts are NOT included +// here — they load on demand when a chat is opened (which activates that +// project's full sync). Pushed over dedicated `roster_snapshot` / `roster_delta` +// envelopes (subscribe handshake mirrors `chat_subscribe`); oversized snapshots +// ride the generic `envelope_chunk` mechanism transparently. +// --------------------------------------------------------------------------- + +/** + * Status of a roster chat row. For an un-booted project (no live runtime) the + * truthful states are `idle` / `ended` / `awaiting` (the last persisted to + * disk); `running` and live `awaiting` are only emitted for a booted scope. + */ +export type SyncRosterChatStatus = "running" | "awaiting" | "idle" | "ended" | "failed"; + +export type SyncRosterChat = { + id: string; // sessionId + laneId: string; + /** Parent chat/session id for attached shell rows. Mirrors TerminalSessionSummary.chatSessionId. */ + chatSessionId?: string | null; + title?: string | null; + provider?: string | null; + model?: string | null; + toolType?: string | null; // distinguishes chat vs CLI rows + status: SyncRosterChatStatus; + awaitingInput?: boolean; + pinned?: boolean; + archived?: boolean; + lastActivityAt?: string | null; + preview?: string | null; // last-output preview, hard-truncated (~120 chars) +}; + +export type SyncRosterLane = { + id: string; + name: string; + color?: string | null; + icon?: string | null; + laneType?: string | null; + branchRef?: string | null; +}; + +export type SyncRosterProject = { + projectId: string; + rootPath?: string | null; + displayName: string; + iconDataUrl?: string | null; + lastOpenedAt?: string | null; + /** true ⇒ live running/awaiting fidelity; false ⇒ disk-derived status only. */ + booted: boolean; + runningCount: number; + /** awaiting-input + failed sessions — drives the hub attention bubbles. */ + attentionCount: number; + lanes: SyncRosterLane[]; + chats: SyncRosterChat[]; +}; + +/** Full roster snapshot — sent on subscribe and on resync. */ +export type SyncRosterSnapshotPayload = { + seq: number; + projects: SyncRosterProject[]; +}; + +/** + * Incremental roster update. `changed` upserts whole project entries (per-project + * delta granularity — simple and cheap since a project's row set is small); + * `removed` lists projectIds no longer present. + */ +export type SyncRosterDeltaPayload = { + seq: number; + changed?: SyncRosterProject[]; + removed?: string[]; +}; + +export type SyncRosterSubscribePayload = { + /** Last seq the client holds; lets the host send a delta instead of a snapshot. */ + sinceSeq?: number | null; +}; + +export type SyncRosterUnsubscribePayload = Record; + export type SyncProjectSwitchRequestPayload = { projectId?: string | null; rootPath?: string | null; @@ -746,6 +833,9 @@ export type SyncRemoteCommandAction = | "chat.listSessions" | "chat.getSummary" | "chat.getTranscript" + | "chat.getChatEventHistory" + | "chat.listSubagents" + | "chat.getSubagentTranscript" | "chat.create" | "chat.send" | "chat.interrupt" @@ -1003,6 +1093,10 @@ export type SyncChatSubscribeEnvelope = SyncEnvelopeWithPayload<"chat_subscribe" export type SyncChatUnsubscribeEnvelope = SyncEnvelopeWithPayload<"chat_unsubscribe", SyncChatUnsubscribePayload>; export type SyncChatEventEnvelope = SyncEnvelopeWithPayload<"chat_event", SyncChatEventPayload>; export type SyncBrainStatusEnvelope = SyncEnvelopeWithPayload<"brain_status", SyncBrainStatusPayload>; +export type SyncRosterSubscribeEnvelope = SyncEnvelopeWithPayload<"roster_subscribe", SyncRosterSubscribePayload>; +export type SyncRosterUnsubscribeEnvelope = SyncEnvelopeWithPayload<"roster_unsubscribe", SyncRosterUnsubscribePayload>; +export type SyncRosterSnapshotEnvelope = SyncEnvelopeWithPayload<"roster_snapshot", SyncRosterSnapshotPayload>; +export type SyncRosterDeltaEnvelope = SyncEnvelopeWithPayload<"roster_delta", SyncRosterDeltaPayload>; export type SyncCommandEnvelope = SyncEnvelopeWithPayload<"command", SyncCommandPayload>; export type SyncCommandAckEnvelope = SyncEnvelopeWithPayload<"command_ack", SyncCommandAckPayload>; export type SyncCommandResultEnvelope = SyncEnvelopeWithPayload<"command_result", SyncCommandResultPayload>; @@ -1062,6 +1156,10 @@ export type SyncEnvelope = | SyncChatUnsubscribeEnvelope | SyncChatEventEnvelope | SyncBrainStatusEnvelope + | SyncRosterSubscribeEnvelope + | SyncRosterUnsubscribeEnvelope + | SyncRosterSnapshotEnvelope + | SyncRosterDeltaEnvelope | SyncCommandEnvelope | SyncCommandAckEnvelope | SyncCommandResultEnvelope diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 49c89b639..7074c0e82 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ E1000000000000000000002B /* WorkChatSessionView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002B /* WorkChatSessionView+Actions.swift */; }; E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */; }; E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002D /* WorkChatRichCardViews.swift */; }; + E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000054 /* WorkPlanComposerViews.swift */; }; E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000050 /* WorkChatAttachmentTray.swift */; }; E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000052 /* LaneDeeplinkHelpers.swift */; }; E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000053 /* LaneDetailGitActionsPane.swift */; }; @@ -88,6 +89,11 @@ E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004C /* FilesDetailScreen+Actions.swift */; }; E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */; }; E2000000000000000000004E /* FilesSearchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004E /* FilesSearchScreen.swift */; }; + E2000000000000000000004F /* RemoteRosterModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2000000000000000000004F /* RemoteRosterModels.swift */; }; + E20000000000000000000050 /* HubScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000050 /* HubScreen.swift */; }; + E20000000000000000000051 /* HubComponents.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000051 /* HubComponents.swift */; }; + E20000000000000000000052 /* HubScreen+ChatNavigation.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000052 /* HubScreen+ChatNavigation.swift */; }; + E20000000000000000000053 /* HubComposerDrawer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D20000000000000000000053 /* HubComposerDrawer.swift */; }; 60F4CDDB763C0A9F0E650B40 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 31EC445F22FD38F90C16343E /* Foundation.framework */; }; 63A9C60B0E0F0E2707634B2E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8943C47805A871A4E4A4BF68 /* Assets.xcassets */; }; 6BDC22C6450AF0B3CBDB2650 /* FilesTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EBFB65019F4C3F8A89428CE /* FilesTabView.swift */; }; @@ -237,6 +243,7 @@ D1000000000000000000002B /* WorkChatSessionView+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Actions.swift"; path = "ADE/Views/Work/WorkChatSessionView+Actions.swift"; sourceTree = ""; }; D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatHeaderAndMessageViews.swift; path = ADE/Views/Work/WorkChatHeaderAndMessageViews.swift; sourceTree = ""; }; D1000000000000000000002D /* WorkChatRichCardViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatRichCardViews.swift; path = ADE/Views/Work/WorkChatRichCardViews.swift; sourceTree = ""; }; + D10000000000000000000054 /* WorkPlanComposerViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPlanComposerViews.swift; path = ADE/Views/Work/WorkPlanComposerViews.swift; sourceTree = ""; }; D10000000000000000000050 /* WorkChatAttachmentTray.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentTray.swift; path = ADE/Views/Work/WorkChatAttachmentTray.swift; sourceTree = ""; }; D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = ""; }; D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = ""; }; @@ -292,6 +299,11 @@ D2000000000000000000004C /* FilesDetailScreen+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "FilesDetailScreen+Actions.swift"; path = "ADE/Views/Files/FilesDetailScreen+Actions.swift"; sourceTree = ""; }; D2000000000000000000004D /* FilesWorkspacePickerDropdown.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesWorkspacePickerDropdown.swift; path = ADE/Views/Files/FilesWorkspacePickerDropdown.swift; sourceTree = ""; }; D2000000000000000000004E /* FilesSearchScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FilesSearchScreen.swift; path = ADE/Views/Files/FilesSearchScreen.swift; sourceTree = ""; }; + D2000000000000000000004F /* RemoteRosterModels.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = RemoteRosterModels.swift; path = ADE/Models/RemoteRosterModels.swift; sourceTree = ""; }; + D20000000000000000000050 /* HubScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubScreen.swift; path = ADE/Views/Hub/HubScreen.swift; sourceTree = ""; }; + D20000000000000000000051 /* HubComponents.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubComponents.swift; path = ADE/Views/Hub/HubComponents.swift; sourceTree = ""; }; + D20000000000000000000052 /* HubScreen+ChatNavigation.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "HubScreen+ChatNavigation.swift"; path = "ADE/Views/Hub/HubScreen+ChatNavigation.swift"; sourceTree = ""; }; + D20000000000000000000053 /* HubComposerDrawer.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = HubComposerDrawer.swift; path = ADE/Views/Hub/HubComposerDrawer.swift; sourceTree = ""; }; 14C0DF7FEB4C2EB854BAC888 /* ADETests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ADETests.swift; path = ADETests/ADETests.swift; sourceTree = ""; }; D30000000000000000000001 /* AttentionDrawerModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerModel.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerModel.swift; sourceTree = ""; }; D30000000000000000000002 /* AttentionDrawerButton.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AttentionDrawerButton.swift; path = ADE/Views/AttentionDrawer/AttentionDrawerButton.swift; sourceTree = ""; }; @@ -477,6 +489,7 @@ isa = PBXGroup; children = ( 02B9E655310A24835B5CFC3B /* Components */, + HB00000000000000000000F1 /* Hub */, D20000000000000000000041 /* Files */, A10000000000000000000001 /* Lanes */, D10000000000000000000021 /* Work */, @@ -491,6 +504,17 @@ name = Views; sourceTree = ""; }; + HB00000000000000000000F1 /* Hub */ = { + isa = PBXGroup; + children = ( + D20000000000000000000050 /* HubScreen.swift */, + D20000000000000000000051 /* HubComponents.swift */, + D20000000000000000000052 /* HubScreen+ChatNavigation.swift */, + D20000000000000000000053 /* HubComposerDrawer.swift */, + ); + name = Hub; + sourceTree = ""; + }; D30000000000000000000004 /* AttentionDrawer */ = { isa = PBXGroup; children = ( @@ -612,6 +636,7 @@ D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D1000000000000000000002F /* WorkArtifactTerminalViews.swift */, @@ -756,6 +781,7 @@ isa = PBXGroup; children = ( 483C5F1818BAE74B19B84617 /* RemoteModels.swift */, + D2000000000000000000004F /* RemoteRosterModels.swift */, ); name = Models; sourceTree = ""; @@ -988,6 +1014,11 @@ E2000000000000000000004C /* FilesDetailScreen+Actions.swift in Sources */, E2000000000000000000004D /* FilesWorkspacePickerDropdown.swift in Sources */, E2000000000000000000004E /* FilesSearchScreen.swift in Sources */, + E2000000000000000000004F /* RemoteRosterModels.swift in Sources */, + E20000000000000000000050 /* HubScreen.swift in Sources */, + E20000000000000000000051 /* HubComponents.swift in Sources */, + E20000000000000000000052 /* HubScreen+ChatNavigation.swift in Sources */, + E20000000000000000000053 /* HubComposerDrawer.swift in Sources */, B10000000000000000000002 /* LaneAttachSheet.swift in Sources */, B10000000000000000000003 /* LaneBatchManageSheet.swift in Sources */, B10000000000000000000004 /* LaneChatLaunchSheet.swift in Sources */, @@ -1084,6 +1115,7 @@ E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, + E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */, E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 0263392a4..c8437a1f8 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -43,9 +43,13 @@ struct ContentView: View { } var body: some View { + // The hub is the home screen: all projects open and ready. Opening a + // project swaps to the detailed tab view. Keep these roots mutually + // mounted: if the hub stays alive under the project tabs it continues + // rebuilding roster cards while the user scrolls Work chat detail. Group { if syncService.shouldShowProjectHome { - ProjectHomeView() + HubScreen() } else { rootTabs } @@ -152,383 +156,3 @@ struct ContentView: View { } } } - -private struct ProjectHomeView: View { - @EnvironmentObject private var syncService: SyncService - @State private var addProjectSheetPresented = false - - private var attachedMachineLabel: String { - let trimmedHost = syncService.hostName?.trimmingCharacters(in: .whitespacesAndNewlines) - let host = (trimmedHost?.isEmpty == false) ? trimmedHost! : nil - switch syncService.connectionState { - case .connected, .syncing: - if let host { return "Attached to \(host)" } - return "Attached to machine" - case .connecting: - if let host { return "Connecting to \(host)…" } - return "Connecting…" - case .error: - if let host { return "Cannot reach \(host)" } - return "Connection error" - case .disconnected: - return "No machine attached" - } - } - - private var attachedMachineTint: Color { - let health = syncService.connectionHealth - switch health.transport { - case .connected: - return health.load == .strained ? ADEColor.warning : ADEColor.success - case .connecting: - return ADEColor.warning - case .unreachable: - // Surface the connection-error affordance — collapsing this with - // .disconnected loses the "something is wrong" tint when a host that - // was reachable goes silent. - return ADEColor.danger - case .disconnected: - return ADEColor.textMuted - } - } - - private var canShowProjectRows: Bool { - syncService.connectionState == .connected || syncService.connectionState == .syncing - } - - var body: some View { - NavigationStack { - ZStack(alignment: .top) { - welcomeBackground - ScrollView { - VStack(spacing: 30) { - welcomeHero - attachedMachineBanner - projectSection - } - .frame(maxWidth: 520) - .frame(maxWidth: .infinity) - .padding(.horizontal, 22) - .padding(.top, 88) - .padding(.bottom, 38) - } - .scrollIndicators(.hidden) - } - .navigationTitle("") - .navigationBarTitleDisplayMode(.inline) - .toolbar(.hidden, for: .navigationBar) - .sheet(isPresented: $addProjectSheetPresented) { - RemoteProjectAddSheet() - .environmentObject(syncService) - } - } - } - - private var welcomeBackground: some View { - ZStack { - ADEColor.pageBackground - RadialGradient( - colors: [ - ADEColor.purpleAccent.opacity(0.28), - ADEColor.purpleAccent.opacity(0.10), - Color.clear - ], - center: .center, - startRadius: 20, - endRadius: 210 - ) - .frame(width: 420, height: 420) - .offset(y: 66) - .blur(radius: 6) - } - .ignoresSafeArea() - } - - private var welcomeHero: some View { - Image("BrandMark") - .resizable() - .renderingMode(.original) - .interpolation(.high) - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 280) - .frame(height: 142) - .frame(maxWidth: .infinity) - .shadow(color: ADEColor.purpleAccent.opacity(0.45), radius: 24, x: 0, y: 0) - .accessibilityLabel("ADE") - } - - private var attachedMachineBanner: some View { - Button { - syncService.settingsPresented = true - } label: { - HStack(spacing: 10) { - Circle() - .fill(attachedMachineTint) - .frame(width: 8, height: 8) - Image(systemName: "desktopcomputer") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - Text(attachedMachineLabel) - .font(.system(.footnote, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - Image(systemName: "chevron.right") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(ADEColor.textMuted) - } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(ADEColor.cardBackground.opacity(0.62), in: Capsule()) - .overlay( - Capsule().stroke(ADEColor.border.opacity(0.80), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(attachedMachineLabel) - .accessibilityHint("Opens machine connection settings.") - } - - private var projectSection: some View { - VStack(spacing: 14) { - Text("PROJECTS") - .font(.system(.caption, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) - .tracking(0.8) - - if syncService.canRunRemoteProjectActions { - ProjectHomeAddProjectRow { - addProjectSheetPresented = true - } - } - - if !canShowProjectRows || syncService.projects.isEmpty { - emptyProjects - } else { - LazyVStack(spacing: 8) { - ForEach(syncService.projects) { project in - ProjectHomeRow( - project: project, - isActive: syncService.isActiveProject(project), - isSwitching: syncService.isSwitchingProject(project), - isDisabled: syncService.isProjectSwitching - ) { - syncService.selectProject(project) - } onForget: { - syncService.forgetProject(project) - } - } - } - } - } - } - - private var emptyProjects: some View { - Group { - if syncService.connectionState == .disconnected || syncService.connectionState == .error { - noMachineConnectedCard - } else { - emptyProjectsActionCard - } - } - } - - private var noMachineConnectedCard: some View { - Text("No machine connected") - .font(.system(.subheadline, design: .rounded).weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .padding(14) - .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(ADEColor.border.opacity(0.80), lineWidth: 1) - ) - .accessibilityLabel("No machine connected") - } - - private var emptyProjectsActionCard: some View { - Button { - syncService.settingsPresented = true - } label: { - HStack(spacing: 12) { - ProjectHomeIcon(iconDataUrl: nil, isActive: false) - VStack(alignment: .leading, spacing: 4) { - Text(emptyProjectsTitle) - .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text(emptyProjectsSubtitle) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - Spacer(minLength: 8) - Image(systemName: "desktopcomputer") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(ADEColor.border.opacity(0.80), lineWidth: 1) - ) - } - .buttonStyle(.plain) - } - - private var emptyProjectsTitle: String { - switch syncService.connectionState { - case .connected, .syncing: return "No projects on machine" - case .connecting: return "Connecting to machine" - case .error, .disconnected: return "No projects on machine" - } - } - - private var emptyProjectsSubtitle: String { - switch syncService.connectionState { - case .connected, .syncing: - return "Open a project on \(syncService.hostName ?? "your machine")" - case .connecting: - return syncService.hostName ?? "Projects appear after this iPhone connects" - case .error, .disconnected: - return "Open a project on your machine" - } - } -} - -private struct ProjectHomeAddProjectRow: View { - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack(spacing: 12) { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(ADEColor.accent.opacity(0.16)) - .frame(width: 38, height: 38) - Image(systemName: "plus") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(ADEColor.accent) - } - - Text("Add project") - .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - - Spacer(minLength: 8) - - Image(systemName: "chevron.right") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.accent.opacity(0.70)) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(ADEColor.accent.opacity(0.40), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .accessibilityLabel("Add project") - .accessibilityHint("Open, create, or clone a project on the connected machine.") - } -} - -private struct ProjectHomeIcon: View { - let iconDataUrl: String? - let isActive: Bool - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(isActive ? ADEColor.accent.opacity(0.16) : ADEColor.recessedBackground) - .frame(width: 38, height: 38) - if let image = projectIconImage(from: iconDataUrl) { - Image(uiImage: image).projectIconStyle(size: 24, cornerRadius: 4) - } else { - Image(systemName: "folder") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textSecondary) - } - } - } -} - -private struct ProjectHomeRow: View { - let project: MobileProjectSummary - let isActive: Bool - let isSwitching: Bool - let isDisabled: Bool - let action: () -> Void - let onForget: () -> Void - - var body: some View { - Button(action: action) { - HStack(alignment: .center, spacing: 12) { - ProjectHomeIcon(iconDataUrl: project.iconDataUrl, isActive: isActive) - - VStack(alignment: .leading, spacing: 4) { - Text(project.displayName) - .font(.system(.subheadline, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - - if let rootPath = project.rootPath, !rootPath.isEmpty { - Text(rootPath) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - .lineLimit(1) - } - } - - Spacer(minLength: 8) - - if isSwitching { - ProgressView() - .controlSize(.small) - } else { - VStack(alignment: .trailing, spacing: 6) { - Text("\(project.laneCount) lane\(project.laneCount == 1 ? "" : "s")") - .font(.system(.caption2, design: .rounded).weight(.semibold)) - .foregroundStyle(ADEColor.accent) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background(ADEColor.accent.opacity(0.16), in: Capsule()) - if let lastOpened = projectHomeRelativeTimestamp(project.lastOpenedAt) { - Text("Last opened \(lastOpened)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(ADEColor.textMuted) - } - } - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(isActive ? ADEColor.accent.opacity(0.55) : ADEColor.border.opacity(0.80), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .disabled(isDisabled) - .contextMenu { - Button(role: .destructive, action: onForget) { - Label("Remove from list", systemImage: "trash") - } - } - .accessibilityAction(named: "Remove from list", onForget) - } -} - -private func projectHomeRelativeTimestamp(_ value: String?) -> String? { - guard let value, !value.isEmpty else { return nil } - let fractional = ISO8601DateFormatter() - fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let plain = ISO8601DateFormatter() - guard let date = fractional.date(from: value) ?? plain.date(from: value) else { return nil } - return RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) -} diff --git a/apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift b/apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift index ef8271b11..7580bef1d 100644 --- a/apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift +++ b/apps/ios/ADE/Debug/ADEInspectorKit/ADEInspectable.swift @@ -53,6 +53,19 @@ private struct ADEInspectorSnapshot: Codable, Equatable { let elements: [ADEInspectorElementSnapshot] } +private enum ADEInspectorRuntime { + static let snapshotsEnabled: Bool = { + let environment = ProcessInfo.processInfo.environment + if let explicit = environment["ADE_INSPECTOR_ENABLED"]?.lowercased() { + return explicit == "1" || explicit == "true" || explicit == "yes" + } + if environment["ADE_INSPECTOR_SESSION_ID"] != nil || environment["ADE_INSPECTOR_MODE"] != nil { + return true + } + return ProcessInfo.processInfo.arguments.contains("--ade-inspector-mode") + }() +} + private actor ADEInspectorSnapshotWriter { static let shared = ADEInspectorSnapshotWriter() @@ -62,12 +75,12 @@ private actor ADEInspectorSnapshotWriter { return encoder }() - private var lastData: Data? + private var lastSignature: String? - func write(_ snapshot: ADEInspectorSnapshot) async { + func write(_ snapshot: ADEInspectorSnapshot, signature: String) async { + guard signature != lastSignature else { return } guard let data = try? encoder.encode(snapshot) else { return } - guard data != lastData else { return } - lastData = data + lastSignature = signature let documentsDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first guard let documentsDirectory else { return } @@ -76,8 +89,10 @@ private actor ADEInspectorSnapshotWriter { } } +private let adeInspectorTimestampFormatter = ISO8601DateFormatter() + private func adeInspectorIsoTimestamp() -> String { - ISO8601DateFormatter().string(from: Date()) + adeInspectorTimestampFormatter.string(from: Date()) } private func adeInspectorElementId(payload: ADEInspectablePayload) -> String { @@ -89,6 +104,35 @@ private func adeInspectorElementId(payload: ADEInspectablePayload) -> String { return "\(payload.componentId)|\(payload.file)|\(payload.line)\(keySegment)|\(metadata)" } +private func adeInspectorRoundedPixel(_ value: Double) -> Int { + Int(value.rounded()) +} + +private func adeInspectorSnapshotSignature(_ snapshot: ADEInspectorSnapshot) -> String { + let screen = snapshot.screen + let screenSignature = [ + adeInspectorRoundedPixel(screen.width * screen.scale), + adeInspectorRoundedPixel(screen.height * screen.scale), + adeInspectorRoundedPixel(screen.scale * 100) + ] + .map(String.init) + .joined(separator: "x") + let elementSignature = snapshot.elements + .map { element in + let frame = element.pixelFrame + return [ + element.id, + String(adeInspectorRoundedPixel(frame.x)), + String(adeInspectorRoundedPixel(frame.y)), + String(adeInspectorRoundedPixel(frame.width)), + String(adeInspectorRoundedPixel(frame.height)) + ] + .joined(separator: ":") + } + .joined(separator: "|") + return "\(screenSignature)|\(elementSignature)" +} + private struct ADEInspectorSnapshotEmitter: View { @Environment(\.displayScale) private var displayScale @@ -142,21 +186,13 @@ private struct ADEInspectorSnapshotEmitter: View { ) } - private var snapshotIdentity: String { - snapshot.elements - .map { element in - let frame = element.pixelFrame - return "\(element.id):\(frame.x):\(frame.y):\(frame.width):\(frame.height)" - } - .joined(separator: "|") - } - var body: some View { let currentSnapshot = snapshot + let signature = adeInspectorSnapshotSignature(currentSnapshot) Color.clear .allowsHitTesting(false) - .task(id: snapshotIdentity) { - await ADEInspectorSnapshotWriter.shared.write(currentSnapshot) + .task(id: signature) { + await ADEInspectorSnapshotWriter.shared.write(currentSnapshot, signature: signature) } } } @@ -164,13 +200,17 @@ private struct ADEInspectorSnapshotEmitter: View { private struct ADEInspectorHostModifier: ViewModifier { func body(content: Content) -> some View { #if DEBUG - content - .overlayPreferenceValue(ADEInspectablePreferenceKey.self) { items in - GeometryReader { proxy in - ADEInspectorSnapshotEmitter(items: items, proxy: proxy) + if ADEInspectorRuntime.snapshotsEnabled { + content + .overlayPreferenceValue(ADEInspectablePreferenceKey.self) { items in + GeometryReader { proxy in + ADEInspectorSnapshotEmitter(items: items, proxy: proxy) + } + .allowsHitTesting(false) } - .allowsHitTesting(false) - } + } else { + content + } #else content #endif diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 73901e80e..d321395d7 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -731,6 +731,53 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var orchestrationTag: String? = nil var orchestrationStepId: String? = nil var orchestrationBundlePath: String? = nil + + static func == (lhs: AgentChatSessionSummary, rhs: AgentChatSessionSummary) -> Bool { + lhs.sessionId == rhs.sessionId + && lhs.laneId == rhs.laneId + && lhs.provider == rhs.provider + && lhs.model == rhs.model + && lhs.modelId == rhs.modelId + && lhs.sessionProfile == rhs.sessionProfile + && lhs.title == rhs.title + && lhs.goal == rhs.goal + && lhs.reasoningEffort == rhs.reasoningEffort + && lhs.codexFastMode == rhs.codexFastMode + && lhs.fastMode == rhs.fastMode + && lhs.executionMode == rhs.executionMode + && lhs.permissionMode == rhs.permissionMode + && lhs.interactionMode == rhs.interactionMode + && lhs.claudePermissionMode == rhs.claudePermissionMode + && lhs.codexApprovalPolicy == rhs.codexApprovalPolicy + && lhs.codexSandbox == rhs.codexSandbox + && lhs.codexConfigSource == rhs.codexConfigSource + && lhs.opencodePermissionMode == rhs.opencodePermissionMode + && lhs.droidPermissionMode == rhs.droidPermissionMode + && lhs.cursorModeId == rhs.cursorModeId + && lhs.identityKey == rhs.identityKey + && lhs.surface == rhs.surface + && lhs.automationId == rhs.automationId + && lhs.automationRunId == rhs.automationRunId + && lhs.capabilityMode == rhs.capabilityMode + && lhs.status == rhs.status + && lhs.idleSinceAt == rhs.idleSinceAt + && lhs.startedAt == rhs.startedAt + && lhs.endedAt == rhs.endedAt + && lhs.archivedAt == rhs.archivedAt + && lhs.lastActivityAt == rhs.lastActivityAt + && lhs.lastOutputPreview == rhs.lastOutputPreview + && lhs.summary == rhs.summary + && lhs.awaitingInput == rhs.awaitingInput + && lhs.pendingInputItemId == rhs.pendingInputItemId + && lhs.threadId == rhs.threadId + && lhs.requestedCwd == rhs.requestedCwd + && lhs.orchestrationRunId == rhs.orchestrationRunId + && lhs.orchestrationRole == rhs.orchestrationRole + && lhs.orchestrationParentSessionId == rhs.orchestrationParentSessionId + && lhs.orchestrationTag == rhs.orchestrationTag + && lhs.orchestrationStepId == rhs.orchestrationStepId + && lhs.orchestrationBundlePath == rhs.orchestrationBundlePath + } } struct CtoWorkerEntry: Codable, Identifiable, Hashable { @@ -1864,6 +1911,24 @@ struct AgentChatEventEnvelope: Decodable, Identifiable, Equatable { var provenance: AgentChatEventProvenance? } +struct AgentChatEventHistorySnapshot: Decodable, Equatable { + var sessionId: String + var events: [AgentChatEventEnvelope] + var truncated: Bool + var transcriptTruncated: Bool? + var windowTruncated: Bool? + var sessionFound: Bool? + var tailStartOffset: Int? +} + +struct AgentChatEventHistoryPage: Decodable, Equatable { + var sessionId: String + var events: [AgentChatEventEnvelope] + var startOffset: Int + var hasMore: Bool + var sessionFound: Bool +} + struct AgentChatFileRef: Codable, Equatable { var path: String var type: String @@ -1890,9 +1955,9 @@ enum AgentChatEvent: Decodable, Equatable { case activity(activity: AgentChatActivityKind, detail: String?, turnId: String?) case stepBoundary(stepNumber: Int, turnId: String?) case todoUpdate(items: [AgentChatTodoItem], turnId: String?) - case subagentStarted(taskId: String, description: String, background: Bool?, turnId: String?) - case subagentProgress(taskId: String, description: String?, summary: String, usage: AgentChatSubagentUsage?, lastToolName: String?, turnId: String?) - case subagentResult(taskId: String, status: AgentChatSubagentStatus, summary: String, usage: AgentChatSubagentUsage?, turnId: String?) + case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String, background: Bool?, turnId: String?) + case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String?, summary: String, usage: AgentChatSubagentUsage?, lastToolName: String?, turnId: String?) + case subagentResult(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, status: AgentChatSubagentStatus, summary: String, usage: AgentChatSubagentUsage?, turnId: String?) case structuredQuestion(question: String, options: [AgentChatStructuredQuestionOption]?, itemId: String, turnId: String?) case toolUseSummary(summary: String, toolUseIds: [String], turnId: String?) case contextCompact(trigger: AgentChatContextCompactTrigger, preTokens: Int?, state: AgentChatContextCompactState?, turnId: String?) @@ -1955,6 +2020,9 @@ extension AgentChatEvent { case stepNumber case items case taskId + case agentId + case agentType + case parentToolUseId case background case lastToolName case question @@ -2129,6 +2197,9 @@ extension AgentChatEvent { case "subagent_started": self = .subagentStarted( taskId: try container.decode(String.self, forKey: .taskId), + agentId: try container.decodeIfPresent(String.self, forKey: .agentId), + agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), description: try container.decode(String.self, forKey: .description), background: try container.decodeIfPresent(Bool.self, forKey: .background), turnId: try container.decodeIfPresent(String.self, forKey: .turnId) @@ -2136,6 +2207,9 @@ extension AgentChatEvent { case "subagent_progress": self = .subagentProgress( taskId: try container.decode(String.self, forKey: .taskId), + agentId: try container.decodeIfPresent(String.self, forKey: .agentId), + agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), description: try container.decodeIfPresent(String.self, forKey: .description), summary: try container.decode(String.self, forKey: .summary), usage: try container.decodeIfPresent(AgentChatSubagentUsage.self, forKey: .usage), @@ -2145,6 +2219,9 @@ extension AgentChatEvent { case "subagent_result": self = .subagentResult( taskId: try container.decode(String.self, forKey: .taskId), + agentId: try container.decodeIfPresent(String.self, forKey: .agentId), + agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), status: try container.decode(AgentChatSubagentStatus.self, forKey: .status), summary: try container.decode(String.self, forKey: .summary), usage: try container.decodeIfPresent(AgentChatSubagentUsage.self, forKey: .usage), @@ -2362,6 +2439,8 @@ struct AgentChatTranscriptEntry: Codable, Identifiable, Equatable { var text: String var timestamp: String var turnId: String? + var messageId: String? = nil + var itemId: String? = nil } struct AgentChatTranscriptResponse: Codable, Equatable { @@ -2754,6 +2833,42 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { var orchestrationRunId: String? = nil var orchestrationRole: String? = nil var orchestrationTag: String? = nil + + static func == (lhs: TerminalSessionSummary, rhs: TerminalSessionSummary) -> Bool { + lhs.id == rhs.id + && lhs.laneId == rhs.laneId + && lhs.laneName == rhs.laneName + && lhs.ptyId == rhs.ptyId + && lhs.tracked == rhs.tracked + && lhs.pinned == rhs.pinned + && lhs.manuallyNamed == rhs.manuallyNamed + && lhs.goal == rhs.goal + && lhs.toolType == rhs.toolType + && lhs.title == rhs.title + && lhs.status == rhs.status + && lhs.startedAt == rhs.startedAt + && lhs.endedAt == rhs.endedAt + && lhs.archivedAt == rhs.archivedAt + && lhs.exitCode == rhs.exitCode + && lhs.transcriptPath == rhs.transcriptPath + && lhs.headShaStart == rhs.headShaStart + && lhs.headShaEnd == rhs.headShaEnd + && lhs.lastOutputPreview == rhs.lastOutputPreview + && lhs.summary == rhs.summary + && lhs.runtimeState == rhs.runtimeState + && lhs.resumeCommand == rhs.resumeCommand + && lhs.resumeMetadata?.provider == rhs.resumeMetadata?.provider + && lhs.resumeMetadata?.targetKind == rhs.resumeMetadata?.targetKind + && lhs.resumeMetadata?.targetId == rhs.resumeMetadata?.targetId + && lhs.resumeMetadata?.target == rhs.resumeMetadata?.target + && lhs.resumeMetadata?.permissionMode == rhs.resumeMetadata?.permissionMode + && lhs.chatIdleSinceAt == rhs.chatIdleSinceAt + && lhs.chatSessionId == rhs.chatSessionId + && lhs.pendingInputItemId == rhs.pendingInputItemId + && lhs.orchestrationRunId == rhs.orchestrationRunId + && lhs.orchestrationRole == rhs.orchestrationRole + && lhs.orchestrationTag == rhs.orchestrationTag + } } struct ProcessReadinessConfig: Codable, Equatable { diff --git a/apps/ios/ADE/Models/RemoteRosterModels.swift b/apps/ios/ADE/Models/RemoteRosterModels.swift new file mode 100644 index 000000000..9ce242008 --- /dev/null +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -0,0 +1,249 @@ +import Foundation + +// Codable mirrors of the all-projects chat roster wire types defined in +// `apps/desktop/src/shared/types/sync.ts` (search `SyncRoster`). The roster is a +// lightweight, machine-wide projection of every project's lanes + chat sessions +// so the mobile hub can render all projects' chats-grouped-by-lane at once +// without activating each project. Transcripts are NOT included — they load on +// demand when a chat is opened. Pushed over `roster_snapshot` / `roster_delta` +// envelopes; see SyncService+Roster.swift for the store + subscribe handshake. + +/// Status of a roster chat row. For an un-booted project (no live runtime) the +/// truthful states are `idle` / `ended` / `awaiting` (last persisted to disk); +/// `running` and live `awaiting` are only emitted for a booted scope. +enum RemoteRosterChatStatus: String, Codable, Equatable { + case running + case awaiting + case idle + case ended + case failed + + /// Tolerate unknown future status strings by collapsing to `.idle`. + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = RemoteRosterChatStatus(rawValue: raw) ?? .idle + } +} + +struct RemoteRosterChat: Codable, Equatable, Identifiable { + var id: String // sessionId + var laneId: String + var chatSessionId: String? + var title: String? + var provider: String? + var model: String? + var toolType: String? + var status: RemoteRosterChatStatus + var awaitingInput: Bool? + var pinned: Bool? + var archived: Bool? + var lastActivityAt: String? + var preview: String? +} + +struct RemoteRosterLane: Codable, Equatable, Identifiable { + var id: String + var name: String + var color: String? + var icon: String? + var laneType: String? + var branchRef: String? +} + +struct RemoteRosterProject: Codable, Equatable, Identifiable { + var projectId: String + var rootPath: String? + var displayName: String + var iconDataUrl: String? + var lastOpenedAt: String? + var booted: Bool + var runningCount: Int + var attentionCount: Int + var lanes: [RemoteRosterLane] + var chats: [RemoteRosterChat] + + var id: String { projectId } +} + +/// Full roster snapshot — `roster_snapshot` envelope payload. +struct RemoteRosterSnapshotPayload: Codable, Equatable { + var seq: Int + var projects: [RemoteRosterProject] +} + +/// Incremental roster update — `roster_delta` envelope payload. +struct RemoteRosterDeltaPayload: Codable, Equatable { + var seq: Int + var changed: [RemoteRosterProject]? + var removed: [String]? +} + +/// Result of applying a `roster_delta` against the current store. Kept as a pure +/// value (no SyncService dependency) so the resync-correctness logic is unit +/// testable — see `RosterDeltaTests`. +enum RosterDeltaOutcome: Equatable { + /// Apply these projects and advance the watermark to `seq`. + case applied(projects: [RemoteRosterProject], seq: Int) + /// Duplicate / out-of-order replay — ignore. + case dropped + /// No baseline or a seq gap — must request a fresh snapshot before applying. + case needsSnapshot +} + +/// Pure delta-merge with the same sinceSeq discipline as chat_event: never apply +/// onto an unknown baseline or across a gap (request a snapshot instead), drop +/// duplicates, and upsert `changed` / drop `removed` only for `currentSeq + 1`. +func rosterApplyDelta( + current: [RemoteRosterProject], + currentSeq: Int?, + delta: RemoteRosterDeltaPayload +) -> RosterDeltaOutcome { + guard let currentSeq else { return .needsSnapshot } + if delta.seq <= currentSeq { return .dropped } + if delta.seq > currentSeq + 1 { return .needsSnapshot } + var byId = Dictionary(uniqueKeysWithValues: current.map { ($0.projectId, $0) }) + for projectId in delta.removed ?? [] { byId.removeValue(forKey: projectId) } + for project in delta.changed ?? [] { byId[project.projectId] = project } + return .applied(projects: Array(byId.values), seq: delta.seq) +} + +// MARK: - Convenience + +extension RemoteRosterChat { + var isChatTool: Bool { + guard let toolType = toolType? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() else { return true } + return toolType == "cursor" + || toolType.hasSuffix("-chat") + || toolType == "chat" + } + + /// Whether this row should drive an attention bubble on the hub. + var needsAttention: Bool { + awaitingInput == true || status == .awaiting || status == .failed + } + + var isRunning: Bool { + status == .running || status == .awaiting + } + + /// Provider key for the glyph: prefer the sidecar provider, else derive from + /// the tool type (e.g. `claude-chat` → `claude`). + var providerKey: String? { + if let provider, !provider.isEmpty { return provider } + guard let toolType, !toolType.isEmpty else { return nil } + if toolType.hasSuffix("-chat") { return String(toolType.dropLast("-chat".count)) } + return toolType + } + + /// Normalized status string consumed by `workChatStatusTint` / `workChatStatusIcon`. + var normalizedStatusString: String { + if awaitingInput == true || status == .awaiting { return "awaiting-input" } + switch status { + case .running: return "active" + case .idle: return "idle" + case .ended: return "ended" + case .failed: return "ended" + case .awaiting: return "awaiting-input" + } + } +} + +extension RemoteRosterProject { + /// Chats for one lane, freshest first. Archived rows are filtered out for the + /// hub's at-a-glance view. + func chats(forLaneId laneId: String) -> [RemoteRosterChat] { + chats + .filter { $0.laneId == laneId && $0.archived != true } + .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } + } + + /// Lanes that actually have at least one non-archived chat, preserving the + /// brain-provided order (primary lane first). + var lanesWithChats: [RemoteRosterLane] { + lanes.filter { lane in chats.contains { $0.laneId == lane.id && $0.archived != true } } + } +} + +// MARK: - Conversions to existing Work types +// +// Lets the hub reuse the Work tab's lane picker + session rows without a +// bespoke renderer. These are display-only synthesizations — the real synced +// LaneSummary / session is used once the user opens the project or chat. + +extension RemoteRosterLane { + func asLaneSummary() -> LaneSummary { + LaneSummary( + id: id, + name: name, + description: nil, + laneType: laneType ?? "primary", + baseRef: "", + branchRef: branchRef ?? "", + worktreePath: "", + attachedRootPath: nil, + parentLaneId: nil, + childCount: 0, + stackDepth: 0, + parentStatus: nil, + isEditProtected: false, + status: LaneStatus(dirty: false, ahead: 0, behind: 0, remoteBehind: 0, rebaseInProgress: false), + color: color, + icon: icon.flatMap(LaneIcon.init(rawValue:)), + tags: [], + folder: nil, + linearIssue: nil, + linearIssueLinks: nil, + createdAt: "", + archivedAt: nil, + devicesOpen: nil + ) + } +} + +extension RemoteRosterChat { + /// (session.status, session.runtimeState) pair that drives the Work row's + /// status dot via `rawWorkChatSessionStatus`. + private var sessionStatusStrings: (status: String, runtimeState: String) { + switch status { + case .running: return ("running", "running") + case .awaiting: return ("awaiting-input", "running") + case .idle: return ("idle", "idle") + case .ended: return ("completed", "exited") + case .failed: return ("failed", "failed") + } + } + + func asTerminalSessionSummary(laneName: String) -> TerminalSessionSummary { + let strings = sessionStatusStrings + return TerminalSessionSummary( + id: id, + laneId: laneId, + laneName: laneName, + ptyId: nil, + tracked: false, + pinned: pinned ?? false, + manuallyNamed: nil, + goal: nil, + toolType: toolType, + title: (title?.isEmpty == false ? title! : "Untitled chat"), + status: strings.status, + startedAt: lastActivityAt ?? "", + endedAt: status == .ended ? lastActivityAt : nil, + archivedAt: archived == true ? (lastActivityAt ?? "") : nil, + exitCode: nil, + transcriptPath: "", + headShaStart: nil, + headShaEnd: nil, + lastOutputPreview: preview, + summary: nil, + runtimeState: strings.runtimeState, + resumeCommand: nil, + resumeMetadata: nil, + chatIdleSinceAt: nil, + chatSessionId: chatSessionId, + pendingInputItemId: nil + ) + } +} diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 17d9b0af3..324a1f798 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -905,6 +905,16 @@ final class DatabaseService { let hydratableSessions = sessions.filter { laneIds.contains($0.laneId) } let sessionIds = hydratableSessions.map(\.id) + let shouldRestoreForeignKeys = (queryInt64("pragma foreign_keys") ?? 0) != 0 + if shouldRestoreForeignKeys { + try exec("pragma foreign_keys = off") + } + defer { + if shouldRestoreForeignKeys { + try? exec("pragma foreign_keys = on") + } + } + try exec("begin") do { try prepareTemporaryIdTable(named: "temp_project_lane_ids", ids: laneIds.sorted()) @@ -953,6 +963,43 @@ final class DatabaseService { """) } } + if hasTable(named: "claude_sessions") { + if sessionIds.isEmpty { + try exec(""" + update claude_sessions + set chat_session_id = null + where chat_session_id in ( + select terminal_sessions.id + from terminal_sessions + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + ) + """) + } else { + try exec(""" + update claude_sessions + set chat_session_id = null + where chat_session_id is not null + and chat_session_id in ( + select terminal_sessions.id + from terminal_sessions + where exists ( + select 1 + from temp_project_lane_ids project_lanes + where project_lanes.id = terminal_sessions.lane_id + ) + ) + and not exists ( + select 1 + from temp_hydrated_session_ids hydrated + where hydrated.id = claude_sessions.chat_session_id + ) + """) + } + } for session in hydratableSessions { _ = try execute(""" @@ -1104,7 +1151,7 @@ final class DatabaseService { try exec("drop table if exists temp_project_lane_ids") try exec("commit") - notifyDidChange(touchedTables: ["terminal_sessions", "session_deltas", "checkpoints"]) + notifyDidChange(touchedTables: ["terminal_sessions", "session_deltas", "checkpoints", "claude_sessions"]) } catch { try? exec("rollback") try? exec("drop table if exists temp_hydrated_session_ids") @@ -1619,6 +1666,10 @@ final class DatabaseService { withLock { fetchSessionsLocked() } } + func fetchSession(id sessionId: String) -> TerminalSessionSummary? { + withLock { fetchSessionLocked(id: sessionId) } + } + private func fetchSessionsLocked() -> [TerminalSessionSummary] { guard let projectId = currentProjectIdLocked() else { return [] } let sql = """ @@ -1636,64 +1687,90 @@ final class DatabaseService { return query(sql, bind: { [self] statement in try self.bindText(projectId, to: statement, index: 1) }) { statement in - SessionRow( - id: stringValue(statement, index: 0) ?? "", - laneId: stringValue(statement, index: 1) ?? "", - laneName: stringValue(statement, index: 2) ?? "", - ptyId: stringValue(statement, index: 3), - tracked: sqlite3_column_int(statement, 4) == 1, - pinned: sqlite3_column_int(statement, 5) == 1, - manuallyNamed: sqlite3_column_int(statement, 6) == 1, - goal: stringValue(statement, index: 7), - toolType: stringValue(statement, index: 8), - title: stringValue(statement, index: 9) ?? "", - status: stringValue(statement, index: 10) ?? "unknown", - startedAt: stringValue(statement, index: 11) ?? "", - endedAt: stringValue(statement, index: 12), - exitCode: columnIsNull(statement, index: 13) ? nil : Int(sqlite3_column_int64(statement, 13)), - transcriptPath: stringValue(statement, index: 14) ?? "", - headShaStart: stringValue(statement, index: 15), - headShaEnd: stringValue(statement, index: 16), - lastOutputPreview: stringValue(statement, index: 17), - summary: stringValue(statement, index: 18), - runtimeState: stringValue(statement, index: 19) ?? runtimeState(for: stringValue(statement, index: 10) ?? "unknown"), - resumeCommand: stringValue(statement, index: 20), - resumeMetadata: decodeJson(stringValue(statement, index: 21), as: TerminalResumeMetadata.self), - chatIdleSinceAt: stringValue(statement, index: 22), - chatSessionId: stringValue(statement, index: 23), - pendingInputItemId: stringValue(statement, index: 24), - archivedAt: stringValue(statement, index: 25) - ) - }.map { row in - TerminalSessionSummary( - id: row.id, - laneId: row.laneId, - laneName: row.laneName, - ptyId: row.ptyId, - tracked: row.tracked, - pinned: row.pinned, - manuallyNamed: row.manuallyNamed, - goal: row.goal, - toolType: row.toolType, - title: row.title, - status: row.status, - startedAt: row.startedAt, - endedAt: row.endedAt, - archivedAt: row.archivedAt, - exitCode: row.exitCode, - transcriptPath: row.transcriptPath, - headShaStart: row.headShaStart, - headShaEnd: row.headShaEnd, - lastOutputPreview: row.lastOutputPreview, - summary: row.summary, - runtimeState: row.runtimeState, - resumeCommand: row.resumeCommand, - resumeMetadata: row.resumeMetadata, - chatIdleSinceAt: row.chatIdleSinceAt, - chatSessionId: row.chatSessionId, - pendingInputItemId: row.pendingInputItemId - ) - } + sessionRow(from: statement) + }.map(terminalSessionSummary(from:)) + } + + private func fetchSessionLocked(id sessionId: String) -> TerminalSessionSummary? { + let trimmedId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedId.isEmpty else { return nil } + let sql = """ + select s.id, s.lane_id, coalesce(nullif(s.lane_name, ''), l.name, s.lane_id), s.pty_id, s.tracked, s.pinned, s.manually_named, s.goal, s.tool_type, + s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, + s.head_sha_start, s.head_sha_end, s.last_output_preview, s.summary, s.runtime_state, + s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at + from terminal_sessions s + left join lanes l on l.id = s.lane_id + where s.id = ? + limit 1 + """ + return query(sql, bind: { [self] statement in + try self.bindText(trimmedId, to: statement, index: 1) + }) { statement in + terminalSessionSummary(from: sessionRow(from: statement)) + }.first + } + + private func sessionRow(from statement: OpaquePointer) -> SessionRow { + SessionRow( + id: stringValue(statement, index: 0) ?? "", + laneId: stringValue(statement, index: 1) ?? "", + laneName: stringValue(statement, index: 2) ?? "", + ptyId: stringValue(statement, index: 3), + tracked: sqlite3_column_int(statement, 4) == 1, + pinned: sqlite3_column_int(statement, 5) == 1, + manuallyNamed: sqlite3_column_int(statement, 6) == 1, + goal: stringValue(statement, index: 7), + toolType: stringValue(statement, index: 8), + title: stringValue(statement, index: 9) ?? "", + status: stringValue(statement, index: 10) ?? "unknown", + startedAt: stringValue(statement, index: 11) ?? "", + endedAt: stringValue(statement, index: 12), + exitCode: columnIsNull(statement, index: 13) ? nil : Int(sqlite3_column_int64(statement, 13)), + transcriptPath: stringValue(statement, index: 14) ?? "", + headShaStart: stringValue(statement, index: 15), + headShaEnd: stringValue(statement, index: 16), + lastOutputPreview: stringValue(statement, index: 17), + summary: stringValue(statement, index: 18), + runtimeState: stringValue(statement, index: 19) ?? runtimeState(for: stringValue(statement, index: 10) ?? "unknown"), + resumeCommand: stringValue(statement, index: 20), + resumeMetadata: decodeJson(stringValue(statement, index: 21), as: TerminalResumeMetadata.self), + chatIdleSinceAt: stringValue(statement, index: 22), + chatSessionId: stringValue(statement, index: 23), + pendingInputItemId: stringValue(statement, index: 24), + archivedAt: stringValue(statement, index: 25) + ) + } + + private func terminalSessionSummary(from row: SessionRow) -> TerminalSessionSummary { + TerminalSessionSummary( + id: row.id, + laneId: row.laneId, + laneName: row.laneName, + ptyId: row.ptyId, + tracked: row.tracked, + pinned: row.pinned, + manuallyNamed: row.manuallyNamed, + goal: row.goal, + toolType: row.toolType, + title: row.title, + status: row.status, + startedAt: row.startedAt, + endedAt: row.endedAt, + archivedAt: row.archivedAt, + exitCode: row.exitCode, + transcriptPath: row.transcriptPath, + headShaStart: row.headShaStart, + headShaEnd: row.headShaEnd, + lastOutputPreview: row.lastOutputPreview, + summary: row.summary, + runtimeState: row.runtimeState, + resumeCommand: row.resumeCommand, + resumeMetadata: row.resumeMetadata, + chatIdleSinceAt: row.chatIdleSinceAt, + chatSessionId: row.chatSessionId, + pendingInputItemId: row.pendingInputItemId + ) } func updateSessionTitle(sessionId: String, title: String) throws { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 00ba167ea..b627ae422 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -271,6 +271,8 @@ private let syncTerminalSubscriptionMaxBytes = 240_000 private let syncTerminalStreamMaxBytes = 512_000 private let syncTerminalHistoryMaxBytes = 262_144 private let syncChatSubscriptionMaxBytes = 2_000_000 +private let syncChatHistoryTailPageProbeOffset = 1_000_000_000 +private let syncChatHistoryTailPageMaxBytes = 600_000 // 512KB, up from 160KB: the old budget silently truncated reasoning-heavy // turns on cellular/Tailscale routes. Chunked envelopes plus off-main decode // make the larger snapshot cheap to receive. @@ -394,15 +396,19 @@ func syncEndpointHost(_ rawValue: String) -> String? { syncParseRouteEndpoint(rawValue)?.host } -func syncConnectPortCandidates(primaryPort: Int, addresses: [String]) -> [Int] { +func syncConnectPortCandidates( + primaryPort: Int, + addresses: [String], + allowFallbackSweep: Bool = true +) -> [Int] { let normalizedHosts = addresses .map(syncNormalizedRouteHost) .map { $0.trimmingCharacters(in: CharacterSet(charactersIn: ".")) } let hasBonjourRoute = normalizedHosts.contains { $0.hasSuffix(".local") } let hasTailnetRoute = addresses.contains(where: syncIsTailscaleRoute) - let shouldTryDefaultPair = - (SyncDirectHostPorts.portCandidates.contains(primaryPort) && !hasBonjourRoute) - || hasTailnetRoute + let shouldTryDefaultPair = allowFallbackSweep + && ((SyncDirectHostPorts.portCandidates.contains(primaryPort) && !hasBonjourRoute) + || hasTailnetRoute) let fallbackPorts = shouldTryDefaultPair ? SyncDirectHostPorts.portCandidates : [] var seen = Set() return ([primaryPort] + fallbackPorts) @@ -1193,7 +1199,32 @@ final class SyncService: ObservableObject { @Published private(set) var filesProjectionRevision = 0 @Published private(set) var prsProjectionRevision = 0 @Published private(set) var proofArtifactsProjectionRevision = 0 + + private let iso8601WithFractionalSecondsFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private let iso8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() @Published private(set) var workspaceSnapshotRevision = 0 + // All-projects chat roster (mobile hub). `rosterProjects` is the machine-wide + // projection of every project's lanes + chats; the hub reads it overlaid on + // `projects` (the catalog). Mutated only by the roster apply path below. + @Published private(set) var rosterProjects: [RemoteRosterProject] = [] + @Published private(set) var rosterRevision = 0 + /// Last applied roster `seq`; gates delta-vs-resnapshot. nil ⇒ no baseline. + var rosterSeq: Int? + /// Whether `roster_subscribe` has been sent on the current connection. + var rosterSubscribed = false + /// True once the host has answered at least one roster push this launch, so + /// the hub knows the feed is supported (older hosts never answer → fallback). + @Published private(set) var rosterSupported = false + /// Debounces persistence of the roster snapshot to the App Group cache. + var rosterPersistTask: Task? @Published var settingsPresented = false @Published var projectHomePresented = true @Published var attentionDrawerPresented = false @@ -1370,6 +1401,11 @@ final class SyncService: ObservableObject { private var pendingDatabaseChangeAffectsAll = false private var latestRemoteDbVersion = 0 private var outboundLocalDbVersion = 0 + private var outboundCursorPersistTask: Task? + private var pendingOutboundCursorPersistVersion: Int? + private var remoteCursorProfilePersistTask: Task? + private var pendingRemoteProfileDbVersion: Int? + private var pendingRemoteProfileDbVersionBySite: [String: Int] = [:] private let discoveryBrowser = SyncBonjourBrowser() private var reconnectState = SyncReconnectState() private var envelopeChunkAssembler = SyncEnvelopeChunkAssembler() @@ -1441,6 +1477,7 @@ final class SyncService: ObservableObject { /// Tracks `activeSessions` derivation so we do not rebuild on every /// unrelated `localStateRevision` bump. private var activeSessionsObservationTask: Task? + private var activeSessionsSnapshotSignature = 0 /// Backing storage for `attentionDrawer` + the Combine subscriptions it /// uses to observe `activeSessions` / workspace snapshot writes. Lazily @@ -1566,6 +1603,45 @@ final class SyncService: ObservableObject { } } + /// Activate a project so a chat opened FROM THE HUB can stream its transcript, + /// without leaving the hub (the chat is presented over it; Back returns to the + /// hub). No-op when the project is already active. Returns once the project is + /// active enough for the chat surface to load (or immediately on the local + /// fallback path). Errors are surfaced via `lastError`, not thrown — the chat + /// cover shows a loading/retry state. + func openProjectForHubChat(_ project: MobileProjectSummary) async { + if isActiveProject(project) { return } + unhideProject(project) + + guard supportsProjectCatalog, + canSendLiveRequests(), + let rootPath = project.rootPath?.trimmingCharacters(in: .whitespacesAndNewlines), + !rootPath.isEmpty else { + // Cached/offline fallback: point reads at the project locally without + // tearing down the hub. Transcript streaming resumes when live. + if project.isCached || database.hasProject(id: project.id) { + setActiveProjectId(project.id, rootPath: project.rootPath) + localStateRevision += 1 + refreshActiveSessionsAndSnapshot() + } + return + } + + let selectionGeneration = beginProjectSelection() + let normalizedSwitchRoot = normalizedProjectRoot(rootPath) ?? rootPath + projectSwitchInFlightRootPath = normalizedSwitchRoot + do { + try await switchToDesktopProject(project, rootPath: rootPath, selectionGeneration: selectionGeneration, dismissHome: false) + } catch { + if isCurrentProjectSelection(selectionGeneration) { + lastError = SyncUserFacingError.message(for: error) + } + } + if isCurrentProjectSelection(selectionGeneration) { + projectSwitchInFlightRootPath = nil + } + } + func forgetProject(_ project: MobileProjectSummary) { let wasActive = isActiveProject(project) rememberHiddenProject(project) @@ -1989,7 +2065,8 @@ final class SyncService: ObservableObject { private func switchToDesktopProject( _ project: MobileProjectSummary, rootPath: String, - selectionGeneration: UInt64 + selectionGeneration: UInt64, + dismissHome: Bool = true ) async throws { let requestId = makeRequestId() let raw = try await awaitResponse(requestId: requestId) { @@ -2032,7 +2109,7 @@ final class SyncService: ObservableObject { // reconnects via the WebSocket. Treat this as a successful switch: // preserve the new active project, tear down any live socket, and let // reconnectIfPossible re-establish streaming for the new project. - projectHomePresented = false + if dismissHome { projectHomePresented = false } localStateRevision += 1 refreshActiveSessionsAndSnapshot() scheduleWorkspaceSnapshotWrite() @@ -2122,7 +2199,7 @@ final class SyncService: ObservableObject { ) guard isCurrentConnectAttempt(connectAttemptGeneration), isCurrentProjectSelection(selectionGeneration) else { return } currentAddress = connectedEndpoint.host - projectHomePresented = false + if dismissHome { projectHomePresented = false } localStateRevision += 1 refreshActiveSessionsAndSnapshot() scheduleWorkspaceSnapshotWrite() @@ -2396,11 +2473,14 @@ final class SyncService: ObservableObject { projects = deduplicateProjectListByRoot( sortedProjectList(database.listMobileProjects().filter { !isProjectHidden($0) }) ) + rosterProjects = loadCachedRoster() outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject(defaultVersion: database.currentDbVersion()) normalizeActiveProjectSelection(allowSingleProjectFallback: false) - if activeProjectId != nil { - projectHomePresented = false - } + // The hub (all-projects ProjectHomeView) is the launch surface: always land + // there, even when a project was previously active. Opening a project from + // the hub (`selectProject`) dismisses it into that project's tabs; Back + // returns here. `activeProjectId` stays set so the roster's live overlay and + // on-tap chat opening have a synced project to work with. pendingOperationCount = loadPendingOperations().count resetOutboundCursorStateForActiveProject() latestRemoteDbVersion = activeHostProfile?.lastRemoteDbVersion ?? 0 @@ -2474,6 +2554,8 @@ final class SyncService: ObservableObject { reconnectTask?.cancel() networkPathReconnectTask?.cancel() pendingOperationFlushTask?.cancel() + outboundCursorPersistTask?.cancel() + remoteCursorProfilePersistTask?.cancel() lanePresenceHeartbeatTask?.cancel() terminalBufferRevisionTask?.cancel() chatEventRevisionTask?.cancel() @@ -2503,13 +2585,47 @@ final class SyncService: ObservableObject { let touchedTables = self.pendingDatabaseChangeAffectsAll ? Set() : self.pendingDatabaseTouchedTables self.pendingDatabaseTouchedTables.removeAll() self.pendingDatabaseChangeAffectsAll = false - self.refreshProjectCatalog() - localStateRevision += 1 - self.bumpProjectionRevisions(for: touchedTables) - self.refreshActiveSessionsAndSnapshot() + + let affectsAll = touchedTables.isEmpty + let affectsProjectCatalog = affectsAll || touchedTables.contains(where: Self.tableAffectsProjectCatalog) + let affectsAnyProjection = affectsAll + || touchedTables.contains(where: Self.tableAffectsLanesProjection) + || touchedTables.contains(where: Self.tableAffectsLaneDetailProjection) + || touchedTables.contains(where: Self.tableAffectsWorkProjection) + || touchedTables.contains(where: Self.tableAffectsFilesProjection) + || touchedTables.contains(where: Self.tableAffectsPrsProjection) + || touchedTables.contains(where: Self.tableAffectsProofArtifactsProjection) + let affectsActiveSessions = affectsAll || touchedTables.contains(where: Self.tableAffectsActiveSessionsSnapshot) + + if affectsProjectCatalog { + self.refreshProjectCatalog() + } + if affectsAnyProjection { + localStateRevision += 1 + self.bumpProjectionRevisions(for: touchedTables) + } + if affectsActiveSessions { + self.refreshActiveSessionsAndSnapshot() + } } } + private static func tableAffectsProjectCatalog(_ table: String) -> Bool { + [ + "projects", + "lanes", + "lane_list_snapshots", + ].contains(table) + } + + private static func tableAffectsActiveSessionsSnapshot(_ table: String) -> Bool { + [ + "terminal_sessions", + "session_deltas", + "checkpoints", + ].contains(table) + } + private func bumpProjectionRevisions(for touchedTables: Set) { let affectsAll = touchedTables.isEmpty if affectsAll || touchedTables.contains(where: Self.tableAffectsLanesProjection) { @@ -3875,6 +3991,10 @@ final class SyncService: ObservableObject { database.fetchSessions() } + func fetchSession(id sessionId: String) async throws -> TerminalSessionSummary? { + database.fetchSession(id: sessionId) + } + func listProcessDefinitions() async throws -> [ProcessDefinition] { try await sendDecodableCommand(action: "processes.listDefinitions", as: [ProcessDefinition].self) } @@ -4603,6 +4723,19 @@ final class SyncService: ObservableObject { chatEventEnvelopesBySession[sessionId] ?? [] } + @discardableResult + func pruneChatEventHistory(sessionId: String, keepingTail limit: Int) -> [AgentChatEventEnvelope] { + let trimmedSessionId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedSessionId.isEmpty, + let events = chatEventEnvelopesBySession[trimmedSessionId] + else { return [] } + let clampedLimit = max(0, limit) + guard events.count > clampedLimit else { return events } + let next = Array(events.suffix(clampedLimit)) + chatEventEnvelopesBySession[trimmedSessionId] = next + return next + } + func chatEventRevision(for sessionId: String) -> Int { chatEventRevisionsBySession[sessionId] ?? 0 } @@ -4676,7 +4809,9 @@ final class SyncService: ObservableObject { modelId: String? = nil, reasoningEffort: String? = nil, cols: Int? = nil, - rows: Int? = nil + rows: Int? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws -> StartCliSessionResult { var args: [String: Any] = [ "laneId": laneId, @@ -4703,7 +4838,13 @@ final class SyncService: ObservableObject { if let rows, rows > 0 { args["rows"] = rows } - return try await sendDecodableCommand(action: "work.startCliSession", args: args, as: StartCliSessionResult.self) + return try await sendDecodableCommand( + action: "work.startCliSession", + args: args, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: StartCliSessionResult.self + ) } func stopWorkRuntime(sessionId: String) async throws { @@ -4717,7 +4858,9 @@ final class SyncService: ObservableObject { baseBranch: String? = nil, branchName: String? = nil, startPoint: String? = nil, - linearIssue: LaneLinearIssue? = nil + linearIssue: LaneLinearIssue? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws -> LaneSummary { var args: [String: Any] = [ "name": name, @@ -4741,7 +4884,13 @@ final class SyncService: ObservableObject { args["branchName"] = branchName } } - return try await sendDecodableCommand(action: "lanes.create", args: args, as: LaneSummary.self) + return try await sendDecodableCommand( + action: "lanes.create", + args: args, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: LaneSummary.self + ) } private struct SuggestLaneNameResult: Decodable { let name: String } @@ -5372,7 +5521,9 @@ final class SyncService: ObservableObject { cursorModeId: String? = nil, cursorConfigValues: [String: RemoteJSONValue]? = nil, computerUse: RemoteJSONValue? = nil, - requestedCwd: String? = nil + requestedCwd: String? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws -> AgentChatSessionSummary { let trimmedModel = model.trimmingCharacters(in: .whitespacesAndNewlines) var args: [String: Any] = [ @@ -5428,13 +5579,63 @@ final class SyncService: ObservableObject { if let requestedCwd, !requestedCwd.isEmpty { args["requestedCwd"] = requestedCwd } - return try await sendDecodableCommand(action: "chat.create", args: args, as: AgentChatSessionSummary.self) + return try await sendDecodableCommand( + action: "chat.create", + args: args, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath, + as: AgentChatSessionSummary.self + ) } func fetchChatSummary(sessionId: String) async throws -> AgentChatSessionSummary { try await sendDecodableCommand(action: "chat.getSummary", args: ["sessionId": sessionId], as: AgentChatSessionSummary.self) } + func fetchChatEventHistorySnapshot(sessionId: String, maxEvents: Int = chatEventHistoryMaxEvents) async throws -> AgentChatEventHistorySnapshot { + try await sendDecodableCommand( + action: "chat.getChatEventHistory", + args: ["sessionId": sessionId, "maxEvents": max(1, min(chatEventHistoryMaxEvents, maxEvents))], + as: AgentChatEventHistorySnapshot.self + ) + } + + @discardableResult + func hydrateChatEventHistorySnapshot(sessionId: String, maxEvents: Int = chatEventHistoryMaxEvents) async throws -> AgentChatEventHistorySnapshot { + let snapshot = try await fetchChatEventHistorySnapshot(sessionId: sessionId, maxEvents: maxEvents) + guard snapshot.sessionFound != false else { return snapshot } + mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + return snapshot + } + + @discardableResult + func hydrateChatEventHistoryTailPage(sessionId: String) async throws -> AgentChatEventHistoryPage { + let page = try await fetchChatEventHistoryPage( + sessionId: sessionId, + beforeOffset: syncChatHistoryTailPageProbeOffset, + maxBytes: syncChatHistoryTailPageMaxBytes + ) + guard page.sessionFound != false else { return page } + mergeChatEventHistory(sessionId: page.sessionId, events: page.events) + return page + } + + func fetchChatEventHistoryPage( + sessionId: String, + beforeOffset: Int, + maxBytes: Int? = nil + ) async throws -> AgentChatEventHistoryPage { + var args: [String: Any] = ["sessionId": sessionId, "beforeOffset": beforeOffset] + if let maxBytes, maxBytes > 0 { + args["maxBytes"] = maxBytes + } + return try await sendDecodableCommand( + action: "chat.getChatEventHistoryPage", + args: args, + as: AgentChatEventHistoryPage.self + ) + } + func fetchChatTranscriptResponse(sessionId: String, limit: Int = 500, maxChars: Int = 600_000) async throws -> AgentChatTranscriptResponse { try await sendDecodableCommand( action: "chat.getTranscript", @@ -5461,6 +5662,33 @@ final class SyncService: ObservableObject { var nextCursor: Int? } + struct AgentChatSubagentTranscriptMessage: Codable, Equatable { + var type: String + var uuid: String? + var sessionId: String + var parentToolUseId: String? + var message: RemoteJSONValue? + var text: String? + var subagentMetadata: RemoteJSONValue? + } + + struct AgentChatSubagentSnapshot: Codable, Equatable { + var taskId: String + var agentId: String? + var agentType: String? + var parentToolUseId: String? + var description: String + var status: String + var turnId: String? + var startTimestamp: String? + var endTimestamp: String? + var summary: String? + var finalSummary: String? + var lastToolName: String? + var background: Bool? + var usage: AgentChatSubagentUsage? + } + /// Fetch a transcript page. Without `cursor` this returns the newest /// entries (same data as `fetchChatTranscriptResponse`) plus a cursor for /// walking backwards; with `cursor` it returns the page strictly BEFORE @@ -5498,6 +5726,51 @@ final class SyncService: ObservableObject { ) } + func fetchSubagentTranscript( + sessionId: String, + agentId: String, + taskId: String? = nil, + laneId: String? = nil, + limit: Int? = nil, + offset: Int? = nil + ) async throws -> [AgentChatSubagentTranscriptMessage]? { + var args: [String: Any] = [ + "sessionId": sessionId, + "agentId": agentId, + ] + if let taskId, !taskId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args["taskId"] = taskId + } + if let laneId, !laneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + args["laneId"] = laneId + } + if let limit { + args["limit"] = limit + } + if let offset { + args["offset"] = offset + } + let response = try await sendCommand(action: "chat.getSubagentTranscript", args: args) + if response is NSNull { + return nil + } + if let payload = response as? [String: Any], payload["queued"] as? Bool == true { + throw QueuedRemoteCommandError(action: "chat.getSubagentTranscript") + } + return try decode(response, as: [AgentChatSubagentTranscriptMessage].self) + } + + func fetchSubagents(sessionId: String) async throws -> [AgentChatSubagentSnapshot] { + let response = try await sendCommand( + action: "chat.listSubagents", + args: ["sessionId": sessionId] + ) + if let payload = response as? [String: Any], payload["queued"] as? Bool == true { + throw QueuedRemoteCommandError(action: "chat.listSubagents") + } + return try decode(response, as: [AgentChatSubagentSnapshot].self) + } + @discardableResult func sendChatMessage(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { let response = try await sendCommand( @@ -6039,8 +6312,12 @@ final class SyncService: ObservableObject { saveSavedProfiles(profiles) migrateTokenIfNeeded(for: profile) } - activeHostProfile = profile - hostName = profile.hostName + if activeHostProfile.map({ !syncProfilesEquivalentForPublishedState($0, profile) }) ?? true { + activeHostProfile = profile + } + if hostName != profile.hostName { + hostName = profile.hostName + } hiddenProjectKeys = loadHiddenProjectKeys() if activeProjectId != nil { let hostIdentity = syncNormalizedCommandScopeValue(profile.hostIdentity) @@ -6064,12 +6341,85 @@ final class SyncService: ObservableObject { } private func updateProfile(_ transform: (inout HostConnectionProfile) -> Void) { - guard var profile = loadProfile() else { return } + guard var profile = activeHostProfile ?? loadProfile() else { return } + let previous = profile transform(&profile) - profile.updatedAt = ISO8601DateFormatter().string(from: Date()) + guard !syncProfilesEquivalentIgnoringUpdatedAt(previous, profile) else { return } + profile.updatedAt = syncDateFormatter.string(from: Date()) saveProfile(profile) } + private func syncProfilesEquivalentIgnoringUpdatedAt( + _ lhs: HostConnectionProfile, + _ rhs: HostConnectionProfile + ) -> Bool { + var left = lhs + var right = rhs + left.updatedAt = "" + right.updatedAt = "" + return left == right + } + + private func syncProfilesEquivalentForPublishedState( + _ lhs: HostConnectionProfile, + _ rhs: HostConnectionProfile + ) -> Bool { + lhs.hostIdentity == rhs.hostIdentity + && lhs.hostName == rhs.hostName + && lhs.siteId == rhs.siteId + && lhs.port == rhs.port + && lhs.authKind == rhs.authKind + && lhs.pairedDeviceId == rhs.pairedDeviceId + && lhs.lastHostDeviceId == rhs.lastHostDeviceId + && lhs.lastSuccessfulAddress == rhs.lastSuccessfulAddress + && lhs.savedAddressCandidates == rhs.savedAddressCandidates + && lhs.discoveredLanAddresses == rhs.discoveredLanAddresses + && lhs.tailscaleAddress == rhs.tailscaleAddress + } + + private func markSyncActivity(force: Bool = false) { + let now = Date() + if !force, let lastSyncAt, now.timeIntervalSince(lastSyncAt) < 2 { + return + } + lastSyncAt = now + } + + private func scheduleRemoteDbCursorProfilePersist(dbVersion: Int, cursorSite: String?) { + pendingRemoteProfileDbVersion = max(pendingRemoteProfileDbVersion ?? 0, dbVersion) + if let cursorSite { + pendingRemoteProfileDbVersionBySite[cursorSite] = max( + pendingRemoteProfileDbVersionBySite[cursorSite] ?? 0, + dbVersion + ) + } + remoteCursorProfilePersistTask?.cancel() + remoteCursorProfilePersistTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !Task.isCancelled else { return } + self.persistPendingRemoteDbCursorProfile() + } + } + + private func persistPendingRemoteDbCursorProfile() { + guard let dbVersion = pendingRemoteProfileDbVersion else { return } + let dbVersionBySite = pendingRemoteProfileDbVersionBySite + pendingRemoteProfileDbVersion = nil + pendingRemoteProfileDbVersionBySite = [:] + remoteCursorProfilePersistTask = nil + + updateProfile { profile in + profile.lastRemoteDbVersion = max(profile.lastRemoteDbVersion, dbVersion) + if !dbVersionBySite.isEmpty { + var bySite = profile.remoteDbVersionBySite ?? [:] + for (siteId, version) in dbVersionBySite { + bySite[siteId] = max(bySite[siteId] ?? 0, version) + } + profile.remoteDbVersionBySite = bySite + } + } + } + private func loadRemoteCommandDescriptors() -> [SyncRemoteCommandDescriptor] { guard let data = UserDefaults.standard.data(forKey: remoteCommandDescriptorsKey), let descriptors = try? decoder.decode([SyncRemoteCommandDescriptor].self, from: data) else { @@ -6362,9 +6712,34 @@ final class SyncService: ObservableObject { outboundLocalDbVersion = min(outboundLocalDbVersion, persisted.payload.fromDbVersion) } - private func advanceOutboundCursorForActiveProject(to dbVersion: Int) { + private func advanceOutboundCursorForActiveProject( + to dbVersion: Int, + persistImmediately: Bool = true + ) { outboundLocalDbVersion = max(outboundLocalDbVersion, max(0, dbVersion)) - persistOutboundCursorForActiveProject(outboundLocalDbVersion) + if persistImmediately { + outboundCursorPersistTask?.cancel() + outboundCursorPersistTask = nil + pendingOutboundCursorPersistVersion = nil + persistOutboundCursorForActiveProject(outboundLocalDbVersion) + } else { + scheduleDeferredOutboundCursorPersist(outboundLocalDbVersion) + } + } + + private func scheduleDeferredOutboundCursorPersist(_ dbVersion: Int) { + pendingOutboundCursorPersistVersion = max(pendingOutboundCursorPersistVersion ?? 0, dbVersion) + outboundCursorPersistTask?.cancel() + outboundCursorPersistTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 5_000_000_000) + guard let self, !Task.isCancelled else { return } + let dbVersion = self.pendingOutboundCursorPersistVersion + self.pendingOutboundCursorPersistVersion = nil + self.outboundCursorPersistTask = nil + if let dbVersion { + self.persistOutboundCursorForActiveProject(dbVersion) + } + } } private func prepareOutboundStateForProjectScopeChange() { @@ -6802,7 +7177,11 @@ final class SyncService: ObservableObject { ? automaticReconnectAddresses(for: profile) : prioritizedAddresses(for: profile) let addresses = connectableAddresses(from: rawAddresses) - let portCandidates = syncConnectPortCandidates(primaryPort: profile.port, addresses: addresses) + let portCandidates = syncConnectPortCandidates( + primaryPort: profile.port, + addresses: addresses, + allowFallbackSweep: !preferLiveCandidatesOnly + ) syncConnectLog.info( "ADE_SYNC_TRACE reconnect candidates preferLiveOnly=\(preferLiveCandidatesOnly) path=\(syncLogPathSummary(self.lastNetworkPathSnapshot), privacy: .public) profile=\(syncLogProfileSummary(profile), privacy: .public) raw=[\(syncLogAddressList(rawAddresses), privacy: .public)] ports=[\(portCandidates.map(String.init).joined(separator: ","), privacy: .public)] connectable=[\(syncLogAddressList(addresses), privacy: .public)]" ) @@ -7027,6 +7406,9 @@ final class SyncService: ObservableObject { let liveTailscaleAddress = matching.compactMap(\.tailscaleAddress).first ?? profile.tailscaleAddress next.discoveredLanAddresses = liveLanAddresses next.tailscaleAddress = liveTailscaleAddress + if let livePort = matching.map(\.port).first(where: { $0 > 0 }) { + next.port = livePort + } next.savedAddressCandidates = Array( deduplicatedAddresses( (profile.lastSuccessfulAddress.map { [$0] } ?? []) @@ -7633,7 +8015,7 @@ final class SyncService: ObservableObject { refreshReducedSyncLoad() lastError = nil lastPairingErrorCode = nil - lastSyncAt = Date() + markSyncActivity(force: true) saveRemoteCommandDescriptors(commandDescriptors) let matchingDiscovery = discoveredHosts.first { discovered in @@ -7686,6 +8068,7 @@ final class SyncService: ObservableObject { startInitialHydrationTask(for: connectionGeneration) restoreTerminalSubscriptions() restoreChatEventSubscriptions() + subscribeRosterIfNeeded() } private func failPendingRequests(with error: Error) { @@ -7886,17 +8269,10 @@ final class SyncService: ObservableObject { // here would make the host skip the new project DB's backlog. guard isCurrentConnectionGeneration(generation) else { return } latestRemoteDbVersion = max(latestRemoteDbVersion, batch.toDbVersion, result.dbVersion) - lastSyncAt = Date() + markSyncActivity() let advancedVersion = latestRemoteDbVersion let cursorSite = activeRemoteDbSiteId - updateProfile { profile in - profile.lastRemoteDbVersion = advancedVersion - if let cursorSite { - var bySite = profile.remoteDbVersionBySite ?? [:] - bySite[cursorSite] = max(bySite[cursorSite] ?? 0, advancedVersion) - profile.remoteDbVersionBySite = bySite - } - } + scheduleRemoteDbCursorProfilePersist(dbVersion: advancedVersion, cursorSite: cursorSite) sendChangesetAck( batch: batch, ok: true, @@ -7963,7 +8339,11 @@ final class SyncService: ObservableObject { // events of the new stream as "old". chatEventLastSeqBySession.removeValue(forKey: snapshot.sessionId) } - mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + if resumed { + mergeChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } else { + replaceChatEventHistory(sessionId: snapshot.sessionId, events: snapshot.events) + } if let turnActive = snapshot.turnActive { updateChatTurnActiveHint(sessionId: snapshot.sessionId, turnActive: turnActive) } else if (dict["resumed"] as? Bool) != true { @@ -8016,6 +8396,14 @@ final class SyncService: ObservableObject { markTerminalBufferChanged(sessionId: sessionId, immediate: true) terminalStreamHandlers[sessionId]?(.exit(code: exitCode)) } + case "roster_snapshot": + if let snapshot = try? decode(payload, as: RemoteRosterSnapshotPayload.self) { + applyRosterSnapshot(snapshot) + } + case "roster_delta": + if let delta = try? decode(payload, as: RemoteRosterDeltaPayload.self) { + applyRosterDelta(delta) + } default: break } @@ -8098,7 +8486,7 @@ final class SyncService: ObservableObject { pendingOutboundChangeset = nil clearPendingOutboundChangesetForActiveProject() advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) - lastSyncAt = Date() + markSyncActivity(force: true) lastError = nil return } @@ -8127,7 +8515,7 @@ final class SyncService: ObservableObject { pendingOutboundChangeset = nil clearPendingOutboundChangesetForActiveProject() advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) - lastSyncAt = Date() + markSyncActivity(force: true) return } if now - pending.sentAt >= 10 { @@ -8150,7 +8538,7 @@ final class SyncService: ObservableObject { persistPendingOutboundChangesetForActiveProject(pending) } else { advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) - lastSyncAt = Date() + markSyncActivity(force: true) } } @@ -8161,7 +8549,7 @@ final class SyncService: ObservableObject { let changes = database.exportChangesSince(version: outboundLocalDbVersion).filter { $0.siteId == localSiteId } let previousDbVersion = outboundLocalDbVersion guard !changes.isEmpty else { - advanceOutboundCursorForActiveProject(to: currentDbVersion) + advanceOutboundCursorForActiveProject(to: currentDbVersion, persistImmediately: false) return nil } @@ -8316,6 +8704,13 @@ final class SyncService: ObservableObject { } private func teardownSocket(closeCode: URLSessionWebSocketTask.CloseCode = .goingAway, reason: String? = nil) { + // The roster subscription is bound to the live socket; a reconnect must + // re-subscribe. Keep `rosterProjects` for offline render, but drop the + // seq baseline so the next subscribe asks for (and applies) a fresh snapshot. + rosterSubscribed = false + rosterSeq = nil + rosterPersistTask?.cancel() + rosterPersistTask = nil transportProbeTask?.cancel() transportProbeTask = nil relayTask?.cancel() @@ -8500,13 +8895,17 @@ final class SyncService: ObservableObject { args: [String: Any] = [:], disconnectOnTimeout: Bool = true, timeoutNanoseconds: UInt64? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil, as type: T.Type ) async throws -> T { let response = try await sendCommand( action: action, args: args, disconnectOnTimeout: disconnectOnTimeout, - timeoutNanoseconds: timeoutNanoseconds + timeoutNanoseconds: timeoutNanoseconds, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath ) if let payload = response as? [String: Any], payload["queued"] as? Bool == true { throw QueuedRemoteCommandError(action: action) @@ -8569,7 +8968,14 @@ final class SyncService: ObservableObject { } } - private func enqueueOperation(kind: String, action: String, args: [String: Any], id: String? = nil) throws { + private func enqueueOperation( + kind: String, + action: String, + args: [String: Any], + id: String? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) throws { guard JSONSerialization.isValidJSONObject(args) else { throw NSError(domain: "ADE", code: 11, userInfo: [NSLocalizedDescriptionKey: "Invalid queued operation payload."]) } @@ -8582,8 +8988,8 @@ final class SyncService: ObservableObject { payload: payload, queuedAt: syncDateFormatter.string(from: Date()), hostId: activeHostStorageKey(), - projectId: activeProjectId, - projectRootPath: activeProjectRootPath + projectId: targetProjectId ?? activeProjectId, + projectRootPath: targetProjectRootPath ?? activeProjectRootPath )) savePendingOperations(queued) if canSendLiveRequests() { @@ -8704,13 +9110,21 @@ final class SyncService: ObservableObject { commandId: String? = nil, disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message, - timeoutNanoseconds: UInt64? = nil + timeoutNanoseconds: UInt64? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws -> Any { guard canSendLiveRequests() else { throw NSError(domain: "ADE", code: 14, userInfo: [NSLocalizedDescriptionKey: "The machine is offline."]) } let requestId = commandId ?? makeRequestId() let effectiveTimeoutNanoseconds = timeoutNanoseconds ?? SyncRequestTimeout.commandTimeoutNanoseconds(for: action) + // `targetProjectId` lets a command create-in-place in a NON-active project + // (mobile hub composer): the host routes the command to that project's scope + // via the command-payload projectId without switching the phone's active + // sync project. Defaults to the active project for every existing caller. + let resolvedProjectId = targetProjectId ?? self.activeProjectId + let resolvedProjectRootPath = targetProjectRootPath ?? self.activeProjectRootPath let raw = try await awaitResponse( requestId: requestId, disconnectOnTimeout: disconnectOnTimeout, @@ -8724,8 +9138,8 @@ final class SyncService: ObservableObject { commandId: requestId, action: action, args: args, - projectId: self.activeProjectId, - projectRootPath: self.activeProjectRootPath + projectId: resolvedProjectId, + projectRootPath: resolvedProjectRootPath ) ) } @@ -8737,7 +9151,9 @@ final class SyncService: ObservableObject { args: [String: Any], disconnectOnTimeout: Bool = true, timeoutMessage: String = SyncRequestTimeout.message, - timeoutNanoseconds: UInt64? = nil + timeoutNanoseconds: UInt64? = nil, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws -> Any { let commandId = makeRequestId() if canSendLiveRequests() { @@ -8748,7 +9164,9 @@ final class SyncService: ObservableObject { commandId: commandId, disconnectOnTimeout: disconnectOnTimeout, timeoutMessage: timeoutMessage, - timeoutNanoseconds: timeoutNanoseconds + timeoutNanoseconds: timeoutNanoseconds, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath ) } catch { let stillLive = canSendLiveRequests() @@ -8757,7 +9175,14 @@ final class SyncService: ObservableObject { canSendLiveRequests: stillLive, queueable: commandPolicy(for: action)?.queueable == true ) { - try enqueueOperation(kind: "command", action: action, args: args, id: commandId) + try enqueueOperation( + kind: "command", + action: action, + args: args, + id: commandId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) if stillLive, isSyncRequestTimeoutError(error) { verifyTransportAliveAfterRequestTimeout(error as NSError) } @@ -8772,7 +9197,13 @@ final class SyncService: ObservableObject { guard policy.queueable == true else { throw NSError(domain: "ADE", code: 15, userInfo: [NSLocalizedDescriptionKey: "This action requires a live connection to the machine."]) } - try enqueueOperation(kind: "command", action: action, args: args) + try enqueueOperation( + kind: "command", + action: action, + args: args, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) return ["queued": true] } @@ -8836,29 +9267,20 @@ final class SyncService: ObservableObject { func recordChatEventEnvelope(_ envelope: AgentChatEventEnvelope) { var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] - guard !events.contains(where: { $0.id == envelope.id }) else { return } - // Fast path: arrival-order appends stay sorted when timestamps are - // monotonically non-decreasing — common for live streaming. Out-of-order - // deliveries (e.g., a delayed tool_result arriving after a later text - // fragment, or a merge with a historical snapshot) fall through to the - // full dedup/sort in deduplicatedChatEventHistory so bubble order matches - // the replace/merge paths. - let canAppendInOrder: Bool = { - guard let last = events.last else { return true } - let lastDate = Self.parseIso8601(last.timestamp) - let envelopeDate = Self.parseIso8601(envelope.timestamp) - if let lhs = envelopeDate, let rhs = lastDate { return lhs >= rhs } - return envelope.timestamp >= last.timestamp - }() - if canAppendInOrder { - events.append(envelope) - events = trimChatEventHistory(events) + if let last = events.last { + if canAppendChatEvent(envelope, after: last) { + events.append(envelope) + events = trimChatEventHistory(events) + } else { + guard !chatEventHistoryContainsDuplicate(envelope, in: events) else { return } + events = insertChatEventEnvelope(envelope, into: events) + } } else { - events = deduplicatedChatEventHistory(events + [envelope]) + events.append(envelope) } chatEventEnvelopesBySession[envelope.sessionId] = events chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 - lastSyncAt = Date() + markSyncActivity() updateChatTurnActiveHintFromEvent(envelope) markChatEventsChanged() } @@ -8868,7 +9290,7 @@ final class SyncService: ObservableObject { guard chatEventEnvelopesBySession[sessionId] != next else { return } chatEventEnvelopesBySession[sessionId] = next chatEventRevisionsBySession[sessionId, default: 0] += 1 - lastSyncAt = Date() + markSyncActivity() markChatEventsChanged(immediate: true) } @@ -8878,38 +9300,174 @@ final class SyncService: ObservableObject { guard current != next else { return } chatEventEnvelopesBySession[sessionId] = next chatEventRevisionsBySession[sessionId, default: 0] += 1 - lastSyncAt = Date() + markSyncActivity() markChatEventsChanged(immediate: true) } private func deduplicatedChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { var seen = Set() - let unique = events.filter { event in - guard !seen.contains(event.id) else { return false } - seen.insert(event.id) - return true + var unique: [AgentChatEventEnvelope] = [] + unique.reserveCapacity(events.count) + for event in events { + let key = chatEventHistoryDedupeKey(event) + guard seen.insert(key).inserted else { continue } + unique.append(event) } - .sorted { lhs, rhs in - // Parse timestamps to Date before comparing — a lexicographic compare - // misorders mixed ISO-8601 variants (e.g., "…56.500Z" sorts before - // "…56Z" because "." < "Z" in ASCII, even though chronologically it's - // half a second later). - let lhsDate = Self.parseIso8601(lhs.timestamp) - let rhsDate = Self.parseIso8601(rhs.timestamp) - if lhsDate == rhsDate { - if lhs.timestamp == rhs.timestamp { - return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) - } - return lhs.timestamp < rhs.timestamp + let sorted = unique + .map { ChatEventSortRecord(event: $0, timestampKey: chatEventTimestampSortKey($0.timestamp)) } + .sorted { lhs, rhs in + compareChatEventSortRecords(lhs, rhs) == .orderedAscending } - switch (lhsDate, rhsDate) { - case (let l?, let r?): return l < r - case (nil, _?): return true - case (_?, nil): return false - case (nil, nil): return lhs.timestamp < rhs.timestamp + .map(\.event) + return trimChatEventHistory(sorted) + } + + private func chatEventHistoryContainsDuplicate( + _ envelope: AgentChatEventEnvelope, + in events: [AgentChatEventEnvelope] + ) -> Bool { + if events.contains(where: { $0.id == envelope.id }) { + return true + } + guard let key = chatEventContentDedupeKey(envelope) else { + return false + } + return events.contains { chatEventContentDedupeKey($0) == key } + } + + private func insertChatEventEnvelope( + _ envelope: AgentChatEventEnvelope, + into events: [AgentChatEventEnvelope] + ) -> [AgentChatEventEnvelope] { + var next = events + let index = chatEventInsertionIndex(for: envelope, in: next) + next.insert(envelope, at: index) + return trimChatEventHistory(next) + } + + private func chatEventInsertionIndex( + for envelope: AgentChatEventEnvelope, + in events: [AgentChatEventEnvelope] + ) -> Int { + var low = events.startIndex + var high = events.endIndex + while low < high { + let mid = low + (high - low) / 2 + if compareChatEvents(events[mid], envelope) == .orderedDescending { + high = mid + } else { + low = mid + 1 } } - return trimChatEventHistory(unique) + return low + } + + private func chatEventHistoryDedupeKey(_ envelope: AgentChatEventEnvelope) -> String { + if let contentKey = chatEventContentDedupeKey(envelope) { + return contentKey + } + return envelope.id + } + + private func chatEventContentDedupeKey(_ envelope: AgentChatEventEnvelope) -> String? { + switch envelope.event { + case .text(let text, let messageId, let turnId, let itemId): + let normalizedText = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedText.count >= 24 else { return nil } + let normalizedTurnId = turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedMessageId = messageId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let stableMessageId = normalizedItemId.isEmpty ? normalizedMessageId : normalizedItemId + guard !normalizedTurnId.isEmpty || !stableMessageId.isEmpty else { return nil } + return [ + envelope.sessionId, + "text", + normalizedTurnId, + stableMessageId, + normalizedText + ].joined(separator: "|") + case .userMessage(let text, _, let turnId, let steerId, let deliveryState, let processed): + let normalizedTurnId = turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let normalizedSteerId = steerId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedTurnId.isEmpty || !normalizedSteerId.isEmpty else { return nil } + return [ + envelope.sessionId, + "user_message", + normalizedTurnId, + normalizedSteerId, + deliveryState ?? "", + processed.map { $0 ? "1" : "0" } ?? "", + text.trimmingCharacters(in: .whitespacesAndNewlines) + ].joined(separator: "|") + default: + return nil + } + } + + private struct ChatEventSortRecord { + let event: AgentChatEventEnvelope + let timestampKey: String + } + + private func compareChatEventSortRecords( + _ lhs: ChatEventSortRecord, + _ rhs: ChatEventSortRecord + ) -> ComparisonResult { + if lhs.event.timestamp == rhs.event.timestamp { + return compareChatEventSequence(lhs.event.sequence, rhs.event.sequence) + } + let timestampOrder = lhs.timestampKey.compare(rhs.timestampKey) + if timestampOrder != .orderedSame { return timestampOrder } + return compareChatEventSequence(lhs.event.sequence, rhs.event.sequence) + } + + private func canAppendChatEvent(_ envelope: AgentChatEventEnvelope, after last: AgentChatEventEnvelope) -> Bool { + if let lastSequence = last.sequence, + let envelopeSequence = envelope.sequence, + envelopeSequence <= lastSequence { + return false + } + + if envelope.timestamp > last.timestamp { + return true + } + if envelope.timestamp == last.timestamp { + return (envelope.sequence ?? 0) >= (last.sequence ?? 0) + } + + return compareChatEvents(last, envelope) != .orderedDescending + } + + private func compareChatEvents( + _ lhs: AgentChatEventEnvelope, + _ rhs: AgentChatEventEnvelope + ) -> ComparisonResult { + if lhs.timestamp == rhs.timestamp { + return compareChatEventSequence(lhs.sequence, rhs.sequence) + } + let timestampOrder = chatEventTimestampSortKey(lhs.timestamp).compare(chatEventTimestampSortKey(rhs.timestamp)) + if timestampOrder != .orderedSame { return timestampOrder } + return compareChatEventSequence(lhs.sequence, rhs.sequence) + } + + private func chatEventTimestampSortKey(_ raw: String) -> String { + guard raw.hasSuffix("Z") else { return raw } + let withoutZone = raw.dropLast() + guard let dotIndex = withoutZone.lastIndex(of: ".") else { + return "\(withoutZone).000000000Z" + } + let prefix = withoutZone[.. ComparisonResult { + let left = lhs ?? 0 + let right = rhs ?? 0 + if left == right { return .orderedSame } + return left < right ? .orderedAscending : .orderedDescending } private func trimChatEventHistory(_ events: [AgentChatEventEnvelope]) -> [AgentChatEventEnvelope] { @@ -9366,7 +9924,7 @@ extension SyncService { if isEndedRuntime && !isFailedStatus && !isAwaiting { continue } if isRunningRuntime && !isFailedStatus { runningChatCount += 1 } - let started = Self.parseIso8601(session.startedAt) ?? now + let started = parseIso8601(session.startedAt) ?? now // For active sessions there is no `endedAt`. Use the chat summary's // `lastActivityAt` when available; fall back to `chatIdleSinceAt`. If // neither is set yet, treat a running session as fresh by falling back @@ -9377,9 +9935,9 @@ extension SyncService { // affects the running roster when activity timestamps are missing. let summary = chatSummaryCache[session.id] let lastActivity = - Self.parseIso8601(summary?.lastActivityAt ?? "") - ?? Self.parseIso8601(session.endedAt ?? "") - ?? Self.parseIso8601(session.chatIdleSinceAt ?? "") + parseIso8601(summary?.lastActivityAt ?? "") + ?? parseIso8601(session.endedAt ?? "") + ?? parseIso8601(session.chatIdleSinceAt ?? "") ?? (isRunningRuntime ? now : started) let elapsed = Int(max(0, lastActivity.timeIntervalSince(started))) @@ -9423,6 +9981,15 @@ extension SyncService { } } + let nextSignature = activeSessionsSignature( + agents: allAgents, + awaitingInputCount: awaitingInputCount, + runningChatCount: runningChatCount, + idleCount: idleCount + ) + guard nextSignature != activeSessionsSnapshotSignature else { return } + activeSessionsSnapshotSignature = nextSignature + activeSessions = allAgents awaitingInputSessionsCount = awaitingInputCount runningChatSessionCount = runningChatCount @@ -9431,6 +9998,36 @@ extension SyncService { scheduleWorkspaceSnapshotWrite() } + private func activeSessionsSignature( + agents: [AgentSnapshot], + awaitingInputCount: Int, + runningChatCount: Int, + idleCount: Int + ) -> Int { + var hasher = Hasher() + hasher.combine(agents.count) + hasher.combine(awaitingInputCount) + hasher.combine(runningChatCount) + hasher.combine(idleCount) + for agent in agents { + hasher.combine(agent.sessionId) + hasher.combine(agent.provider) + hasher.combine(agent.modelId) + hasher.combine(agent.laneName) + hasher.combine(agent.title) + hasher.combine(agent.status) + hasher.combine(agent.awaitingInput) + hasher.combine(agent.lastActivityAt.timeIntervalSince1970) + hasher.combine(agent.elapsedSeconds) + hasher.combine(agent.preview) + hasher.combine(agent.pendingInputItemId) + hasher.combine(agent.progress) + hasher.combine(agent.phase) + hasher.combine(agent.toolCalls) + } + return hasher.finalize() + } + private func pendingInputItemIdForSnapshot(sessionId: String) -> String? { let events = chatEventEnvelopesBySession[sessionId] ?? [] guard !events.isEmpty else { return nil } @@ -9460,7 +10057,7 @@ extension SyncService { state: item.state, mergeReady: (item.reviewStatus == "approved") && (item.checksStatus == "passing") && item.state == "open", branch: item.headBranch.isEmpty ? nil : item.headBranch, - updatedAt: Self.parseIso8601(item.updatedAt) + updatedAt: parseIso8601(item.updatedAt) ) } @@ -9486,13 +10083,10 @@ extension SyncService { } } - private static func parseIso8601(_ raw: String) -> Date? { + private func parseIso8601(_ raw: String) -> Date? { guard !raw.isEmpty else { return nil } - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let date = iso.date(from: raw) { return date } - iso.formatOptions = [.withInternetDateTime] - return iso.date(from: raw) + if let date = iso8601WithFractionalSecondsFormatter.date(from: raw) { return date } + return iso8601Formatter.date(from: raw) } } @@ -9576,8 +10170,10 @@ private final class SyncTailnetProbe { switch state { case .ready: complete(.reachable) - case .waiting: - break + case .waiting(let error): + if Self.probeResult(for: error) == .unresolvedHost { + complete(.unresolvedHost) + } case .failed(let error): complete(Self.probeResult(for: error)) case .cancelled: @@ -10100,3 +10696,308 @@ struct PrAutoMapCreateResult: Decodable, Equatable { let preflight: PrAutoMapPreflight let lane: LaneSummary } + +// MARK: - All-projects chat roster (mobile hub) +// +// A machine-wide projection of every project's lanes + chat sessions, pushed by +// the brain over `roster_snapshot` / `roster_delta` envelopes so the hub can +// render all projects' chats-grouped-by-lane at once without activating each +// project. This extension lives in the same file as the `rosterProjects` +// declaration so it can write the `private(set)` store. See the contract in +// `apps/desktop/src/shared/types/sync.ts` (search `SyncRoster`). +extension SyncService { + private static let rosterCacheKey = "ade.roster.cache.v1" + + /// Subscribe to the all-projects roster once per live connection. Older hosts + /// that don't implement the feed simply never answer, leaving `rosterSupported` + /// false so the hub falls back to the per-project catalog (lane counts only). + func subscribeRosterIfNeeded() { + guard canSendLiveRequests(), !rosterSubscribed else { return } + rosterSubscribed = true + var payload: [String: Any] = [:] + if let rosterSeq { + payload["sinceSeq"] = rosterSeq + } + sendEnvelope(type: "roster_subscribe", requestId: nil, payload: payload) + } + + /// Force a fresh full snapshot (used after a detected seq gap). + func requestRosterSnapshot() { + guard canSendLiveRequests() else { return } + rosterSubscribed = true + sendEnvelope(type: "roster_subscribe", requestId: nil, payload: [:]) + } + + func unsubscribeRoster() { + guard canSendLiveRequests() else { return } + rosterSubscribed = false + sendEnvelope(type: "roster_unsubscribe", requestId: nil, payload: [:]) + } + + func applyRosterSnapshot(_ snapshot: RemoteRosterSnapshotPayload) { + rosterProjects = sortRosterProjects(snapshot.projects) + rosterSeq = snapshot.seq + rosterSupported = true + rosterRevision &+= 1 + schedulePersistRoster() + } + + func applyRosterDelta(_ delta: RemoteRosterDeltaPayload) { + rosterSupported = true + switch rosterApplyDelta(current: rosterProjects, currentSeq: rosterSeq, delta: delta) { + case .needsSnapshot: + // No baseline or a seq gap — re-request a full snapshot rather than apply + // onto an unknown baseline (mirrors the chat_event sinceSeq discipline). + requestRosterSnapshot() + case .dropped: + break // duplicate / out-of-order replay + case let .applied(projects, seq): + rosterProjects = sortRosterProjects(projects) + rosterSeq = seq + rosterRevision &+= 1 + schedulePersistRoster() + } + } + + /// Roster entry for a catalog project, matched by id then normalized root path + /// (the brain derives both ids from the root, but match on root as a fallback). + func rosterProject(for project: MobileProjectSummary) -> RemoteRosterProject? { + if let direct = rosterProjects.first(where: { $0.projectId == project.id }) { + return direct + } + guard let root = normalizedProjectRoot(project.rootPath) else { return nil } + return rosterProjects.first { normalizedProjectRoot($0.rootPath) == root } + } + + private func sortRosterProjects(_ projects: [RemoteRosterProject]) -> [RemoteRosterProject] { + // Attention first, then most-recently-active, then name — so the projects + // that need the user float to the top of the hub. + projects.sorted { lhs, rhs in + if (lhs.attentionCount > 0) != (rhs.attentionCount > 0) { + return lhs.attentionCount > 0 + } + let lhsActivity = lhs.chats.compactMap { $0.lastActivityAt }.max() ?? lhs.lastOpenedAt ?? "" + let rhsActivity = rhs.chats.compactMap { $0.lastActivityAt }.max() ?? rhs.lastOpenedAt ?? "" + if lhsActivity != rhsActivity { + return lhsActivity > rhsActivity + } + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + + // MARK: Persistence (App Group UserDefaults — instant offline hub render) + + private func schedulePersistRoster() { + rosterPersistTask?.cancel() + let snapshot = rosterProjects + rosterPersistTask = Task { @MainActor [weak self] in + try? await Task.sleep(nanoseconds: 600_000_000) + guard let self, !Task.isCancelled else { return } + self.persistRoster(snapshot) + } + } + + private func persistRoster(_ projects: [RemoteRosterProject]) { + guard let data = try? JSONEncoder().encode(projects) else { return } + ADESharedContainer.defaults.set(data, forKey: Self.rosterCacheKey) + } + + func loadCachedRoster() -> [RemoteRosterProject] { + guard let data = ADESharedContainer.defaults.data(forKey: Self.rosterCacheKey), + let projects = try? JSONDecoder().decode([RemoteRosterProject].self, from: data) + else { return [] } + return projects + } + + /// Build the active project's roster entry from the phone's local DB (its + /// lanes + chat/CLI sessions are already synced and authoritative). This makes + /// the active project's hub card show real chats instantly, without depending + /// on the cross-project roster feed (which only the active brain build serves, + /// and which only the brain can populate for NON-active projects). + func buildActiveProjectLocalRoster() -> RemoteRosterProject? { + guard let projectId = activeProjectId else { return nil } + let lanes = database.fetchLanes(includeArchived: false) + let visibleLaneIds = Set(lanes.map(\.id)) + let scopedSessions = database.fetchSessions().filter { session in + session.archivedAt == nil && visibleLaneIds.contains(session.laneId) + } + let topLevelIds = Set(scopedSessions.filter { isRosterTopLevelToolType($0.toolType) }.map(\.id)) + let visibleSessions = scopedSessions.filter { session in + if isRosterTopLevelToolType(session.toolType) { return true } + guard let parentId = normalizedRosterParentSessionId(session), + topLevelIds.contains(parentId) else { + return false + } + return true + } + let chats: [RemoteRosterChat] = visibleSessions.map { session in + let status = rosterStatus(forSession: session) + return RemoteRosterChat( + id: session.id, + laneId: session.laneId, + chatSessionId: session.chatSessionId, + title: session.title, + provider: session.toolType, + model: nil, + toolType: session.toolType, + status: status, + awaitingInput: status == .awaiting, + pinned: session.pinned, + archived: false, + lastActivityAt: session.endedAt ?? session.startedAt, + preview: session.lastOutputPreview + ) + } + let rosterLanes: [RemoteRosterLane] = lanes.map { lane in + RemoteRosterLane( + id: lane.id, + name: lane.name, + color: lane.color, + icon: lane.icon?.rawValue, + laneType: lane.laneType, + branchRef: lane.branchRef + ) + } + let active = activeProject + let roster = RemoteRosterProject( + projectId: projectId, + rootPath: activeProjectRootPath ?? active?.rootPath, + displayName: active?.displayName ?? "Project", + iconDataUrl: active?.iconDataUrl, + lastOpenedAt: active?.lastOpenedAt, + booted: true, + runningCount: chats.filter(\.isRunning).count, + attentionCount: chats.filter(\.needsAttention).count, + lanes: rosterLanes, + chats: chats + ) + upsertLocalRosterProject(roster) + return roster + } + + private func upsertLocalRosterProject(_ local: RemoteRosterProject) { + var next = rosterProjects + let localRoot = normalizedProjectRoot(local.rootPath) + let existingIndex = next.firstIndex { project in + project.projectId == local.projectId + || (localRoot != nil && normalizedProjectRoot(project.rootPath) == localRoot) + } + + if let existingIndex { + let merged = mergedRosterProject(remote: next[existingIndex], local: local) + guard next[existingIndex] != merged else { return } + next[existingIndex] = merged + } else { + next.append(local) + } + + rosterProjects = sortRosterProjects(next) + rosterRevision &+= 1 + schedulePersistRoster() + } + + private func mergedRosterProject(remote: RemoteRosterProject, local: RemoteRosterProject) -> RemoteRosterProject { + var merged = remote + + var laneIds = Set(merged.lanes.map(\.id)) + for lane in local.lanes where laneIds.insert(lane.id).inserted { + merged.lanes.append(lane) + } + + var chatIndexById = Dictionary(uniqueKeysWithValues: merged.chats.enumerated().map { ($0.element.id, $0.offset) }) + for localChat in local.chats { + if let index = chatIndexById[localChat.id] { + merged.chats[index] = mergedRosterChat(remote: merged.chats[index], local: localChat) + } else { + chatIndexById[localChat.id] = merged.chats.count + merged.chats.append(localChat) + } + } + + merged.booted = remote.booted || local.booted + merged.runningCount = merged.chats.filter(\.isRunning).count + merged.attentionCount = merged.chats.filter(\.needsAttention).count + merged.chats.sort { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } + return merged + } + + private func mergedRosterChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { + var merged = remote + let localIsAtLeastAsFresh = (local.lastActivityAt ?? "") >= (remote.lastActivityAt ?? "") + + if localIsAtLeastAsFresh { + merged.status = local.status + merged.awaitingInput = local.awaitingInput ?? remote.awaitingInput + merged.pinned = local.pinned ?? remote.pinned + merged.archived = local.archived ?? remote.archived + merged.lastActivityAt = nonEmptyRosterString(local.lastActivityAt) ?? remote.lastActivityAt + merged.title = nonEmptyRosterString(local.title) ?? remote.title + merged.preview = nonEmptyRosterString(local.preview) ?? remote.preview + } + + merged.provider = nonEmptyRosterString(remote.provider) ?? local.provider + merged.model = nonEmptyRosterString(remote.model) ?? local.model + merged.toolType = nonEmptyRosterString(remote.toolType) ?? local.toolType + merged.chatSessionId = nonEmptyRosterString(remote.chatSessionId) ?? local.chatSessionId + return merged + } + + private func nonEmptyRosterString(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } + + private func isRosterTopLevelToolType(_ toolType: String?) -> Bool { + let raw = toolType? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() ?? "" + guard !raw.isEmpty else { return false } + if raw == "codex-chat" || raw == "claude-chat" || raw == "opencode-chat" || raw == "cursor" { + return true + } + return raw.hasSuffix("-chat") + } + + private func normalizedRosterParentSessionId(_ session: TerminalSessionSummary) -> String? { + let parentId = session.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !parentId.isEmpty, parentId != session.id else { return nil } + return parentId + } + + private func rosterStatus(forSession session: TerminalSessionSummary) -> RemoteRosterChatStatus { + let runtimeState = session.runtimeState.lowercased() + let status = session.status.lowercased() + if status == "awaiting-input" || status == "awaiting_input" || runtimeState == "waiting-input" { + return .awaiting + } + switch runtimeState { + case "running": return .running + case "idle": return .idle + case "stopped", "exited", "completed", "interrupted": return .ended + case "failed": return .failed + default: break + } + switch status { + case "running", "active": return .running + case "idle", "paused": return .idle + case "failed": return .failed + case "ended", "completed", "interrupted", "exited": return .ended + default: return .ended + } + } + + /// Run a chat lifecycle action (`chat.archive` / `chat.unarchive` / + /// `chat.delete`) on a roster chat, routing to its project — which may not be + /// the active one — without switching the active sync project. Refreshes the + /// roster so the row updates promptly. + func performRosterChatAction(_ action: String, sessionId: String, project: MobileProjectSummary) async throws { + let foreign = !isActiveProject(project) + _ = try await sendCommand( + action: action, + args: ["sessionId": sessionId], + targetProjectId: foreign ? project.id : nil, + targetProjectRootPath: foreign ? project.rootPath : nil + ) + requestRosterSnapshot() + } +} diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index e11543935..3f9b794c7 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -741,6 +741,47 @@ struct ADEProjectHomeButton: View { /// separated by 1pt white α0.08 vertical dividers. All tap targets, wiring and /// accessibility labels are preserved exactly. @available(iOS 17.0, *) +/// Permanent "back to the hub" affordance shown at the leading edge of every +/// in-project tab header: a left chevron + the active project's icon. Tapping it +/// returns to the all-projects hub (`showProjectHome`) from any tab's main page. +/// Replaces the old top-right "Projects" grid button. +struct ADEHubBackButton: View { + @EnvironmentObject private var syncService: SyncService + + var body: some View { + Button { + syncService.showProjectHome() + } label: { + HStack(spacing: 5) { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(ADEColor.accent) + if let icon = projectIconImage(from: syncService.activeProject?.iconDataUrl) { + Image(uiImage: icon).projectIconStyle(size: 24, cornerRadius: 6) + } else { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(ADEColor.recessedBackground) + .frame(width: 24, height: 24) + .overlay( + Image(systemName: "folder") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + ) + } + } + .padding(.vertical, 5) + .padding(.leading, 8) + .padding(.trailing, 6) + .background(ADEColor.glassBackground, in: Capsule()) + .overlay(Capsule().stroke(Color.white.opacity(0.12), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .accessibilityLabel("Back to all projects") + .accessibilityHint("Returns to the project hub.") + } +} + struct ADERootToolbarControls: View { @EnvironmentObject private var syncService: SyncService @EnvironmentObject private var drawer: AttentionDrawerModel @@ -754,113 +795,81 @@ struct ADERootToolbarControls: View { self.scopeKey = scopeKey } - private var presentation: ConnectionHealthPresentation { - ConnectionHealthPresentation( - health: syncService.connectionHealth, - connectionState: syncService.connectionState, - hostName: syncService.hostName - ) - } - - private var connectionTint: Color { presentation.tint } - private var connectionIsAlive: Bool { presentation.showsConnectedGlow } - private var connectionAccessibilityLabel: String { - "Machine connection · \(presentation.accessibilityLabel)" - } - private var hasUnread: Bool { drawer.unreadCount > 0 } var body: some View { - HStack(spacing: 0) { - toolbarIconButton( - icon: "laptopcomputer", - tint: connectionTint, - isAlive: connectionIsAlive, - accessibilityLabel: connectionAccessibilityLabel, - action: { syncService.settingsPresented = true } - ) - - divider - - toolbarIconButton( - icon: "square.grid.2x2.fill", - tint: PrsGlass.accentTop, - isAlive: false, - iconImage: projectIconImage(from: syncService.activeProject?.iconDataUrl), - accessibilityLabel: "Projects", - action: { syncService.showProjectHome() } - ) - - divider - - ZStack(alignment: .topTrailing) { - toolbarIconButton( - icon: "bell.fill", - tint: hasUnread ? ADESharedTheme.warningAmber : PrsGlass.textSecondary, - isAlive: hasUnread, - accessibilityLabel: "Attention items: \(drawer.unreadCount)", - action: { syncService.attentionDrawerPresented = true } - ) - - if hasUnread { - Circle() - .fill(ADEColor.warning) - .frame(width: 7, height: 7) - .overlay( - Circle().stroke(PrsGlass.ink, lineWidth: 1.25) + toolbarBody + .animation(.snappy(duration: 0.2), value: drawer.unreadCount) + .padding(.vertical, 4) + .background { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(ADEColor.glassBackground) + } + .overlay { + // Soft vertical highlight (white 0.10 → 0). + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill( + LinearGradient( + colors: [Color.white.opacity(0.10), .clear], + startPoint: .top, + endPoint: .bottom ) - .shadow(color: ADEColor.warning.opacity(0.45), radius: 3, x: 0, y: 0) - .offset(x: -7, y: 6) - .transition(.scale.combined(with: .opacity)) - .accessibilityHidden(true) - } + ) + .allowsHitTesting(false) } - .animation(.snappy(duration: 0.2), value: drawer.unreadCount) - } - .padding(.vertical, 4) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(ADEColor.glassBackground) - } - .overlay { - // Soft vertical highlight (white 0.10 → 0). - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.10), .clear], - startPoint: .top, - endPoint: .bottom + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .strokeBorder( + LinearGradient( + colors: [Color.white.opacity(0.22), Color.white.opacity(0.04)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 1 ) - ) - .allowsHitTesting(false) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder( - LinearGradient( - colors: [Color.white.opacity(0.22), Color.white.opacity(0.04)], - startPoint: .top, - endPoint: .bottom - ), - lineWidth: 1 - ) - .allowsHitTesting(false) - } - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.white.opacity(0.08), lineWidth: 0.75) - .allowsHitTesting(false) + .allowsHitTesting(false) + } + .overlay { + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 0.75) + .allowsHitTesting(false) + } + .compositingGroup() + .shadow(color: Color.black.opacity(0.28), radius: 12, x: 0, y: 5) + .fixedSize(horizontal: true, vertical: false) + } + + private var toolbarBody: some View { + ZStack(alignment: .topTrailing) { + attentionButton + unreadBadge } - .compositingGroup() - .shadow(color: Color.black.opacity(0.28), radius: 12, x: 0, y: 5) - .fixedSize(horizontal: true, vertical: false) } - private var divider: some View { - Rectangle() - .fill(Color.white.opacity(0.08)) - .frame(width: 1, height: 18) - .allowsHitTesting(false) + private var attentionButton: some View { + toolbarIconButton( + icon: "bell.fill", + tint: hasUnread ? ADESharedTheme.warningAmber : PrsGlass.textSecondary, + isAlive: hasUnread, + accessibilityLabel: "Attention items: \(drawer.unreadCount)", + action: { syncService.attentionDrawerPresented = true } + ) + } + + @ViewBuilder + private var unreadBadge: some View { + if hasUnread { + Circle() + .fill(ADEColor.warning) + .frame(width: 7, height: 7) + .overlay( + Circle().stroke(PrsGlass.ink, lineWidth: 1.25) + ) + .shadow(color: ADEColor.warning.opacity(0.45), radius: 3, x: 0, y: 0) + .offset(x: -7, y: 6) + .transition(.scale.combined(with: .opacity)) + .accessibilityHidden(true) + } } @ViewBuilder @@ -924,37 +933,42 @@ struct ADERootToolbarLeading: View { struct ADERootTopBar: View { let title: String let showsGlobalControls: Bool + let showsHubBackButton: Bool let actions: Actions init( title: String, showsGlobalControls: Bool = true, + showsHubBackButton: Bool = true, @ViewBuilder actions: () -> Actions ) { self.title = title self.showsGlobalControls = showsGlobalControls + self.showsHubBackButton = showsHubBackButton self.actions = actions() } var body: some View { - ZStack { + HStack(spacing: 8) { + // Permanent back-to-hub control, at the leading edge of every root tab. + if showsHubBackButton { + ADEHubBackButton() + } + if !title.isEmpty { Text(title) .font(.system(size: 22, weight: .heavy, design: .rounded)) .foregroundStyle(PrsGlass.textPrimary) .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.leading, 4) + .padding(.leading, showsHubBackButton ? 0 : 4) .shadow(color: Color.black.opacity(0.55), radius: 8, x: 0, y: 3) .accessibilityAddTraits(.isHeader) } - HStack(spacing: 8) { - Spacer(minLength: 0) - actions - if showsGlobalControls { - ADERootToolbarControls(scopeKey: title) - } + Spacer(minLength: 8) + actions + if showsGlobalControls { + ADERootToolbarControls(scopeKey: title) } } .padding(.horizontal, 16) @@ -979,9 +993,10 @@ struct ADERootTopBar: View { @available(iOS 17.0, *) extension ADERootTopBar where Actions == EmptyView { - init(title: String, showsGlobalControls: Bool = true) { + init(title: String, showsGlobalControls: Bool = true, showsHubBackButton: Bool = true) { self.title = title self.showsGlobalControls = showsGlobalControls + self.showsHubBackButton = showsHubBackButton self.actions = EmptyView() } } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift new file mode 100644 index 000000000..786825b75 --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -0,0 +1,994 @@ +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. + +// MARK: - Top bar + +struct HubTopBar: View { + @EnvironmentObject private var syncService: SyncService + let onAdd: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image("BrandMark") + .resizable() + .renderingMode(.original) + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(height: 26) + .frame(maxWidth: 92, alignment: .leading) + .shadow(color: ADEColor.purpleAccent.opacity(0.35), radius: 10) + .accessibilityLabel("ADE") + + Spacer(minLength: 8) + + HubConnectionPill() + + HubCircularButton(systemImage: "plus", tint: ADEColor.accent, action: onAdd) + .accessibilityLabel("Add project") + + HubCircularButton(systemImage: "gearshape", tint: ADEColor.textSecondary) { + syncService.settingsPresented = true + } + .accessibilityLabel("Settings") + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 10) + } +} + +private struct HubCircularButton: View { + let systemImage: String + let tint: Color + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: systemImage) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 38, height: 38) + .background(ADEColor.cardBackground.opacity(0.72), in: Circle()) + .overlay(Circle().stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) + } + .buttonStyle(.plain) + } +} + +/// Compact "● Machine" pill — tap opens connection settings. +struct HubConnectionPill: View { + @EnvironmentObject private var syncService: SyncService + + private var tint: Color { + let health = syncService.connectionHealth + switch health.transport { + case .connected: return health.load == .strained ? ADEColor.warning : ADEColor.success + case .connecting: return ADEColor.warning + case .unreachable: return ADEColor.danger + case .disconnected: return ADEColor.textMuted + } + } + + private var label: String { + if let host = syncService.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty { + return host + } + switch syncService.connectionState { + case .connected, .syncing: return "Connected" + case .connecting: return "Connecting…" + case .error: return "Error" + case .disconnected: return "Offline" + } + } + + var body: some View { + Button { + syncService.settingsPresented = true + } label: { + HStack(spacing: 6) { + Circle().fill(tint).frame(width: 7, height: 7) + Text(label) + .font(.system(.caption, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + } + .padding(.horizontal, 11) + .padding(.vertical, 8) + .background(ADEColor.cardBackground.opacity(0.62), in: Capsule()) + .overlay(Capsule().stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) + } + .buttonStyle(.plain) + .accessibilityLabel("Machine connection: \(label)") + .accessibilityHint("Opens connection settings.") + } +} + +// MARK: - Project card + +struct HubProjectPresentation: Equatable, Identifiable { + let project: MobileProjectSummary + let isActive: Bool + let isSwitching: Bool + 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 + + var id: String { project.id } + + init( + project: MobileProjectSummary, + isActive: Bool, + isSwitching: Bool, + isLoading: Bool, + laneCount: Int, + chatCount: Int, + runningCount: Int, + attentionCount: Int, + lanes: [HubLanePresentation] + ) { + self.project = project + self.isActive = isActive + self.isSwitching = isSwitching + 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")" + self.metaLine = "\(lanePart) · \(chatPart)" + self.renderSignature = hubProjectRenderSignature( + project: project, + isActive: isActive, + isSwitching: isSwitching, + isLoading: isLoading, + laneCount: laneCount, + chatCount: chatCount, + runningCount: runningCount, + attentionCount: attentionCount, + lanes: lanes + ) + } + + static func == (lhs: HubProjectPresentation, rhs: HubProjectPresentation) -> Bool { + lhs.renderSignature == rhs.renderSignature + } +} + +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 } + + init( + lane: RemoteRosterLane, + rows: [HubChatRowPresentation], + totalCount: Int, + runningCount: Int, + attentionCount: 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 + ) + } + + static func == (lhs: HubLanePresentation, rhs: HubLanePresentation) -> Bool { + lhs.renderSignature == rhs.renderSignature + } +} + +struct HubChatRowPresentation: Equatable, Identifiable { + let chat: RemoteRosterChat + let title: String + let preview: String? + let providerKey: String? + let activityLabel: String? + let statusString: String + let childRows: [HubChatRowPresentation] + fileprivate let renderSignature: Int + + var id: String { chat.id } + var childCount: Int { childRows.count } + + init( + chat: RemoteRosterChat, + title: String, + preview: String?, + providerKey: String?, + activityLabel: String?, + statusString: String, + childRows: [HubChatRowPresentation] + ) { + self.chat = chat + self.title = title + self.preview = preview + self.providerKey = providerKey + self.activityLabel = activityLabel + self.statusString = statusString + self.childRows = childRows + self.renderSignature = hubChatRowRenderSignature( + chat: chat, + title: title, + preview: preview, + providerKey: providerKey, + activityLabel: activityLabel, + statusString: statusString, + childRows: childRows + ) + } + + static func make(chat: RemoteRosterChat, childRows: [HubChatRowPresentation] = []) -> HubChatRowPresentation { + let trimmedTitle = chat.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPreview = chat.preview?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return HubChatRowPresentation( + chat: chat, + title: trimmedTitle.isEmpty ? "Untitled chat" : trimmedTitle, + preview: trimmedPreview.isEmpty ? nil : trimmedPreview, + providerKey: chat.providerKey, + activityLabel: hubRelativeTimestamp(chat.lastActivityAt), + statusString: chat.normalizedStatusString, + childRows: childRows + ) + } + + static func == (lhs: HubChatRowPresentation, rhs: HubChatRowPresentation) -> Bool { + lhs.renderSignature == rhs.renderSignature + } +} + +private func hubProjectRenderSignature( + project: MobileProjectSummary, + isActive: Bool, + isSwitching: Bool, + isLoading: Bool, + laneCount: Int, + chatCount: Int, + runningCount: Int, + attentionCount: Int, + lanes: [HubLanePresentation] +) -> Int { + var hasher = Hasher() + hasher.combine(project.id) + hasher.combine(project.displayName) + hasher.combine(hubProjectIconSignature(project.iconDataUrl)) + hasher.combine(project.laneCount) + hasher.combine(project.isOpen) + hasher.combine(isActive) + hasher.combine(isSwitching) + hasher.combine(isLoading) + hasher.combine(laneCount) + hasher.combine(chatCount) + hasher.combine(runningCount) + hasher.combine(attentionCount) + hasher.combine(lanes.map(\.renderSignature)) + return hasher.finalize() +} + +private func hubProjectIconSignature(_ dataUrl: String?) -> String { + guard let dataUrl, !dataUrl.isEmpty else { return "" } + let byteCount = dataUrl.utf8.count + let prefix = String(dataUrl.prefix(32)) + let suffix = byteCount > 32 ? String(dataUrl.suffix(32)) : "" + return "\(byteCount)|\(prefix)|\(suffix)" +} + +private func hubLaneRenderSignature( + lane: RemoteRosterLane, + rows: [HubChatRowPresentation], + totalCount: Int, + runningCount: Int, + attentionCount: Int +) -> Int { + var hasher = Hasher() + hasher.combine(lane.id) + hasher.combine(lane.name) + 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() +} + +private func hubChatRowRenderSignature( + chat: RemoteRosterChat, + title: String, + preview: String?, + providerKey: String?, + activityLabel: String?, + statusString: String, + childRows: [HubChatRowPresentation] +) -> Int { + var hasher = Hasher() + hasher.combine(chat.id) + hasher.combine(chat.laneId) + hasher.combine(chat.chatSessionId) + hasher.combine(title) + hasher.combine(preview) + hasher.combine(providerKey) + hasher.combine(activityLabel) + hasher.combine(statusString) + hasher.combine(chat.pinned) + hasher.combine(chat.archived) + hasher.combine(chat.lastActivityAt) + hasher.combine(childRows.map(\.renderSignature)) + return hasher.finalize() +} + +func buildHubProjectPresentation( + project: MobileProjectSummary, + roster: RemoteRosterProject?, + isActive: Bool, + isSwitching: Bool +) -> HubProjectPresentation { + guard let roster else { + return HubProjectPresentation( + project: project, + isActive: isActive, + isSwitching: isSwitching, + isLoading: isActive, + laneCount: project.laneCount, + chatCount: 0, + runningCount: 0, + attentionCount: 0, + lanes: [] + ) + } + + let laneById = Dictionary(roster.lanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + let visibleChats = roster.chats.filter { chat in + chat.archived != true && laneById[chat.laneId] != nil + } + let topLevelChats = visibleChats.filter(\.isChatTool) + let topLevelChatIds = Set(topLevelChats.map(\.id)) + let childRowsByParentId = Dictionary(grouping: visibleChats.filter { chat in + guard !chat.isChatTool, + let parentId = chat.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), + !parentId.isEmpty, + parentId != chat.id + else { return false } + return topLevelChatIds.contains(parentId) + }, by: { $0.chatSessionId ?? "" }) + .mapValues { chats in + chats + .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } + .map { HubChatRowPresentation.make(chat: $0) } + } + let topLevelChatsByLane = Dictionary(grouping: topLevelChats, by: \.laneId) + + let lanes = roster.lanes.compactMap { lane -> HubLanePresentation? in + let laneChats = (topLevelChatsByLane[lane.id] ?? []) + .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } + guard !laneChats.isEmpty else { return nil } + let rows = laneChats.map { chat in + HubChatRowPresentation.make(chat: chat, childRows: childRowsByParentId[chat.id] ?? []) + } + return HubLanePresentation( + lane: lane, + rows: rows, + totalCount: rows.count, + runningCount: laneChats.filter(\.isRunning).count, + attentionCount: laneChats.filter(\.needsAttention).count + ) + } + let chatCount = lanes.reduce(0) { $0 + $1.rows.count } + + return HubProjectPresentation( + project: project, + isActive: isActive, + isSwitching: isSwitching, + isLoading: false, + laneCount: roster.lanes.count, + chatCount: chatCount, + runningCount: topLevelChats.filter(\.isRunning).count, + attentionCount: topLevelChats.filter(\.needsAttention).count, + lanes: lanes + ) +} + +struct HubProjectCard: View, Equatable { + let presentation: HubProjectPresentation + let isCollapsed: Bool + let collapsedLaneKeysSnapshot: Set + @Binding var collapsedLaneKeys: Set + let onToggleCollapse: () -> Void + let onOpenProject: () -> Void + let onOpenChat: (RemoteRosterChat, RemoteRosterLane?) -> Void + let onArchiveChat: (RemoteRosterChat) -> Void + let onDeleteChat: (RemoteRosterChat) -> Void + let onForget: () -> Void + + private var project: MobileProjectSummary { presentation.project } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Only the project itself carries the card surface + border. Drawn above + // the expanded rows (zIndex) with an opaque backing so the rows slide out + // from *behind* the card, never over it. + header + .background(ADEColor.pageBackground) + .background(ADEColor.cardBackground.opacity(0.62), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(presentation.isActive ? ADEColor.accent.opacity(0.5) : ADEColor.border.opacity(0.8), lineWidth: 1) + ) + .zIndex(1) + + // Expanded lanes + chats hang below the card, indented and unboundaried. + if !isCollapsed { + Group { + if presentation.lanes.isEmpty { + HubProjectEmptyChats(isLoading: presentation.isLoading) + } else { + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(presentation.lanes) { lanePresentation in + HubLaneSection( + project: project, + presentation: lanePresentation, + isCollapsed: collapsedLaneKeysSnapshot.contains(laneKey(lanePresentation.lane)), + onToggle: { toggleLane(lanePresentation.lane) }, + onOpenChat: { chat in onOpenChat(chat, lanePresentation.lane) }, + onArchiveChat: onArchiveChat, + onDeleteChat: onDeleteChat + ) + .equatable() + } + } + .padding(.top, 10) + } + } + .padding(.leading, 16) + .padding(.trailing, 4) + .zIndex(0) + // Pure vertical slide (no cross-fade). The parent `.clipped()` masks the + // rows to the card's animating bounds, so on collapse they roll up + // behind the card instead of lingering over the cards below. + .transition(.move(edge: .top)) + } + } + .clipped() + } + + private var header: some View { + HStack(spacing: 11) { + Button(action: onToggleCollapse) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isCollapsed ? "Expand project" : "Collapse project") + + HubProjectIcon(iconDataUrl: project.iconDataUrl, isActive: presentation.isActive, size: 44) + + // 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()) + } + .buttonStyle(.plain) + + // Lane/chat counts live to the left of the open arrow now that the name + // owns the full leading run. + Text(presentation.metaLine) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + .fixedSize() + + if presentation.isSwitching { + ProgressView().controlSize(.small) + } else { + Button(action: onOpenProject) { + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.accent.opacity(0.8)) + .frame(width: 30, height: 30) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Open \(project.displayName)") + .accessibilityHint("Opens the full project view.") + } + } + .padding(12) + .contentShape(Rectangle()) + .contextMenu { + Button { onOpenProject() } label: { Label("Open project", systemImage: "rectangle.stack") } + Button(role: .destructive, action: onForget) { Label("Remove from list", systemImage: "trash") } + } + } + + private func laneKey(_ lane: RemoteRosterLane) -> String { "\(project.id)/\(lane.id)" } + private func toggleLane(_ lane: RemoteRosterLane) { + let key = laneKey(lane) + withAnimation(.easeOut(duration: 0.16)) { + if collapsedLaneKeys.contains(key) { collapsedLaneKeys.remove(key) } else { collapsedLaneKeys.insert(key) } + } + } + + private var collapsedLaneSignature: [String] { + let relevantKeys = presentation.lanes.map { laneKey($0.lane) } + return relevantKeys.filter { collapsedLaneKeysSnapshot.contains($0) }.sorted() + } + + static func == (lhs: HubProjectCard, rhs: HubProjectCard) -> Bool { + lhs.presentation == rhs.presentation + && lhs.isCollapsed == rhs.isCollapsed + && lhs.collapsedLaneSignature == rhs.collapsedLaneSignature + } +} + +struct HubProjectIcon: View { + let iconDataUrl: String? + let isActive: Bool + // The real project logo art is already a rounded-square glyph, so we render it + // edge-to-edge (no dark bezel) at this size. Only the folder fallback keeps a + // recessed backing so the SF Symbol has something to sit on. + var size: CGFloat = 38 + + var body: some View { + if let image = projectIconImage(from: iconDataUrl) { + Image(uiImage: image).projectIconStyle(size: size, cornerRadius: size * 0.24) + } else { + RoundedRectangle(cornerRadius: size * 0.21, style: .continuous) + .fill(isActive ? ADEColor.accent.opacity(0.16) : ADEColor.recessedBackground) + .frame(width: size, height: size) + .overlay( + Image(systemName: "folder") + .font(.system(size: size * 0.4, weight: .semibold)) + .foregroundStyle(isActive ? ADEColor.accent : ADEColor.textSecondary) + ) + } + } +} + +// MARK: - Lane section + +struct HubLaneSection: View, Equatable { + let project: MobileProjectSummary + let presentation: HubLanePresentation + let isCollapsed: Bool + let onToggle: () -> Void + let onOpenChat: (RemoteRosterChat) -> Void + let onArchiveChat: (RemoteRosterChat) -> Void + let onDeleteChat: (RemoteRosterChat) -> Void + + private var lane: RemoteRosterLane { presentation.lane } + private var laneTint: Color { LaneColorPalette.displayColor(forHex: lane.color) } + + private var laneIcon: LaneIcon? { lane.icon.flatMap(LaneIcon.init(rawValue:)) } + + var body: some View { + // Mirrors the Work tab's lane section header: chevron, lane logo mark, and + // the lane name in its own color, with a count badge on the trailing edge. + VStack(alignment: .leading, spacing: 4) { + Button(action: onToggle) { + HStack(spacing: 8) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 10, alignment: .center) + WorkLaneLogoMark(color: laneTint, laneIcon: laneIcon, size: 11) + .frame(width: 13, height: 13) + Text(lane.name) + .font(.system(.caption, design: .rounded).weight(.semibold)) + .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) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(ADEColor.surfaceBackground.opacity(0.65), in: Capsule()) + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + // Opaque backing + zIndex so chat rows slide up behind the lane header on + // collapse instead of showing through it. + .background(ADEColor.pageBackground) + .zIndex(1) + + if !isCollapsed { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(presentation.rows) { row in + HubChatRow( + row: row, + laneTint: laneTint, + onOpen: { onOpenChat(row.chat) }, + onArchive: { onArchiveChat(row.chat) }, + onDelete: { onDeleteChat(row.chat) } + ) + .equatable() + } + } + .padding(.leading, 6) + .zIndex(0) + .transition(.move(edge: .top)) + } + } + .clipped() + } + + static func == (lhs: HubLaneSection, rhs: HubLaneSection) -> Bool { + lhs.project.id == rhs.project.id + && lhs.presentation == rhs.presentation + && lhs.isCollapsed == rhs.isCollapsed + } +} + +// MARK: - Chat row + +struct HubChatRow: View, Equatable { + let row: HubChatRowPresentation + let laneTint: Color + var compact = false + let onOpen: () -> Void + let onArchive: () -> Void + let onDelete: () -> Void + + var body: some View { + // Deliberately minimal: provider logo, chat name, and the relative + // timestamp. Nothing else competes for the eye at the hub's glance level. + HStack(spacing: 10) { + WorkProviderBareLogo(provider: row.providerKey, fallbackSymbol: "terminal.fill", tint: ADEColor.textSecondary, size: compact ? 16 : 20) + + Text(row.title) + .font(.system(.footnote, design: .rounded).weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + if let activity = row.activityLabel { + Text(activity) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + } + } + .padding(.horizontal, 8) + .padding(.vertical, compact ? 5 : 7) + .contentShape(Rectangle()) + .accessibilityHidden(true) + .overlay { + Button(action: onOpen) { + Color.black.opacity(0.001) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(row.title) + .accessibilityHint("Opens chat.") + } + // The hub uses a scrolling LazyVStack (not a List), where SwiftUI + // `.swipeActions` are unavailable — so pin/archive/close are offered through + // a long-press context menu instead, routed to the chat's project. + .contextMenu { + Button { onOpen() } label: { Label("Open chat", systemImage: "bubble.left.and.bubble.right") } + Button { onArchive() } label: { Label("Archive", systemImage: "archivebox") } + Button(role: .destructive) { onDelete() } label: { Label("Close chat", systemImage: "xmark.circle") } + } + } + + static func == (lhs: HubChatRow, rhs: HubChatRow) -> Bool { + lhs.row == rhs.row && lhs.compact == rhs.compact + } +} + +struct HubStatusDot: View { + let status: String + var body: some View { + Circle() + .fill(workChatStatusTint(status)) + .frame(width: 8, height: 8) + .accessibilityHidden(true) + } +} + +// 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 HubProjectEmptyChats: View { + let isLoading: Bool + var body: some View { + HStack(spacing: 8) { + if isLoading { + ProgressView().controlSize(.small) + Text("Loading chats…") + } else { + Image(systemName: "tray") + .foregroundStyle(ADEColor.textMuted) + Text("No chats yet") + } + } + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.bottom, 12) + } +} + +struct HubConnectingCard: View { + var body: some View { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("Connecting to your machine…") + .font(.system(.subheadline, design: .rounded)) + .foregroundStyle(ADEColor.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } +} + +struct HubEmptyProjectsCard: View { + @EnvironmentObject private var syncService: SyncService + var body: some View { + VStack(spacing: 8) { + Image(systemName: "folder.badge.plus") + .font(.system(size: 26)) + .foregroundStyle(ADEColor.textMuted) + Text("No projects on \(syncService.hostName ?? "this machine")") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("Add a project to start vibecoding from your phone.") + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + .padding(.horizontal, 16) + } +} + +// MARK: - No-machine state (preserves the old ProjectHomeView landing) + +struct HubNoMachineState: View { + @EnvironmentObject private var syncService: SyncService + + var body: some View { + VStack(spacing: 0) { + Image("BrandMark") + .resizable() + .renderingMode(.original) + .interpolation(.high) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 280) + .frame(height: 142) + .frame(maxWidth: .infinity) + .shadow(color: ADEColor.purpleAccent.opacity(0.45), radius: 24) + .padding(.top, 88) + .accessibilityLabel("ADE") + + HStack(spacing: 8) { + Circle().fill(ADEColor.textMuted).frame(width: 8, height: 8) + Image(systemName: "desktopcomputer") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(syncService.connectionState == .error ? "Cannot reach machine" : "No machine attached") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(ADEColor.cardBackground.opacity(0.62), in: Capsule()) + .overlay(Capsule().stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) + .padding(.top, 30) + + Spacer(minLength: 40) + + Button { + syncService.settingsPresented = true + } label: { + HStack(spacing: 10) { + Image(systemName: "link") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + Text("Connect Machine") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background(ADEColor.accent.opacity(0.14), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(ADEColor.accent.opacity(0.4), lineWidth: 1)) + } + .buttonStyle(.plain) + .padding(.bottom, 56) + } + .frame(maxWidth: 520) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .padding(.horizontal, 22) + } +} + +// MARK: - Created toast (after a drawer send) + +struct HubCreatedToast: View { + let toast: HubCreatedChat + let onOpen: () -> Void + let onDismiss: () -> Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 18)) + .foregroundStyle(ADEColor.success) + VStack(alignment: .leading, spacing: 1) { + Text("\(toast.isCli ? "CLI session" : "Chat") created") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text("\(toast.projectName) · \(toast.laneName)") + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Spacer(minLength: 8) + Button(action: onOpen) { + Text("Open") + .font(.system(.footnote, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(ADEColor.accent.opacity(0.14), in: Capsule()) + } + .buttonStyle(.plain) + Button(action: onDismiss) { + Image(systemName: "xmark") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 26, height: 26) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(ADEColor.surfaceBackground.opacity(0.96), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 14, style: .continuous).stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) + .shadow(color: .black.opacity(0.16), radius: 8, y: 3) + } +} + +// MARK: - Helpers + +private enum HubTimestampFormatters { + static let fractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static let plain = ISO8601DateFormatter() + + static let relative: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter + }() +} + +private let hubRelativeTimestampCache: NSCache = { + let cache = NSCache() + cache.countLimit = 1_024 + return cache +}() + +func hubRelativeTimestamp(_ value: String?) -> String? { + guard let value, !value.isEmpty else { return nil } + let minuteBucket = Int(Date().timeIntervalSince1970 / 60) + let cacheKey = "\(minuteBucket)|\(value)" as NSString + if let cached = hubRelativeTimestampCache.object(forKey: cacheKey) { + return cached as String + } + guard let date = HubTimestampFormatters.fractional.date(from: value) + ?? HubTimestampFormatters.plain.date(from: value) + else { return nil } + let label = HubTimestampFormatters.relative.localizedString(for: date, relativeTo: Date()) + hubRelativeTimestampCache.setObject(label as NSString, forKey: cacheKey) + return label +} diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift new file mode 100644 index 000000000..28b985eaf --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -0,0 +1,958 @@ +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. + +// 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). +struct HubCreatedChat: Equatable { + let projectId: String + let projectRootPath: String? + let projectName: String + let laneName: String + let sessionId: String + let isCli: Bool +} + +/// Reports the destination control's global top edge so the picker popover can +/// size to the room above it. +private struct HubDestinationTopKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + 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)) + } +} + +/// 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 + + @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 + + let onCreated: (HubCreatedChat) -> Void + + // Composer selection (seeded from the app-wide "last used" record in init so + // the provider/model onChange handlers don't reset runtimeMode on first + // layout — same gotcha the in-project new-chat screen guards against). + @State private var provider: String = "claude" + @State private var modelId: String = "claude-sonnet-4-6" + @State private var runtimeMode: String = "default" + @State private var reasoningEffort: String = "" + @State private var codexFastMode: Bool = false + @State private var sessionMode: WorkNewSessionMode = .chat + /// The catalog option the picker handed us, kept so fast-tier support is read + /// from the live host-advertised model rather than re-derived from the + /// curated iOS catalog (which can miss a freshly advertised fast model). + @State private var selectedModelOption: WorkModelOption? + + // Destination (Project ▸ Lane). `selectedLaneId` is a real lane id or the + // auto-create sentinel; both are resolved against the TARGET project on send. + @State private var pickedProjectId: String = "" + @State private var selectedLaneId: String = "" + + // UI / flow. + @State private var draft: String = "" + @State private var busy: Bool = false + @State private var errorMessage: String? + @State private var modelPickerPresented = false + @State private var destinationPickerPresented = false + @State private var isDictating = false + @State private var controlsWidth: CGFloat = 0 + // Global top edge of the destination control, so the picker popover can size + // itself to the room above it (and never overflow the top of the screen). + @State private var destinationControlTopY: CGFloat = 0 + @FocusState private var composerFocused: Bool + @StateObject private var dictationCoordinator = DictationInsertionCoordinator() + + private let dictationTargetId = "hub-new-chat-drawer" + + init(onCreated: @escaping (HubCreatedChat) -> Void) { + self.onCreated = onCreated + if let saved = WorkComposerPreferences.load() { + _provider = State(initialValue: saved.provider) + _modelId = State(initialValue: saved.modelId) + _runtimeMode = State(initialValue: saved.runtimeMode) + _reasoningEffort = State(initialValue: saved.reasoningEffort) + _codexFastMode = State(initialValue: saved.codexFastMode) + } + if let dest = HubComposerDrawer.loadLastDestination() { + _pickedProjectId = State(initialValue: dest.projectId) + _selectedLaneId = State(initialValue: dest.laneId) + } + } + + // MARK: Derived state + + private var composerSelection: WorkComposerPreferences.Selection { + WorkComposerPreferences.Selection( + provider: provider, + modelId: modelId, + runtimeMode: runtimeMode, + reasoningEffort: reasoningEffort, + codexFastMode: codexFastMode + ) + } + + private var pickedProject: MobileProjectSummary? { + syncService.projects.first { $0.id == pickedProjectId } + } + + private var lanesForPickedProject: [RemoteRosterLane] { + lanes(forProjectId: pickedProjectId) + } + + private var isAutoCreateLane: Bool { + selectedLaneId == workAutoCreateLaneSentinelId + } + + private var selectedLaneName: String { + if isAutoCreateLane { return "Auto-create lane" } + if let lane = lanesForPickedProject.first(where: { $0.id == selectedLaneId }) { + return lane.name + } + return selectedLaneId.isEmpty ? "Select lane" : selectedLaneId + } + + private var selectedLaneTint: Color { + guard let lane = lanesForPickedProject.first(where: { $0.id == selectedLaneId }) else { + return ADEColor.textMuted + } + return LaneColorPalette.displayColor(forHex: lane.color) + } + + /// Fast mode only applies to in-app chat sessions on fast-tier models — the + /// CLI launcher has no fast-mode parameter — so the lightning toggle (and the + /// value we send) is gated on both. The live picker option can only *add* + /// support; the catalog/allow-list fallback still shows the toggle for a + /// known-fast model whose option ships empty `serviceTiers`. + private var fastModeSupported: Bool { + guard sessionMode == .chat else { return false } + if let option = selectedModelOption, + workModelIdsEquivalent(option.id, modelId), + option.supportsServiceTier("fast") { + return true + } + return workComposerSupportsFastMode(modelId: modelId, provider: provider) + } + + private var canStart: Bool { + !busy + && !pickedProjectId.isEmpty + && (isAutoCreateLane || !selectedLaneId.isEmpty) + && !modelId.isEmpty + } + + private var trimmedDraft: String { + draft.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var canSend: Bool { + canStart && !trimmedDraft.isEmpty + } + + private var isControlsCollapsed: Bool { + controlsWidth > 0 && controlsWidth <= workComposerControlsCollapseThreshold + } + + // 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 + } + .padding(.horizontal, 16) + .padding(.top, 6) + .padding(.bottom, 16) + } + .scrollBounceBehavior(.basedOnSize) + .scrollDismissesKeyboard(.interactively) + } + .background(ADEColor.pageBackground.ignoresSafeArea()) + .onAppear { onAppearSetup() } + .onChange(of: provider) { _, newProvider in + runtimeMode = workDefaultRuntimeMode(provider: newProvider) + if !hubChatModelBelongs(modelId, to: hubNormalizedChatProvider(newProvider)) { + modelId = hubDefaultChatModelId(provider: newProvider) + } + if !modelSupportsReasoning(modelId: modelId, provider: newProvider) { + reasoningEffort = "" + } + if !fastModeSupported { + codexFastMode = false + } + } + .onChange(of: sessionMode) { _, newMode in + normalizeSelection(for: newMode) + } + .onChange(of: modelId) { _, newModel in + if !modelSupportsReasoning(modelId: newModel, provider: provider) { + reasoningEffort = "" + } + if !fastModeSupported { + codexFastMode = false + } + } + .onChange(of: composerSelection) { _, newValue in + WorkComposerPreferences.save(newValue) + } + .sheet(isPresented: $modelPickerPresented) { + WorkModelPickerSheet( + currentModelId: modelId, + currentProvider: provider, + currentReasoningEffort: reasoningEffort, + cursorAvailabilityMode: sessionMode == .cli ? .cli : .chat, + isBusy: false, + onSelect: { option, pickedReasoning, runtimeProvider in + selectedModelOption = option + modelId = option.id + provider = sessionMode == .chat + ? hubNormalizedChatProvider(runtimeProvider) + : workResolveCliProvider(for: option.id, provider: runtimeProvider) + reasoningEffort = pickedReasoning ?? "" + runtimeMode = workDefaultRuntimeMode(provider: provider) + modelPickerPresented = false + } + ) + .environmentObject(syncService) + } + } + + // MARK: Combined destination control (Project ▸ Lane) + + private var destinationControl: some View { + Button { + destinationPickerPresented = true + } label: { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + + Text(pickedProject?.displayName ?? "Select project") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + + Image(systemName: "chevron.compact.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + + laneTag + + Spacer(minLength: 4) + + Image(systemName: "chevron.up.chevron.down") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(ADEColor.textMuted.opacity(0.7)) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(ADEColor.cardBackground.opacity(0.62), in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).stroke(ADEColor.border.opacity(0.8), lineWidth: 1)) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .background( + GeometryReader { proxy in + Color.clear.preference(key: HubDestinationTopKey.self, value: proxy.frame(in: .global).minY) + } + ) + .onPreferenceChange(HubDestinationTopKey.self) { destinationControlTopY = $0 } + .accessibilityLabel("Destination") + .accessibilityValue("\(pickedProject?.displayName ?? "No project"), \(selectedLaneName)") + .accessibilityHint("Choose the project and lane for this chat.") + .popover(isPresented: $destinationPickerPresented, attachmentAnchor: .rect(.bounds), arrowEdge: .bottom) { + destinationPicker + .presentationCompactAdaptation(.popover) + } + } + + @ViewBuilder + private var laneTag: some View { + if isAutoCreateLane { + HStack(spacing: 4) { + Image(systemName: "sparkles") + .font(.system(size: 10, weight: .bold)) + Text("Auto-create lane") + .font(.system(.caption, design: .rounded).weight(.medium)) + .lineLimit(1) + } + .foregroundStyle( + LinearGradient( + colors: [ADEColor.accent, ADEColor.purpleAccent], + startPoint: .leading, + endPoint: .trailing + ) + ) + } else { + HStack(spacing: 5) { + Circle().fill(selectedLaneTint).frame(width: 7, height: 7) + Text(selectedLaneName) + .font(.system(.caption, design: .rounded).weight(.medium)) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + } + } + } + + /// The picker fills the room between the destination control and the top of + /// the screen (minus a small margin) so it never overflows and clips a + /// project — the two sections then split that height evenly. Falls back to a + /// device fraction until the control's position has been measured. + private var destinationPickerHeight: CGFloat { + let deviceCap = min(UIScreen.main.bounds.height * 0.62, 560) + guard destinationControlTopY > 0 else { return min(deviceCap, 320) } + let available = destinationControlTopY - 64 + return max(240, min(deviceCap, available)) + } + + private var destinationPicker: some View { + VStack(spacing: 0) { + sectionLabel("PROJECT") + ScrollView { + LazyVStack(spacing: 2) { + if syncService.projects.isEmpty { + emptyPickerRow("No projects on this machine") + } else { + ForEach(syncService.projects) { project in + projectRow(project) + } + } + } + .padding(4) + } + .frame(maxHeight: .infinity) + + Divider().overlay(ADEColor.glassBorder) + + sectionLabel("LANE") + ScrollView { + LazyVStack(spacing: 2) { + autoCreateLaneRow + ForEach(lanesForPickedProject) { lane in + laneRow(lane) + } + } + .padding(4) + } + .frame(maxHeight: .infinity) + } + .frame(width: 320, height: destinationPickerHeight) + .background(ADEColor.cardBackground.opacity(0.98)) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(ADEColor.glassBorder, lineWidth: 0.8)) + } + + private func sectionLabel(_ text: String) -> some View { + HStack { + Text(text) + .font(.system(.caption2, design: .rounded).weight(.bold)) + .tracking(0.6) + .foregroundStyle(ADEColor.textMuted) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.top, 10) + .padding(.bottom, 4) + } + + private func emptyPickerRow(_ text: String) -> some View { + Text(text) + .font(.system(.caption, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } + + private func projectRow(_ project: MobileProjectSummary) -> some View { + let isSelected = project.id == pickedProjectId + return Button { + guard project.id != pickedProjectId else { return } + pickedProjectId = project.id + // Switching projects resets the lane to that project's default; the user + // can still tap a specific lane (or auto-create) below to confirm. + selectedLaneId = defaultLaneId(forProjectId: project.id) + } label: { + HStack(spacing: 9) { + HubProjectIcon(iconDataUrl: project.iconDataUrl, isActive: isSelected) + Text(project.displayName) + .font(.system(.footnote, design: .rounded).weight(isSelected ? .semibold : .regular)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var autoCreateLaneRow: some View { + Button { + selectedLaneId = workAutoCreateLaneSentinelId + destinationPickerPresented = false + } label: { + HStack(spacing: 8) { + Image(systemName: "sparkles") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + Text("Auto-create lane") + .font(.system(.footnote, design: .rounded).weight(.medium)) + .foregroundStyle( + LinearGradient( + colors: [ADEColor.accent, ADEColor.purpleAccent], + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(maxWidth: .infinity, alignment: .leading) + if isAutoCreateLane { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 7) + .background( + isAutoCreateLane ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private func laneRow(_ lane: RemoteRosterLane) -> some View { + let isSelected = lane.id == selectedLaneId + let tint = LaneColorPalette.displayColor(forHex: lane.color) + let branch = normalizedPrBranchName(lane.branchRef) + return Button { + selectedLaneId = lane.id + destinationPickerPresented = false + } label: { + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 8) { + Circle().fill(tint).frame(width: 8, height: 8) + Text(lane.name) + .font(.system(.footnote, design: .rounded).weight(isSelected ? .semibold : .regular)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.accent) + } + } + if !branch.isEmpty { + HStack(spacing: 4) { + Image(systemName: "arrow.branch") + .font(.system(size: 9, weight: .regular)) + .foregroundStyle(ADEColor.textMuted.opacity(0.6)) + Text(branch) + .font(.system(size: 10, design: .rounded)) + .foregroundStyle(ADEColor.textMuted.opacity(0.9)) + .lineLimit(1) + } + .padding(.leading, 16) + } + } + .padding(.horizontal, 8) + .padding(.vertical, branch.isEmpty ? 6 : 5) + .background( + isSelected ? ADEColor.accent.opacity(0.12) : Color.clear, + in: RoundedRectangle(cornerRadius: 8, style: .continuous) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // MARK: Composer card + + 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) + } + + 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() + } + } + } + } + .padding(.horizontal, 14) + .padding(.vertical, 14) + .background( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .fill(ADEColor.composerBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: 22, style: .continuous) + .stroke(ADEColor.glassBorder, lineWidth: 1) + ) + .shadow(color: Color.black.opacity(0.16), radius: 8, y: 3) + } + + // MARK: Actions + + @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 + // .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 + if !workModelAllowedForAvailabilityMode(modelId: modelId, provider: provider, mode: availabilityMode) { + normalizeSelection(for: sessionMode) + } + if runtimeMode.isEmpty { + 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 + private func dispatch() { + let text = trimmedDraft + guard !text.isEmpty else { return } + draft = "" + Task { + let started = await submit(opener: text) + if !started { + draft = text + } + } + } + + @MainActor + private func submit(opener rawOpener: String) async -> Bool { + let opener = rawOpener.trimmingCharacters(in: .whitespacesAndNewlines) + guard canStart, !opener.isEmpty, !modelId.isEmpty else { return false } + guard let project = pickedProject else { + errorMessage = "Pick a project first." + return false + } + + // Anchor the "last time you sent a message" composer choice. + WorkComposerPreferences.save(composerSelection) + busy = true + errorMessage = nil + + let targetProjectId = project.id + let targetProjectRootPath = project.rootPath + let wire = workRuntimeWireFields(provider: provider, mode: runtimeMode) + let normalizedReasoning = reasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines) + + // Resolve the target lane. Auto-create mints a fresh lane in the TARGET + // project first; on failure we surface the error and never create the chat. + // Track the minted lane so we can tear it back down if the chat launch + // fails immediately afterwards (desktop parity — no orphaned empty lane). + let targetLaneId: String + let targetLaneName: String + var createdLaneId: String? + if isAutoCreateLane { + let name = workDeterministicAutoLaneName(from: opener, genericSuffix: workAutoLaneGenericSuffix()) + do { + let lane = try await syncService.createLane( + name: name, + description: opener.isEmpty ? "" : String(opener.prefix(280)), + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + targetLaneId = lane.id + targetLaneName = lane.name + createdLaneId = lane.id + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + busy = false + return false + } + } else { + targetLaneId = selectedLaneId + targetLaneName = lanesForPickedProject.first(where: { $0.id == selectedLaneId })?.name ?? selectedLaneId + } + + do { + let isCli = sessionMode == .cli + let sessionId: String + if isCli { + let cliProvider = workResolveCliProvider(for: modelId, provider: provider) + let cliReasoning = hubCliSupportsReasoning(provider: cliProvider) && !normalizedReasoning.isEmpty + ? normalizedReasoning + : nil + let result = try await syncService.startCliSession( + laneId: targetLaneId, + provider: cliProvider, + permissionMode: workCliPermissionMode(provider: cliProvider, runtimeMode: runtimeMode), + title: hubCliInitialTitle(opener: opener, provider: cliProvider), + initialInput: opener, + modelId: modelId, + reasoningEffort: cliReasoning, + cols: 48, + rows: 24, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + sessionId = result.sessionId + } else { + let summary = try await syncService.createChatSession( + laneId: targetLaneId, + provider: provider, + model: modelId, + reasoningEffort: normalizedReasoning.isEmpty ? nil : normalizedReasoning, + // Send an explicit true/false when fast mode applies so the user's + // choice (including an explicit OFF) is honored; nil only when N/A. + codexFastMode: fastModeSupported ? codexFastMode : nil, + permissionMode: wire.permissionMode, + interactionMode: wire.interactionMode, + claudePermissionMode: wire.claudePermissionMode, + codexApprovalPolicy: wire.codexApprovalPolicy, + codexSandbox: wire.codexSandbox, + codexConfigSource: wire.codexConfigSource, + opencodePermissionMode: wire.opencodePermissionMode, + droidPermissionMode: wire.droidPermissionMode, + cursorModeId: wire.cursorModeId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + sessionId = summary.sessionId + } + + // Persist the composer + destination so the next New Chat restores both. + WorkComposerPreferences.save(composerSelection) + saveLastDestination( + projectId: targetProjectId, + laneId: isAutoCreateLane ? workAutoCreateLaneSentinelId : selectedLaneId + ) + ADEHaptics.success() + busy = false + + let created = HubCreatedChat( + projectId: targetProjectId, + projectRootPath: targetProjectRootPath, + projectName: project.displayName, + laneName: targetLaneName, + sessionId: sessionId, + isCli: isCli + ) + dismiss() + onCreated(created) + return true + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + // The chat never launched into the lane we just minted — clean it up so + // an auto-create failure doesn't leave an orphaned empty lane behind. + if let createdLaneId { + try? await syncService.deleteLane(createdLaneId) + } + busy = false + return false + } + } + + // MARK: Destination resolution + + private func lanes(forProjectId id: String) -> [RemoteRosterLane] { + guard !id.isEmpty, let project = syncService.projects.first(where: { $0.id == id }) else { return [] } + return syncService.rosterProject(for: project)?.lanes ?? [] + } + + /// Primary lane for a project (or its first lane); falls back to the + /// auto-create sentinel when the project has no synced lanes yet. + private func defaultLaneId(forProjectId id: String) -> String { + let lanes = lanes(forProjectId: id) + if let primary = lanes.first(where: { ($0.laneType ?? "") == "primary" }) { + return primary.id + } + if let first = lanes.first { + return first.id + } + return workAutoCreateLaneSentinelId + } + + /// Reconciles the (possibly persisted) destination against the live project + + /// lane lists: keep a still-valid choice, else fall back to the active project + /// (then first) and that project's primary/first lane. + private func reconcileDestination() { + let projects = syncService.projects + guard !projects.isEmpty else { + pickedProjectId = "" + selectedLaneId = "" + return + } + if pickedProjectId.isEmpty || !projects.contains(where: { $0.id == pickedProjectId }) { + pickedProjectId = syncService.activeProject?.id ?? projects[0].id + selectedLaneId = defaultLaneId(forProjectId: pickedProjectId) + return + } + if !isAutoCreateLane { + let lanes = lanesForPickedProject + if selectedLaneId.isEmpty || !lanes.contains(where: { $0.id == selectedLaneId }) { + selectedLaneId = defaultLaneId(forProjectId: pickedProjectId) + } + } + } + + // MARK: Last-destination persistence (App Group) + + private struct HubLastDestination: Codable, Equatable { + var projectId: String + var laneId: String + } + + private static let lastDestinationKey = "ade.hub.lastDestination.v1" + + private static func loadLastDestination() -> HubLastDestination? { + guard let data = ADESharedContainer.defaults.data(forKey: lastDestinationKey) else { return nil } + return try? JSONDecoder().decode(HubLastDestination.self, from: data) + } + + private func saveLastDestination(projectId: String, laneId: String) { + let trimmed = projectId.trimmingCharacters(in: .whitespacesAndNewlines) + 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) + } + + // MARK: Composer normalization (self-contained mirrors of the new-chat screen) + + private func normalizeSelection(for mode: WorkNewSessionMode) { + let availabilityMode: WorkCursorAvailabilityMode = mode == .cli ? .cli : .chat + if !workModelAllowedForAvailabilityMode(modelId: modelId, provider: provider, mode: availabilityMode), + let replacement = workDefaultModelIdForAvailabilityMode(preferredProvider: provider, mode: availabilityMode) { + modelId = replacement.modelId + provider = mode == .chat + ? hubNormalizedChatProvider(replacement.provider) + : workResolveCliProvider(for: replacement.modelId, provider: replacement.provider) + } else if mode == .chat { + provider = hubNormalizedChatProvider(provider) + if !hubChatModelBelongs(modelId, to: provider) { + modelId = hubDefaultChatModelId(provider: provider) + } + } else { + provider = workResolveCliProvider(for: modelId, provider: provider) + } + runtimeMode = workDefaultRuntimeMode(provider: provider) + if !modelSupportsReasoning(modelId: modelId, provider: provider) { + reasoningEffort = "" + } + if !fastModeSupported { + codexFastMode = false + } + } +} + +// MARK: - File-private helpers (mirror the private new-chat-screen helpers) + +/// Collapse a free-form provider key to a chat-capable runtime family, matching +/// the new-chat screen so a picked Droid Core model stays on the droid runtime +/// instead of silently routing to Claude. +private func hubNormalizedChatProvider(_ provider: String) -> String { + let family = providerFamilyKey(provider) + return ["claude", "codex", "cursor", "opencode", "droid"].contains(family) ? family : "claude" +} + +private func hubChatModelBelongs(_ modelId: String, to provider: String) -> Bool { + let trimmed = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return workModelCatalogGroupKey(for: trimmed, currentProvider: provider) == provider +} + +private func hubDefaultChatModelId(provider: String) -> String { + let family = providerFamilyKey(provider) + if let defaultModel = workDefaultCatalogModelId(provider: family) { + return defaultModel + } + switch hubNormalizedChatProvider(provider) { + case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.5" + case "cursor": return "auto" + case "opencode": return "opencode/anthropic/claude-sonnet-4-6" + default: return "claude-sonnet-4-6" + } +} + +/// CLI runtimes that accept a reasoning-effort selection (mirrors the new-chat +/// screen's `workCliSupportsReasoningSelection`). +private func hubCliSupportsReasoning(provider: String) -> Bool { + let family = providerFamilyKey(provider) + return family == "claude" || family == "codex" || family == "droid" +} + +/// Derive a short CLI session title from the opener (mirrors the new-chat +/// screen's `workCliInitialSessionTitle`). +private func hubCliInitialTitle(opener: String, provider: String) -> String { + let fallback = providerLabel(provider) + let seed = opener + .replacingOccurrences(of: "\n", with: " ") + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !seed.isEmpty else { return fallback } + let clipped: String + if seed.count > 72 { + let prefix = String(seed.prefix(72)) + clipped = prefix.replacingOccurrences(of: #"\s+\S*$"#, with: "", options: .regularExpression) + } else { + clipped = seed + } + return clipped.trimmingCharacters(in: CharacterSet(charactersIn: ".?!,:; ").union(.whitespacesAndNewlines)) +} + +/// Beautify a raw model id for the composer pill (mirrors the new-chat screen's +/// `prettyNewChatModelName`), preferring the host-known display name. +private func hubPrettyModelName(_ model: String) -> String { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return "Model" } + if let known = workKnownModelDisplayName(trimmed) { + return known + } + let lower = trimmed.lowercased() + switch lower { + case "opus": return "Claude Opus 4.7" + case "opus[1m]", "opus-1m": return "Claude Opus 4.7 1M" + case "sonnet": return "Claude Sonnet 4.6" + case "haiku": return "Claude Haiku 4.5" + default: break + } + if lower.hasPrefix("claude-") { + let tail = trimmed.dropFirst("claude-".count) + let joined = tail.split(separator: "-").map { part -> String in + let s = String(part) + if s.range(of: #"^\d+$"#, options: .regularExpression) != nil { return s } + return s.prefix(1).uppercased() + s.dropFirst() + }.joined(separator: " ") + return "Claude " + joined.replacingOccurrences(of: #"(\d+) (\d+)"#, with: "$1.$2", options: .regularExpression) + } + return trimmed +} diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift new file mode 100644 index 000000000..9856e435f --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -0,0 +1,99 @@ +import SwiftUI + +// Opening a chat FROM THE HUB. The chat is presented as a full-screen cover over +// the hub so Back returns to the all-projects list (the second entry point — +// inside a project — pushes the same `WorkSessionDestinationView` onto the Work +// tab's stack instead, where Back returns to Work). Before the chat renders we +// activate its project (without leaving the hub) so the transcript can stream. + +extension View { + func hubChatCover(target: Binding) -> some View { + modifier(HubChatCoverModifier(target: target)) + } +} + +private struct HubChatCoverModifier: ViewModifier { + @Binding var target: HubChatTarget? + @EnvironmentObject private var syncService: SyncService + @EnvironmentObject private var dictationController: DictationController + + func body(content: Content) -> some View { + content.fullScreenCover(item: $target) { target in + HubChatCover(target: target, syncService: syncService) { self.target = nil } + .environmentObject(syncService) + .environmentObject(dictationController) + } + } +} + +private struct HubChatCover: View { + let target: HubChatTarget + let syncService: SyncService + let onClose: () -> Void + @State private var ready = false + + var body: some View { + NavigationStack { + Group { + if ready { + WorkSessionDestinationView( + sessionId: target.chat.id, + initialOpeningPrompt: nil, + initialSession: nil, + initialChatSummary: nil, + initialTranscript: nil, + transitionNamespace: nil, + isLive: true, + navigationChrome: .pushedDetail, + lanes: target.lane.map { [$0.asLaneSummary()] } ?? [] + ) + .equatable() + } 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) + ready = true + } + } +} + +private struct HubChatActivatingView: View { + let projectName: String + let onClose: () -> Void + + var body: some View { + ZStack { + ADEColor.pageBackground.ignoresSafeArea() + VStack(spacing: 14) { + ProgressView().controlSize(.large) + Text("Opening \(projectName)…") + .font(.system(.subheadline, design: .rounded).weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + } + .safeAreaInset(edge: .top, spacing: 0) { + HStack { + Button(action: onClose) { + HStack(spacing: 4) { + Image(systemName: "chevron.left").font(.system(size: 15, weight: .semibold)) + Text("Hub") + } + .foregroundStyle(ADEColor.accent) + } + .buttonStyle(.plain) + Spacer() + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + } + } +} diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift new file mode 100644 index 000000000..f21dd8aa3 --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -0,0 +1,522 @@ +import SwiftUI + +// The all-projects hub — the mobile app's main surface once a machine is +// 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. +// +// Replaces the connected-state layout of the old `ProjectHomeView`; the +// no-machine / connecting states are preserved here. +struct HubScreen: View { + @EnvironmentObject private var syncService: SyncService + @State private var addProjectSheetPresented = false + @State private var collapsedProjectIds: Set = [] + @State private var collapsedLaneKeys: Set = [] + @State private var collapsedDefaultsConnectionKey: String? + @State private var collapsedDefaultsSeededProjectIds: Set = [] + @State private var collapsedDefaultsSeededLaneKeys: Set = [] + // User's manual project order (drag-to-reorder). Persisted per machine so the + // 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 + // Set when a hub chat row is tapped — drives the chat cover (wired in + // HubScreen+ChatNavigation). + @State var openChatTarget: HubChatTarget? + // "Created in · " toast shown after a drawer send (the chat is + // created in place and does NOT auto-open; the toast offers an Open shortcut). + @State private var createdToast: HubCreatedChat? + @State private var toastDismissTask: Task? + // The active project's chats come straight from the phone's already-synced + // local DB (authoritative + instant), independent of the cross-project roster + // feed — so the active card is never stuck on "Loading chats…". + @State private var activeRoster: RemoteRosterProject? + @State private var hubProjectPresentations: [HubProjectPresentation] = [] + + private var isNoMachineBlankState: Bool { + syncService.connectionState == .disconnected || syncService.connectionState == .error + } + + private var canShowProjects: Bool { + syncService.connectionState == .connected || syncService.connectionState == .syncing + } + + private var hubIsActive: Bool { + syncService.shouldShowProjectHome && openChatTarget == nil && !composerPresented + } + + var body: some View { + ZStack(alignment: .top) { + HubBackground() + if openChatTarget != nil { + HubCoverParkingSurface() + } else if isNoMachineBlankState { + HubNoMachineState() + } else { + connectedHub + } + } + .sheet(isPresented: $addProjectSheetPresented) { + 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() }) + .padding(.horizontal, 16) + .padding(.bottom, 78) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.spring(response: 0.35, dampingFraction: 0.85), value: createdToast) + .task(id: hubCollapseDefaultsConnectionKey) { + loadHubLayoutForConnection(hubCollapseDefaultsConnectionKey) + } + } + + private func handleCreated(_ created: HubCreatedChat) { + createdToast = created + // Nudge a fresh roster so the new chat surfaces under its project promptly, + // even if the create routed into a project that wasn't the active one. + syncService.requestRosterSnapshot() + toastDismissTask?.cancel() + toastDismissTask = Task { @MainActor in + try? await Task.sleep(nanoseconds: 5_000_000_000) + if !Task.isCancelled { dismissToast() } + } + } + + private func dismissToast() { + toastDismissTask?.cancel() + createdToast = nil + } + + private func openCreated(_ created: HubCreatedChat) { + dismissToast() + guard let project = syncService.projects.first(where: { $0.id == created.projectId }) else { return } + let chat = RemoteRosterChat( + id: created.sessionId, laneId: "", chatSessionId: nil, title: nil, provider: nil, model: nil, toolType: nil, + status: .running, awaitingInput: nil, pinned: nil, archived: nil, lastActivityAt: nil, preview: nil + ) + openChatTarget = HubChatTarget(project: project, lane: nil, chat: chat) + } + + private var connectedHub: some View { + VStack(spacing: 0) { + HubTopBar(onAdd: { addProjectSheetPresented = true }) + ScrollView { + LazyVStack(spacing: 12) { + if !canShowProjects { + HubConnectingCard() + } else if syncService.projects.isEmpty { + HubEmptyProjectsCard() + } else { + ForEach(hubProjectPresentations) { presentation in + let project = presentation.project + HubProjectCard( + presentation: presentation, + isCollapsed: collapsedProjectIds.contains(project.id), + collapsedLaneKeysSnapshot: collapsedLaneKeys, + collapsedLaneKeys: $collapsedLaneKeys, + onToggleCollapse: { withAnimation(.easeOut(duration: 0.16)) { toggle(&collapsedProjectIds, project.id) } }, + onOpenProject: { syncService.selectProject(project) }, + onOpenChat: { chat, lane in + openChatTarget = HubChatTarget(project: project, lane: lane, chat: chat) + }, + onArchiveChat: { chat in runRosterChatAction("chat.archive", chat: chat, project: project) }, + onDeleteChat: { chat in runRosterChatAction("chat.delete", chat: chat, project: project) }, + onForget: { syncService.forgetProject(project) } + ) + .equatable() + .draggable(project.id) + .dropDestination(for: String.self) { items, _ in + guard let draggedId = items.first else { return false } + moveProject(draggedId, onto: project.id) + return true + } + } + } + } + .frame(maxWidth: 640) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 16) + } + .scrollIndicators(.hidden) + .safeAreaInset(edge: .bottom, spacing: 0) { + VStack(spacing: 0) { + LinearGradient( + colors: [ + ADEColor.pageBackground.opacity(0), + ADEColor.pageBackground.opacity(0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 14) + .allowsHitTesting(false) + + HubComposerBar { composerPresented = true } + } + .background( + ADEColor.pageBackground + .opacity(0.96) + .ignoresSafeArea(edges: .bottom) + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + // Rebuild the active project's local roster whenever its sessions/lanes + // change or the active project switches. + .task(id: rebuildKey) { + guard rebuildKey != nil else { return } + activeRoster = syncService.buildActiveProjectLocalRoster() + rebuildHubProjectPresentations() + } + .task(id: hubPresentationKey) { + guard hubPresentationKey != nil else { return } + rebuildHubProjectPresentations() + } + .task(id: rosterRequestKey) { + guard rosterRequestKey != nil, canShowProjects else { return } + syncService.requestRosterSnapshot() + } + // Persist the user's expand/collapse choices as they change so the hub + // restores identically after opening a project and returning. + .onChange(of: collapsedProjectIds) { _, _ in persistHubLayout() } + .onChange(of: collapsedLaneKeys) { _, _ in persistHubLayout() } + } + + /// Composite key that changes whenever the active project's local chat/lane + /// data does, driving an `activeRoster` rebuild. + private var rebuildKey: String? { + guard hubIsActive else { return nil } + return "\(syncService.activeProjectId ?? "-")|\(syncService.workProjectionRevision)|\(syncService.lanesProjectionRevision)" + } + + /// Display order: the user's persisted manual order first, with any project + /// not yet placed falling back to alphabetical. Stable so viewing a project + /// never reorders the hub. + private var hubProjects: [MobileProjectSummary] { + let projects = syncService.projects + guard !projectOrder.isEmpty else { + return projects.sorted { + $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending + } + } + let rankById = Dictionary(uniqueKeysWithValues: projectOrder.enumerated().map { ($1, $0) }) + return projects.sorted { lhs, rhs in + switch (rankById[lhs.id], rankById[rhs.id]) { + case let (l?, r?): return l < r + case (.some, .none): return true + case (.none, .some): return false + case (.none, .none): + return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending + } + } + } + + private var hubPresentationKey: String? { + guard hubIsActive else { return nil } + let projectKey = syncService.projects.map { project in + [ + project.id, + project.displayName, + project.rootPath ?? "", + project.lastOpenedAt ?? "", + String(project.laneCount), + String(project.isOpen ?? false), + ].joined(separator: ":") + }.joined(separator: "|") + return [ + projectKey, + String(syncService.rosterRevision), + syncService.activeProjectId ?? "", + String(syncService.isProjectSwitching), + ].joined(separator: "#") + } + + private var rosterRequestKey: String? { + guard hubIsActive else { return nil } + return [ + String(describing: syncService.connectionState), + String(syncService.projects.count), + syncService.projects.map(\.id).joined(separator: ",") + ].joined(separator: "|") + } + + /// Machine identity used to scope the persisted hub layout. Keyed on the host + /// name (not the address) so a reconnect that drifts the port doesn't drop the + /// user's saved order and expand/collapse state. + private var hubCollapseDefaultsConnectionKey: String? { + guard canShowProjects else { return nil } + let host = (syncService.hostName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let key = host.isEmpty ? (syncService.currentAddress ?? "") : host + return key.isEmpty ? nil : key + } + + /// Prefer the machine-wide roster for shape (all lanes/chats). For the active + /// project, merge in the phone's local cache as a live overlay instead of + /// replacing the roster; otherwise opening Versic collapses it to whichever + /// small subset has hydrated locally and makes ADE's rows appear to vanish. + private func rosterEntry(for project: MobileProjectSummary) -> RemoteRosterProject? { + let remoteRoster = syncService.rosterProject(for: project) + guard syncService.isActiveProject(project), let activeRoster else { + return remoteRoster + } + guard let remoteRoster else { + return activeRoster + } + return mergedHubRoster(remote: remoteRoster, local: activeRoster) + } + + private func rebuildHubProjectPresentations() { + let nextPresentations = hubProjects.map { project in + buildHubProjectPresentation( + project: project, + roster: rosterEntry(for: project), + isActive: syncService.isActiveProject(project), + isSwitching: syncService.isSwitchingProject(project) + ) + } + if nextPresentations != hubProjectPresentations { + hubProjectPresentations = nextPresentations + } + seedCollapsedHubDefaults(for: nextPresentations) + } + + private func loadHubLayoutForConnection(_ connectionKey: String?) { + guard let connectionKey else { + collapsedDefaultsConnectionKey = nil + return + } + guard collapsedDefaultsConnectionKey != connectionKey else { return } + collapsedDefaultsConnectionKey = connectionKey + + // Restore the last-known layout for this machine, then seed defaults for any + // project/lane we've never placed before. + let saved = HubLayoutStore.load(connectionKey) + projectOrder = saved.order + collapsedProjectIds = Set(saved.collapsedProjects) + collapsedLaneKeys = Set(saved.collapsedLanes) + collapsedDefaultsSeededProjectIds = Set(saved.seededProjects) + collapsedDefaultsSeededLaneKeys = Set(saved.seededLanes) + seedCollapsedHubDefaults(for: hubProjectPresentations) + } + + private func seedCollapsedHubDefaults(for presentations: [HubProjectPresentation]) { + guard collapsedDefaultsConnectionKey != nil else { return } + var changed = false + + // Append any newly-seen project to the manual order (alphabetically among the + // newcomers). Never drop ids: a transient disconnect can briefly empty the + // roster, and we must not lose the user's saved order over a blip. + let orderedIds = Set(projectOrder) + let newProjectIds = presentations + .map { $0.project } + .filter { !orderedIds.contains($0.id) } + .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending } + .map(\.id) + if !newProjectIds.isEmpty { + projectOrder.append(contentsOf: newProjectIds) + changed = true + } + + let projectIds = Set(presentations.map { $0.project.id }) + let unseededProjectIds = projectIds.subtracting(collapsedDefaultsSeededProjectIds) + if !unseededProjectIds.isEmpty { + collapsedProjectIds.formUnion(unseededProjectIds) + collapsedDefaultsSeededProjectIds.formUnion(unseededProjectIds) + changed = true + } + + let laneKeys = Set(presentations.flatMap { presentation in + presentation.lanes.map { hubLaneKey(project: presentation.project, lane: $0.lane) } + }) + let unseededLaneKeys = laneKeys.subtracting(collapsedDefaultsSeededLaneKeys) + if !unseededLaneKeys.isEmpty { + collapsedLaneKeys.formUnion(unseededLaneKeys) + collapsedDefaultsSeededLaneKeys.formUnion(unseededLaneKeys) + changed = true + } + + if changed { persistHubLayout() } + } + + private func persistHubLayout() { + guard let connectionKey = collapsedDefaultsConnectionKey else { return } + let state = HubLayoutState( + order: projectOrder, + collapsedProjects: Array(collapsedProjectIds), + collapsedLanes: Array(collapsedLaneKeys), + seededProjects: Array(collapsedDefaultsSeededProjectIds), + seededLanes: Array(collapsedDefaultsSeededLaneKeys) + ) + HubLayoutStore.save(state, for: connectionKey) + } + + /// Drag-reorder: drop `draggedId` onto `targetId`, moving it into that slot. + /// Purely local — desktop ordering is untouched. + private func moveProject(_ draggedId: String, onto targetId: String) { + guard draggedId != targetId else { return } + var order = projectOrder.isEmpty ? hubProjects.map(\.id) : projectOrder + guard let from = order.firstIndex(of: draggedId) else { return } + order.remove(at: from) + let insertAt = order.firstIndex(of: targetId) ?? order.count + order.insert(draggedId, at: insertAt) + ADEHaptics.light() + withAnimation(.easeInOut(duration: 0.22)) { + projectOrder = order + rebuildHubProjectPresentations() + } + persistHubLayout() + } + + private func hubLaneKey(project: MobileProjectSummary, lane: RemoteRosterLane) -> String { + "\(project.id)/\(lane.id)" + } + + private func mergedHubRoster(remote: RemoteRosterProject, local: RemoteRosterProject) -> RemoteRosterProject { + var merged = remote + + var laneIds = Set(merged.lanes.map(\.id)) + for lane in local.lanes where laneIds.insert(lane.id).inserted { + merged.lanes.append(lane) + } + + var chatIndexById = Dictionary(uniqueKeysWithValues: merged.chats.enumerated().map { ($0.element.id, $0.offset) }) + for localChat in local.chats { + if let index = chatIndexById[localChat.id] { + merged.chats[index] = mergedHubChat(remote: merged.chats[index], local: localChat) + } else { + chatIndexById[localChat.id] = merged.chats.count + merged.chats.append(localChat) + } + } + + merged.booted = remote.booted || local.booted + merged.runningCount = merged.chats.filter(\.isRunning).count + merged.attentionCount = merged.chats.filter(\.needsAttention).count + merged.chats.sort { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } + return merged + } + + private func mergedHubChat(remote: RemoteRosterChat, local: RemoteRosterChat) -> RemoteRosterChat { + var merged = remote + let localIsAtLeastAsFresh = (local.lastActivityAt ?? "") >= (remote.lastActivityAt ?? "") + + if localIsAtLeastAsFresh { + merged.status = local.status + merged.awaitingInput = local.awaitingInput ?? remote.awaitingInput + merged.pinned = local.pinned ?? remote.pinned + merged.archived = local.archived ?? remote.archived + merged.lastActivityAt = nonEmpty(local.lastActivityAt) ?? remote.lastActivityAt + merged.title = nonEmpty(local.title) ?? remote.title + merged.preview = nonEmpty(local.preview) ?? remote.preview + } + + merged.provider = nonEmpty(remote.provider) ?? local.provider + merged.model = nonEmpty(remote.model) ?? local.model + merged.toolType = nonEmpty(remote.toolType) ?? local.toolType + merged.chatSessionId = nonEmpty(remote.chatSessionId) ?? local.chatSessionId + return merged + } + + private func nonEmpty(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil } + return value + } + + private func toggle(_ set: inout Set, _ id: String) { + if set.contains(id) { set.remove(id) } else { set.insert(id) } + } + + private func runRosterChatAction(_ action: String, chat: RemoteRosterChat, project: MobileProjectSummary) { + Task { @MainActor in + try? await syncService.performRosterChatAction(action, sessionId: chat.id, project: project) + } + } +} + +/// Identifies a chat opened from the hub, carrying enough context to activate +/// the right project and render the chat over the hub. +struct HubChatTarget: Identifiable, Equatable { + let project: MobileProjectSummary + let lane: RemoteRosterLane? + let chat: RemoteRosterChat + var id: String { chat.id } +} + +// MARK: - Persisted layout + +/// The hub's per-machine layout: the user's manual project order plus which +/// projects/lanes are collapsed. Persisted to the App Group so opening a project +/// and returning restores the hub exactly. `seeded*` records which ids have +/// already received a default so newly-appearing ones start collapsed while +/// previously-expanded ones stay expanded. +struct HubLayoutState: Codable, Equatable { + var order: [String] = [] + var collapsedProjects: [String] = [] + var collapsedLanes: [String] = [] + var seededProjects: [String] = [] + var seededLanes: [String] = [] +} + +enum HubLayoutStore { + private static func defaultsKey(_ connectionKey: String) -> String { + "ade.hub.layout.v1|\(connectionKey)" + } + + static func load(_ connectionKey: String) -> HubLayoutState { + guard let data = ADESharedContainer.defaults.data(forKey: defaultsKey(connectionKey)), + let state = try? JSONDecoder().decode(HubLayoutState.self, from: data) + else { return HubLayoutState() } + return state + } + + static func save(_ state: HubLayoutState, for connectionKey: String) { + guard let data = try? JSONEncoder().encode(state) else { return } + ADESharedContainer.defaults.set(data, forKey: defaultsKey(connectionKey)) + } +} + +// MARK: - Background + +private struct HubBackground: View { + var body: some View { + ZStack { + ADEColor.pageBackground + RadialGradient( + colors: [ + ADEColor.purpleAccent.opacity(0.20), + ADEColor.purpleAccent.opacity(0.06), + Color.clear, + ], + center: .top, + startRadius: 10, + endRadius: 360 + ) + .frame(height: 420) + .frame(maxHeight: .infinity, alignment: .top) + .blur(radius: 8) + .allowsHitTesting(false) + } + .ignoresSafeArea() + } +} + +/// While a chat opened from the hub is presented, keep the presenter mounted +/// but collapse its expensive roster/list tree. `fullScreenCover` keeps the +/// presenting hierarchy alive; leaving the whole hub underneath chat detail +/// makes roster updates diff both screens during streaming and scrolling. +private struct HubCoverParkingSurface: View { + var body: some View { + ADEColor.pageBackground + .ignoresSafeArea() + .accessibilityHidden(true) + } +} diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index d6a350753..ff0d12e75 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -569,6 +569,7 @@ struct PRsTabView: View { @ViewBuilder private var prsInlineTopBar: some View { HStack(alignment: .center, spacing: 12) { + ADEHubBackButton() HStack(alignment: .firstTextBaseline, spacing: 8) { Text("PRs") .font(.system(size: 28, weight: .bold, design: .rounded)) @@ -659,9 +660,8 @@ struct PRsTabView: View { .disabled(!canCreatePr) .opacity(canCreatePr ? 1 : 0.4) - // Global triad (laptop/grid/bell) — kept in PRs tab for parity with - // every other tab. The user doesn't have to context-switch tabs to - // reach connection status, project home, or attention. + // Keep the shared attention control in the PRs tab so alerts remain + // reachable without switching tabs. ADERootToolbarControls(scopeKey: "PRs") } } diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 6bc3698e9..f5ef44c3e 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -20,28 +20,36 @@ struct ConnectionSettingsView: View { NavigationStack { ScrollView { LazyVStack(spacing: 18) { - SettingsConnectionHeader( - snapshot: presentationModel.connectionSnapshot, - onDisconnect: { - syncService.disconnect() - }, - onReconnect: { preferTailnet in - Task { - await syncService.reconnectIfPossible( - userInitiated: true, - preferTailnet: preferTailnet - ) + // One "MACHINE" subsection: header → connection status card → pair + // actions, so the whole machine area reads as a single group. + VStack(alignment: .leading, spacing: 12) { + SettingsSectionHeader( + label: "MACHINE", + hint: "Your machine connection" + ) + + SettingsConnectionHeader( + snapshot: presentationModel.connectionSnapshot, + onDisconnect: { + syncService.disconnect() + }, + onReconnect: { preferTailnet in + Task { + await syncService.reconnectIfPossible( + userInitiated: true, + preferTailnet: preferTailnet + ) + } } - } - ) - .padding(.horizontal, 16) - .padding(.top, 4) + ) - SettingsPairingSection( - snapshot: presentationModel.pairingSnapshot, - presentedSheet: $presentedSheet - ) + SettingsPairingSection( + snapshot: presentationModel.pairingSnapshot, + presentedSheet: $presentedSheet + ) + } .padding(.horizontal, 16) + .padding(.top, 4) SettingsAppearanceSection() .padding(.horizontal, 16) diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 23fb417fa..e92afe130 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -29,6 +29,8 @@ struct SettingsConnectionHeader: View { Text(detail) .font(.caption) .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) } } Spacer(minLength: 0) @@ -43,16 +45,16 @@ struct SettingsConnectionHeader: View { } if health.transport.isConnected { - SettingsConnectedHostDetails( - hostDisplayName: snapshot.hostDisplayName, - routeLine: snapshot.routeLine - ) + SettingsConnectedHostDetails(routeLine: snapshot.routeLine) } else if let hostName = pendingHostName { Text(pendingDescription(hostName: hostName)) .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) - } else { + } else if !snapshot.canReconnectToSavedHost { + // Onboarding copy for users who have never paired a machine. Once a + // machine is saved we never show this again — the status caption above + // ("Last connected to: …") carries the returning-user message instead. Text("Pair once on Wi‑Fi to remotely connect later.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) @@ -134,25 +136,20 @@ struct SettingsConnectionHeader: View { private var stateDetailLine: String? { switch health.transport { case .connected: - if health.load == .strained { - return "Live · machine responding slowly" - } - if snapshot.connectionState == .syncing { - return "Live · syncing changes" - } - return "Live · ready to sync" + // Name the machine you're attached to, right under the status word. + return snapshot.hostDisplayName case .connecting: return "Connecting to saved machine" case .unreachable: return "Unable to reach your machine" case .disconnected: - if snapshot.savedReconnectPrefersTailnet { - return "Saved machine · Tailscale route ready" - } - if snapshot.canReconnectToSavedHost { - return "Saved machine · not connected" + // Returning users see where they left off. Brand-new users (no saved + // machine) get no caption here at all — the pairing onboarding copy + // below carries the message instead. + if snapshot.canReconnectToSavedHost, let host = snapshot.hostDisplayName { + return "Last connected to: \(host)" } - return "No paired machine" + return nil } } @@ -169,24 +166,17 @@ struct SettingsConnectionHeader: View { } private struct SettingsConnectedHostDetails: View { - let hostDisplayName: String? let routeLine: String? var body: some View { - VStack(alignment: .leading, spacing: 6) { - if let hostName = hostDisplayName { - Text(hostName) - .font(.title3.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - } - if let routeLine { - Text(routeLine) - .font(.caption.monospaced()) - .foregroundStyle(ADEColor.textSecondary) - .lineLimit(1) - .truncationMode(.middle) - } + // The machine name now lives in the status caption above, so this block + // only carries the route line (Tailscale/LAN address · port). + if let routeLine { + Text(routeLine) + .font(.caption.monospaced()) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(1) + .truncationMode(.middle) } } } diff --git a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift index baea4f178..9e90a1818 100644 --- a/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift +++ b/apps/ios/ADE/Views/Settings/SettingsPairingSection.swift @@ -5,29 +5,24 @@ struct SettingsPairingSection: View { @Binding var presentedSheet: SettingsPairSheetRoute? var body: some View { - VStack(alignment: .leading, spacing: 10) { - SettingsSectionHeader( - label: "PAIR A MACHINE", - hint: pairingHint - ) - - GlassEffectContainer(spacing: 8) { - VStack(spacing: 8) { - SettingsPairActionRow( - icon: "dot.radiowaves.left.and.right", - title: "Discover on network", - subtitle: discoverSubtitle - ) { - presentedSheet = .discover - } + // Header lives in the parent "MACHINE" subsection now — this just renders + // the pairing action rows. + GlassEffectContainer(spacing: 8) { + VStack(spacing: 8) { + SettingsPairActionRow( + icon: "dot.radiowaves.left.and.right", + title: "Discover on network", + subtitle: discoverSubtitle + ) { + presentedSheet = .discover + } - SettingsPairActionRow( - icon: "keyboard", - title: "Enter machine details", - subtitle: "Machine address and port" - ) { - presentedSheet = .manual - } + SettingsPairActionRow( + icon: "keyboard", + title: "Enter machine details", + subtitle: "Machine address and port" + ) { + presentedSheet = .manual } } } @@ -44,13 +39,6 @@ struct SettingsPairingSection: View { } return count == 1 ? "1 nearby machine found" : "\(count) nearby machines found" } - - private var pairingHint: String? { - guard snapshot.savedReconnectHostCount > 0 else { - return "Pick how to reach your machine" - } - return "Add another machine or switch saved machines" - } } struct SettingsSectionHeader: View { diff --git a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift index 4e1775a04..ada25fb72 100644 --- a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift +++ b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift @@ -14,7 +14,7 @@ struct WorkActivityIndicator: View { @Environment(\.accessibilityReduceMotion) private var reduceMotion - /// Anchors the "· Ns" elapsed label to when the streaming turn began. Held in + /// Anchors the elapsed label to when the streaming turn began. Held in /// `@State` so it survives transcript re-renders; only re-derived when the /// transcript actually changes (via `onAppear` / `onChange`), never per-frame. /// @@ -77,11 +77,11 @@ struct WorkActivityIndicator: View { return max(0, Int(now.timeIntervalSince(turnStart))) } - /// "Thinking · 4s" / "Working · 42s · taking longer than usual". + /// "Thinking · 4s" / "Working · 1m 02s · taking longer than usual". private func tailLabel(for presentation: Presentation, elapsed: Int) -> String { var label = presentation.label if elapsed > 0 { - label += " · \(elapsed)s" + label += " · \(Self.formatElapsedSeconds(elapsed))" } if elapsed >= 30 { label += " · taking longer than usual" @@ -89,6 +89,12 @@ struct WorkActivityIndicator: View { return label } + static func formatElapsedSeconds(_ totalSeconds: Int) -> String { + let safe = max(0, totalSeconds) + if safe < 60 { return "\(safe)s" } + return String(format: "%dm %02ds", safe / 60, safe % 60) + } + /// Anchor the elapsed clock to the most recent active-turn start. Falls back /// to the latest envelope timestamp so the counter still advances even when a /// discrete `status: started` boundary wasn't emitted. @@ -137,9 +143,9 @@ struct WorkActivityIndicator: View { /// Walks the transcript tail looking for the most recent running/active /// event. Command > running tool call > file change > named activity > - /// subagent progress > fall back to "Thinking…". + /// subagent progress > fall back to "Working". static func derivePresentation(from transcript: [WorkChatEnvelope]) -> Presentation? { - let thinkingFallback = Presentation(label: "Thinking", detail: nil, tint: ADEColor.accent) + let workingFallback = Presentation(label: "Working", detail: nil, tint: ADEColor.accent) let endedTurnIds = Set(transcript.compactMap(Self.endedTurnId(from:))) for envelope in sortedWorkChatEnvelopes(transcript).reversed() { @@ -148,36 +154,38 @@ struct WorkActivityIndicator: View { return nil case .userMessage: - return thinkingFallback + return workingFallback case .assistantText(_, let turnId, _): if let turnId, endedTurnIds.contains(turnId) { continue } - return thinkingFallback + return workingFallback case .command(let command, _, _, let status, _, _, _, let turnId): if let turnId, endedTurnIds.contains(turnId) { continue } if status == .running { return Presentation( - label: "Running", + label: "Running command", detail: summarizeCommand(command), tint: ADEColor.accent ) } - case .toolCall(let tool, _, _, _, let turnId): + case .toolCall(let tool, let argsText, _, _, let turnId): if let turnId, endedTurnIds.contains(turnId) { continue } + let activity = workToolActivityPresentation(tool: tool, argsText: argsText) return Presentation( - label: labelForTool(tool), - detail: nil, + label: activity.label, + detail: activity.detail, tint: ADEColor.accent ) case .toolResult(let tool, _, _, _, let turnId, let status): if let turnId, endedTurnIds.contains(turnId) { continue } if status == .running { + let activity = workToolActivityPresentation(tool: tool, argsText: nil) return Presentation( - label: labelForTool(tool), - detail: nil, + label: activity.label, + detail: activity.detail, tint: ADEColor.accent ) } @@ -194,8 +202,9 @@ struct WorkActivityIndicator: View { case .activity(let kind, let detail, let turnId): if let turnId, endedTurnIds.contains(turnId) { continue } + let label = humanizeActivityKind(kind) return Presentation( - label: humanizeActivityKind(kind), + label: label.isEmpty ? "Working" : label, detail: detail?.isEmpty == false ? detail : nil, tint: ADEColor.accent ) @@ -210,7 +219,7 @@ struct WorkActivityIndicator: View { ) } - case .subagentStarted(_, let description, _, let turnId): + case .subagentStarted(_, _, _, _, let description, _, let turnId): if let turnId, endedTurnIds.contains(turnId) { continue } return Presentation( label: "Agent", @@ -218,7 +227,7 @@ struct WorkActivityIndicator: View { tint: ADEColor.accent ) - case .subagentProgress(_, _, let summary, let toolName, let turnId): + case .subagentProgress(_, _, _, _, _, let summary, let toolName, let turnId): if let turnId, endedTurnIds.contains(turnId) { continue } return Presentation( label: toolName.map { "Agent · \($0)" } ?? "Agent", @@ -231,7 +240,7 @@ struct WorkActivityIndicator: View { return nil } if Self.isActiveStatus(turnStatus) { - return thinkingFallback + return workingFallback } if let message, !message.isEmpty { return Presentation( @@ -253,7 +262,7 @@ struct WorkActivityIndicator: View { } } - return thinkingFallback + return workingFallback } private static func endedTurnId(from envelope: WorkChatEnvelope) -> String? { @@ -290,17 +299,6 @@ struct WorkActivityIndicator: View { } } - private static func labelForTool(_ tool: String) -> String { - let normalized = tool.lowercased() - if normalized.contains("read") { return "Reading" } - if normalized.contains("write") { return "Writing" } - if normalized.contains("edit") { return "Editing" } - if normalized.contains("search") || normalized.contains("grep") { return "Searching" } - if normalized.contains("bash") || normalized.contains("shell") { return "Running" } - if normalized.contains("web") { return "Browsing" } - return "Using \(tool)" - } - private static func fileChangeLabel(kind: String) -> String { switch kind.lowercased() { case "create", "add": return "Creating" diff --git a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift index 768e0f4f8..07247a3e1 100644 --- a/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkBrowserHelpers.swift @@ -57,8 +57,9 @@ func workFilteredSessions( searchText: String, outputSearchBySessionId: [String: String] = [:] ) -> [TerminalSessionSummary] { - sessions - .filter { !isRunOwnedSession($0) } + let chatSessionIds = Set(sessions.filter(isChatSession).map(\.id)) + return sessions + .filter { workSessionShouldAppearInWorkList($0, parentChatSessionIds: chatSessionIds) } .filter { session in let isArchived = archivedSessionIds.contains(session.id) let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) @@ -89,6 +90,33 @@ func workFilteredSessions( .sorted { compareWorkSessionSortOrder($0, $1, chatSummaries: chatSummaries) } } +func workSessionShouldAppearInWorkList( + _ session: TerminalSessionSummary, + parentChatSessionIds: Set +) -> Bool { + if isRunOwnedSession(session) { return false } + if isChatSession(session) { return true } + + let parentId = session.chatSessionId? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !parentId.isEmpty, parentId != session.id, parentChatSessionIds.contains(parentId) { + return true + } + + if let ptyId = session.ptyId?.trimmingCharacters(in: .whitespacesAndNewlines), + !ptyId.isEmpty { + return true + } + + let status = session.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let runtimeState = session.runtimeState.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "running" + || status == "idle" + || runtimeState == "running" + || runtimeState == "idle" + || runtimeState == "waiting-input" +} + func workRunningBannerLiveCounts(_ liveSessions: [TerminalSessionSummary]) -> (chat: Int, terminal: Int) { let chatCount = liveSessions.filter(isChatSession).count return (chat: chatCount, terminal: max(0, liveSessions.count - chatCount)) diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 676de4be4..b74970214 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -98,20 +98,70 @@ func workReasoningChipLabel(_ effort: String?) -> String? { } func workChatComposerSupportsFastMode(_ summary: AgentChatSessionSummary) -> Bool { - if summary.effectiveFastMode { return true } - if workComposerModelOption(modelId: summary.modelId ?? summary.model, provider: summary.provider)? - .supportsServiceTier("fast") == true { return true } - return workModelRefsLookFastCapable([summary.modelId, summary.model]) + workComposerFastModeSupported( + modelId: summary.modelId ?? summary.model, + provider: summary.provider, + effectiveFastMode: summary.effectiveFastMode, + fallbackRefs: [summary.modelId, summary.model] + ) } /// Whether a model (by raw id + provider family) can use the "fast" service /// tier. Shared by the in-session and new-chat composers so both surfaces show /// the fast-mode lightning toggle for the same models. func workComposerSupportsFastMode(modelId: String, provider: String) -> Bool { - if workComposerModelOption(modelId: modelId, provider: provider)?.supportsServiceTier("fast") == true { - return true + workComposerFastModeSupported( + modelId: modelId, + provider: provider, + effectiveFastMode: false, + fallbackRefs: [modelId] + ) +} + +private func workComposerFastModeSupported( + modelId: String, + provider: String, + effectiveFastMode: Bool, + fallbackRefs: [String?] +) -> Bool { + if effectiveFastMode { return true } + return WorkComposerFastModeCapabilityCache.shared.supportsFastMode( + modelId: modelId, + provider: provider, + fallbackRefs: fallbackRefs + ) +} + +private final class WorkComposerFastModeCapabilityCache { + static let shared = WorkComposerFastModeCapabilityCache() + + private let lock = NSLock() + private var cachedValues: [String: Bool] = [:] + + func supportsFastMode(modelId: String, provider: String, fallbackRefs: [String?]) -> Bool { + let trimmedModelId = modelId.trimmingCharacters(in: .whitespacesAndNewlines) + let refsKey = fallbackRefs + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .filter { !$0.isEmpty } + .joined(separator: ",") + let key = "\(providerFamilyKey(provider))|\(trimmedModelId.lowercased())|\(refsKey)" + + lock.lock() + if let cached = cachedValues[key] { + lock.unlock() + return cached + } + lock.unlock() + + let supported = workComposerModelOption(modelId: trimmedModelId, provider: provider)? + .supportsServiceTier("fast") == true + || workModelRefsLookFastCapable(fallbackRefs) + + lock.lock() + cachedValues[key] = supported + lock.unlock() + return supported } - return workModelRefsLookFastCapable([modelId]) } /// Resolve the catalog `WorkModelOption` for a raw model id, preferring the @@ -446,7 +496,7 @@ struct WorkComposerControlsRow: View { /// and nothing else. Reasoning is summarized in the model chip and changed /// through the full model picker. struct WorkComposerChipStrip: View { - let chatSummary: AgentChatSessionSummary? + let chatSummary: WorkChatSummaryRenderContext let pendingInputCount: Int let settingsMutationInFlight: Bool let codexFastModeOverride: Bool? @@ -471,17 +521,17 @@ struct WorkComposerChipStrip: View { var body: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { - if let chatSummary { - let currentMode = workInitialRuntimeMode(chatSummary) + if chatSummary.isAvailable { + let currentMode = chatSummary.runtimeMode WorkComposerControlsRow( provider: chatSummary.provider, - modelDisplayName: prettyModelName(chatSummary.model), - reasoningEffort: chatSummary.reasoningEffort ?? "", + modelDisplayName: chatSummary.modelLabel, + reasoningEffort: chatSummary.reasoningEffort, currentMode: currentMode, modeOptions: workRuntimeModeOptions(provider: chatSummary.provider), modeLabel: workRuntimeModeLabel(provider: chatSummary.provider, mode: currentMode), isCollapsed: isCollapsed, - fastModeSupported: workChatComposerSupportsFastMode(chatSummary), + fastModeSupported: chatSummary.fastModeSupported, fastModeEnabled: codexFastModeOverride ?? chatSummary.effectiveFastMode, settingsMutationInFlight: settingsMutationInFlight, onOpenModelPicker: onOpenModelPicker, @@ -632,7 +682,6 @@ struct WorkQueuedSteerStrip: View { } .padding(10) .background(ADEColor.accent.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 14)) .overlay( RoundedRectangle(cornerRadius: 14, style: .continuous) .stroke(ADEColor.accent.opacity(0.22), lineWidth: 0.8) @@ -831,8 +880,7 @@ struct WorkQueuedSteerRow: View { } } .padding(10) - .background(ADEColor.surfaceBackground.opacity(0.6), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 12)) + .background(ADEColor.surfaceBackground.opacity(0.86), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) @@ -897,17 +945,16 @@ func workPreviewIsWireframe(_ text: String) -> Bool { "│", "┌", "┐", "└", "┘", "├", "┤", "┼", "─", "╭", "╮", "╰", "╯", "║", "═", "╔", "╗", "╚", "╝", "╠", "╣", "╬", "▌", "▐", "█", "▓", "▒", "░", "▢", "▣", "□", "■", - "●", "○", "◉", "◯", "◦", ] if text.contains(where: { wireframeScalars.contains($0) }) { return true } let lines = text.split(separator: "\n", omittingEmptySubsequences: false) guard lines.count >= 2 else { return false } - let indentedLines = lines.filter { line in - line.range(of: #"^\s{2,}\S"#, options: .regularExpression) != nil + let alignedColumnLines = lines.filter { line in + line.range(of: #"\S\s{3,}\S"#, options: .regularExpression) != nil } - return indentedLines.count >= 2 + return alignedColumnLines.count >= 2 } struct WorkStructuredQuestionCard: View { diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 6e42ccf29..82ea248b6 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -122,21 +122,24 @@ struct WorkChatMessageBubble: View { ) } - /// Desktop's `--chat-user-bubble-gradient`: a 135° sweep that starts at the - /// (slightly lightened) provider accent, eases into #7c3aed (violet), then - /// settles on #4c1d95 (deep violet). Replicated with explicit color mixes so - /// the per-runtime accent still tints the bubble while every message shares - /// the same violet base. - private var userBubbleGradient: LinearGradient { - LinearGradient( - stops: [ - .init(color: workMixColors(accent, Color.white, 0.08), location: 0.0), - .init(color: workMixColors(accent, workViolet, 0.40), location: 0.5), - .init(color: workMixColors(accent, workDeepViolet, 0.42), location: 1.0), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) + private var isCodexChat: Bool { + let provider = (message.turnProvider ?? sessionProvider ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let model = (message.turnModelId ?? sessionModelId ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + return provider == "codex" + || provider == "openai" + || model.contains("codex") + || model.hasPrefix("gpt-") + || model.hasPrefix("openai/gpt-") + } + + private var userBubbleFill: Color { + isCodexChat + ? workMixColors(accent, workViolet, 0.44) + : workMixColors(accent, workViolet, 0.36) } private var userBubbleBorder: Color { @@ -154,16 +157,23 @@ struct WorkChatMessageBubble: View { // reads like a document. The truncation / "Show more" affordance stays but // unstyled so it doesn't reintroduce a boxed feel. let preview = assistantPreview + let usesMonospacedPreview = workAssistantMessageUsesMonospacedPreview(preview.text) + let maxLineBudget = workAssistantMessageMaxLineBudget(for: message.markdown) return VStack(alignment: .leading, spacing: 10) { if preview.isTruncated { - Text(preview.text) - .font(.body) - .foregroundStyle(ADEColor.textPrimary) - .lineSpacing(5) - .tint(ADEColor.accent) - .frame(maxWidth: .infinity, alignment: .leading) - .textSelection(.enabled) + if usesMonospacedPreview { + WorkAssistantMonospacedPreview(text: preview.text) + .accessibilityLabel(workAssistantMessageAccessibilityLabel(preview)) + } else { + WorkMarkdownRenderer( + markdown: preview.text, + streamingCacheKey: isStreaming ? message.id : nil + ) + .accessibilityLabel(workAssistantMessageAccessibilityLabel(preview)) + } + } else if usesMonospacedPreview { + WorkAssistantMonospacedPreview(text: preview.text) .accessibilityLabel(workAssistantMessageAccessibilityLabel(preview)) } else { WorkMarkdownRenderer( @@ -176,9 +186,9 @@ struct WorkChatMessageBubble: View { if preview.isTruncated { HStack(spacing: 12) { - Text("\(preview.visibleLineCount) of \(preview.totalLineCount) lines") - .font(.caption2.weight(.semibold)) - .foregroundStyle(ADEColor.textMuted) + Text(workAssistantMessagePreviewSummaryText(preview)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) Spacer(minLength: 0) @@ -192,11 +202,12 @@ struct WorkChatMessageBubble: View { .buttonStyle(.plain) .foregroundStyle(ADEColor.textSecondary) - if assistantLineBudget < min(preview.totalLineCount, workAssistantMessageMaxLineBudget) { + if preview.isTruncated, + assistantLineBudget < maxLineBudget { Button { assistantLineBudget = min( assistantLineBudget + workAssistantMessageLineBudgetStep, - workAssistantMessageMaxLineBudget + maxLineBudget ) } label: { Label("Show more", systemImage: "chevron.down") @@ -269,24 +280,11 @@ struct WorkChatMessageBubble: View { } .padding(.horizontal, 16) .padding(.vertical, 8) - .background(userBubbleGradient, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .overlay( - // Subtle inset top highlight — the desktop bubble's soft sheen. - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.14), .clear], - startPoint: .top, - endPoint: .center - ) - ) - .allowsHitTesting(false) - ) + .background(userBubbleFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(userBubbleBorder, lineWidth: 0.8) ) - .shadow(color: accent.opacity(0.34), radius: 12, y: 5) .frame(maxWidth: maxBubbleWidth, alignment: .trailing) .fixedSize(horizontal: false, vertical: true) } @@ -321,7 +319,8 @@ struct WorkChatMessageBubble: View { return workAssistantMessagePreview( message.markdown, lineBudget: assistantLineBudget, - characterBudget: workAssistantMessageCharacterBudget(forLineBudget: assistantLineBudget) + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: assistantLineBudget), + anchor: .head ) } @@ -380,6 +379,23 @@ struct WorkChatMessageBubble: View { } } +struct WorkAssistantMonospacedPreview: View { + let text: String + + var body: some View { + Text(text) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ADEColor.textPrimary) + .lineSpacing(3) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + .padding(.vertical, 2) + .tint(ADEColor.accent) + } +} + /// Linearly blend two colors in sRGB. `fraction` is the weight of `other` /// (0 → all `base`, 1 → all `other`), matching CSS `color-mix` semantics where /// `mix(base X%, other …)` means `other` gets `1 - X` weight. Resolves both @@ -403,36 +419,53 @@ func workMixColors(_ base: Color, _ other: Color, _ fraction: Double) -> Color { let workAssistantMessageInitialLineBudget = 48 let workAssistantMessageLineBudgetStep = 48 let workAssistantMessageMaxLineBudget = 192 -let workAssistantMessageInitialCharacterBudget = 4_000 -let workAssistantMessageCharacterBudgetStep = 4_000 +let workAssistantMessageInitialCharacterBudget = 1_600 +let workAssistantMessageCharacterBudgetStep = 2_400 +let workAssistantMessageSmallFullCharacterBudget = 6_000 +let workAssistantMessageTailFullLineBudget = 96 +let workAssistantMessageTailFullCharacterBudget = 12_000 +let workAssistantMessageWideInitialLineBudget = 24 +let workAssistantMessageWideMaxLineBudget = 96 let workChatAccessibilityPreviewLimit = 800 +enum WorkAssistantMessagePreviewAnchor: Equatable { + case head + case tail +} + struct WorkAssistantMessagePreview: Equatable { let text: String let isTruncated: Bool let visibleLineCount: Int let totalLineCount: Int + let visibleCharacterCount: Int + let totalCharacterCount: Int + let anchor: WorkAssistantMessagePreviewAnchor } final class WorkAssistantPreviewCache { private struct Entry { let utf8Count: Int - let markdown: String + let textHash: Int + let anchor: WorkAssistantMessagePreviewAnchor let preview: WorkAssistantMessagePreview } private var entries: [String: Entry] = [:] - func preview(for message: WorkChatMessage) -> WorkAssistantMessagePreview { + func preview(for message: WorkChatMessage, anchor: WorkAssistantMessagePreviewAnchor = .head) -> WorkAssistantMessagePreview { let utf8Count = message.markdown.utf8.count + let textHash = message.markdown.hashValue if let entry = entries[message.id], entry.utf8Count == utf8Count, - entry.markdown == message.markdown { + entry.textHash == textHash, + entry.anchor == anchor, + entry.preview.anchor == anchor { return entry.preview } - let preview = workInitialAssistantMessagePreview(message.markdown) - entries[message.id] = Entry(utf8Count: utf8Count, markdown: message.markdown, preview: preview) + let preview = workInitialAssistantMessagePreview(message.markdown, anchor: anchor) + entries[message.id] = Entry(utf8Count: utf8Count, textHash: textHash, anchor: anchor, preview: preview) return preview } @@ -441,11 +474,15 @@ final class WorkAssistantPreviewCache { } } -func workInitialAssistantMessagePreview(_ markdown: String) -> WorkAssistantMessagePreview { +func workInitialAssistantMessagePreview( + _ markdown: String, + anchor: WorkAssistantMessagePreviewAnchor = .head +) -> WorkAssistantMessagePreview { workAssistantMessagePreview( markdown, lineBudget: workAssistantMessageInitialLineBudget, - characterBudget: workAssistantMessageCharacterBudget(forLineBudget: workAssistantMessageInitialLineBudget) + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: workAssistantMessageInitialLineBudget), + anchor: anchor ) } @@ -457,22 +494,55 @@ func workAssistantMessageCharacterBudget(forLineBudget lineBudget: Int) -> Int { func workAssistantMessagePreview( _ markdown: String, lineBudget: Int, - characterBudget: Int + characterBudget: Int, + anchor: WorkAssistantMessagePreviewAnchor = .head ) -> WorkAssistantMessagePreview { let normalized = markdown.replacingOccurrences(of: "\r\n", with: "\n") guard !normalized.isEmpty else { - return WorkAssistantMessagePreview(text: markdown, isTruncated: false, visibleLineCount: 0, totalLineCount: 0) + return WorkAssistantMessagePreview( + text: markdown, + isTruncated: false, + visibleLineCount: 0, + totalLineCount: 0, + visibleCharacterCount: 0, + totalCharacterCount: 0, + anchor: anchor + ) } - let clampedLineBudget = max(lineBudget, 1) - let clampedCharacterBudget = max(characterBudget, 256) + let usesMonospacedPreview = workAssistantMessageUsesMonospacedPreview(normalized) + let clampedLineBudget = workAssistantMessageEffectiveLineBudget( + requestedLineBudget: max(lineBudget, 1), + usesMonospacedPreview: usesMonospacedPreview + ) + let clampedCharacterBudget = max( + usesMonospacedPreview + ? min(characterBudget, workAssistantMessageWideCharacterBudget(forLineBudget: clampedLineBudget)) + : characterBudget, + 256 + ) let totalLineCount = workAssistantMessageLineCount(normalized) - if totalLineCount <= clampedLineBudget && normalized.count <= clampedCharacterBudget { + let totalCharacterCount = normalized.count + let smallFullCharacterBudget = max(clampedCharacterBudget, workAssistantMessageSmallFullCharacterBudget) + if totalLineCount <= clampedLineBudget && normalized.count <= smallFullCharacterBudget { return WorkAssistantMessagePreview( text: markdown, isTruncated: false, visibleLineCount: totalLineCount, - totalLineCount: totalLineCount + totalLineCount: totalLineCount, + visibleCharacterCount: totalCharacterCount, + totalCharacterCount: totalCharacterCount, + anchor: anchor + ) + } + + if anchor == .tail { + return workAssistantMessageTailPreview( + normalized, + lineBudget: clampedLineBudget, + characterBudget: clampedCharacterBudget, + totalLineCount: totalLineCount, + totalCharacterCount: totalCharacterCount ) } @@ -514,11 +584,93 @@ func workAssistantMessagePreview( text: rendered, isTruncated: visibleLineCount < totalLineCount || rendered.count < normalized.count, visibleLineCount: visibleLineCount, - totalLineCount: totalLineCount + totalLineCount: totalLineCount, + visibleCharacterCount: rendered.count, + totalCharacterCount: totalCharacterCount, + anchor: .head ) } -private func workAssistantMessageLineCount(_ text: String) -> Int { +private func workAssistantMessageTailPreview( + _ normalized: String, + lineBudget: Int, + characterBudget: Int, + totalLineCount: Int, + totalCharacterCount: Int +) -> WorkAssistantMessagePreview { + var segments: [Substring] = [] + segments.reserveCapacity(min(lineBudget, 16)) + var usedCharacters = 0 + var visibleLineCount = 0 + var lineEnd = normalized.endIndex + + while lineEnd >= normalized.startIndex, visibleLineCount < lineBudget { + let lineStart = normalized[.. 0 else { break } + + let lineLength = normalized.distance(from: lineStart, to: lineEnd) + if lineLength > remaining { + let suffixStart = normalized.index(lineEnd, offsetBy: -remaining) + segments.append(normalized[suffixStart.. normalized.startIndex else { break } + lineEnd = normalized.index(before: lineStart) + } + + let rendered = segments.reversed().joined(separator: "\n") + return WorkAssistantMessagePreview( + text: rendered, + isTruncated: visibleLineCount < totalLineCount || rendered.count < normalized.count, + visibleLineCount: visibleLineCount, + totalLineCount: totalLineCount, + visibleCharacterCount: rendered.count, + totalCharacterCount: totalCharacterCount, + anchor: .tail + ) +} + +func workAssistantMessageUsesMonospacedPreview(_ text: String) -> Bool { + workPreviewIsWireframe(text) +} + +func workAssistantMessageMaxLineBudget(for text: String) -> Int { + workAssistantMessageUsesMonospacedPreview(text) + ? workAssistantMessageWideMaxLineBudget + : workAssistantMessageMaxLineBudget +} + +private func workAssistantMessageEffectiveLineBudget( + requestedLineBudget: Int, + usesMonospacedPreview: Bool +) -> Int { + guard usesMonospacedPreview else { + return requestedLineBudget + } + if requestedLineBudget <= workAssistantMessageInitialLineBudget { + return min(requestedLineBudget, workAssistantMessageWideInitialLineBudget) + } + return min(requestedLineBudget, workAssistantMessageWideMaxLineBudget) +} + +private func workAssistantMessageWideCharacterBudget(forLineBudget lineBudget: Int) -> Int { + let extraSteps = max((lineBudget - workAssistantMessageWideInitialLineBudget) / workAssistantMessageLineBudgetStep, 0) + return workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) +} + +func workAssistantMessageLineCount(_ text: String) -> Int { text.reduce(1) { count, character in character == "\n" ? count + 1 : count } @@ -526,7 +678,7 @@ private func workAssistantMessageLineCount(_ text: String) -> Int { func workAssistantMessageAccessibilityLabel(_ preview: WorkAssistantMessagePreview) -> String { if preview.isTruncated { - return "Assistant response preview. \(preview.visibleLineCount) of \(preview.totalLineCount) lines shown." + return "Assistant response preview. \(workAssistantMessagePreviewSummaryText(preview)) shown." } let trimmed = preview.text.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { @@ -538,6 +690,40 @@ func workAssistantMessageAccessibilityLabel(_ preview: WorkAssistantMessagePrevi return "Assistant response preview. \(trimmed.prefix(500))" } +func workAssistantMessagePreviewSummaryText(_ preview: WorkAssistantMessagePreview) -> String { + if preview.visibleLineCount < preview.totalLineCount { + switch preview.anchor { + case .head: + return "\(preview.visibleLineCount) of \(preview.totalLineCount) lines" + case .tail: + return "Latest \(preview.visibleLineCount) of \(preview.totalLineCount) lines" + } + } + + if preview.visibleCharacterCount < preview.totalCharacterCount { + let visible = workAssistantCompactCount(preview.visibleCharacterCount) + let total = workAssistantCompactCount(preview.totalCharacterCount) + switch preview.anchor { + case .head: + return "\(visible) of \(total) characters" + case .tail: + return "Latest \(visible) of \(total) characters" + } + } + + return "\(preview.totalLineCount) line\(preview.totalLineCount == 1 ? "" : "s")" +} + +private func workAssistantCompactCount(_ count: Int) -> String { + if count >= 1_000_000 { + return String(format: "%.1fM", Double(count) / 1_000_000.0) + } + if count >= 1_000 { + return String(format: "%.1fK", Double(count) / 1_000.0) + } + return "\(count)" +} + func workChatAccessibilityPreview(_ markdown: String) -> String { guard markdown.count > workChatAccessibilityPreviewLimit else { return markdown } return "\(markdown.prefix(workChatAccessibilityPreviewLimit))..." @@ -600,9 +786,41 @@ struct WorkTurnSeparatorView: View { struct WorkTurnEndMarkerView: View { let marker: WorkTurnEndMarker + private var status: String { + marker.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private var completed: Bool { + status.isEmpty || status == "completed" || status == "complete" || status == "succeeded" || status == "success" + } + + private var statusTint: Color { + status == "failed" ? ADEColor.danger : ADEColor.warning + } + + private var accent: Color { + ADEColor.chatSurfaceAccent(modelId: marker.modelId, provider: marker.provider) + } + var body: some View { HStack(spacing: 10) { hairline + content + hairline + } + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .accessibilityElement(children: .combine) + .accessibilityLabel( + completed + ? "Turn ended at \(workTurnSeparatorTimeLabel(marker.time)). Worked for \(marker.workedDurationLabel)" + : "Turn \(status). \(marker.modelLabel). Worked for \(marker.workedDurationLabel)" + ) + } + + @ViewBuilder + private var content: some View { + if completed { Text("\(workTurnSeparatorTimeLabel(marker.time)) · Worked for \(marker.workedDurationLabel)") .font(.caption2) .foregroundStyle(ADEColor.textMuted) @@ -610,14 +828,42 @@ struct WorkTurnEndMarkerView: View { .minimumScaleFactor(0.9) .fixedSize(horizontal: true, vertical: false) .layoutPriority(1) - hairline + } else { + HStack(spacing: 6) { + runtimeGlyph + if !marker.modelLabel.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(marker.modelLabel) + .font(.caption2.weight(.semibold)) + } + Text(status.uppercased()) + .font(.caption2.weight(.semibold)) + .tracking(0.5) + Text("·") + .opacity(0.42) + Text("Worked for \(marker.workedDurationLabel)") + .font(.caption2) + } + .foregroundStyle(statusTint.opacity(0.9)) + .lineLimit(1) + .minimumScaleFactor(0.82) + .fixedSize(horizontal: true, vertical: false) + .layoutPriority(1) + } + } + + @ViewBuilder + private var runtimeGlyph: some View { + if let asset = providerAssetName(marker.provider) { + Image(asset) + .resizable() + .scaledToFit() + .frame(width: 11, height: 11) + .opacity(0.9) + } else { + Circle() + .fill(accent.opacity(0.75)) + .frame(width: 5, height: 5) } - .frame(maxWidth: .infinity) - .padding(.vertical, 8) - .accessibilityElement(children: .combine) - .accessibilityLabel( - "Turn ended at \(workTurnSeparatorTimeLabel(marker.time)). Worked for \(marker.workedDurationLabel)" - ) } private var hairline: some View { diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 5c3bfc073..eeba6da24 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -978,6 +978,8 @@ struct WorkEventCardView: View { var body: some View { if card.kind == "contextCompact" { WorkContextCompactDivider(summary: card.body, isInProgress: card.isInProgress) + } else if card.kind == "status" { + statusRibbonBody } else if isRibbonKind(card.kind) { ribbonBody } else { @@ -1022,6 +1024,42 @@ struct WorkEventCardView: View { .accessibilityLabel([card.title, card.body].compactMap { $0 }.joined(separator: ". ")) } + private var statusRibbonBody: some View { + let normalized = card.metadata.first?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + let isFailure = normalized == "failed" + let isInterrupted = normalized == "interrupted" + let tint = isFailure ? ADEColor.danger : (isInterrupted ? ADEColor.warning : ADEColor.textMuted) + + return HStack(alignment: .center, spacing: 8) { + Image(systemName: isFailure ? "xmark.circle.fill" : "pause.circle.fill") + .font(.system(size: 11, weight: .bold)) + Text(normalized.isEmpty ? ribbonText.uppercased() : normalized.uppercased()) + .font(.caption2.monospaced().weight(.semibold)) + .tracking(0.8) + if let body = card.body?.trimmingCharacters(in: .whitespacesAndNewlines), !body.isEmpty { + Text(body) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + Spacer(minLength: 8) + Text(relativeTimestamp(card.timestamp)) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + .foregroundStyle(tint.opacity(0.9)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(tint.opacity(isFailure || isInterrupted ? 0.05 : 0.0), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(isFailure || isInterrupted ? 0.14 : 0.0), lineWidth: 1) + ) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + .accessibilityLabel([card.title, card.body].compactMap { $0 }.joined(separator: ". ")) + } + private var ribbonText: String { // Prefer the actual event text over the generic "Turn status" title so // the ribbon reads "Started" / "Completed" / "Session ready" instead of @@ -1701,6 +1739,10 @@ struct WorkSubagentStrip: View { Image(systemName: "xmark.circle.fill") .font(.system(size: 11, weight: .bold)) .foregroundStyle(tint) + case .stopped: + Image(systemName: "pause.circle.fill") + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(tint) } } @@ -1749,6 +1791,7 @@ struct WorkSubagentStrip: View { case .running: return ADEColor.accent case .succeeded: return ADEColor.success case .failed: return ADEColor.danger + case .stopped: return ADEColor.warning } } @@ -1757,6 +1800,7 @@ struct WorkSubagentStrip: View { case .running: return "Running" case .succeeded: return "Done" case .failed: return "Failed" + case .stopped: return "Halted" } } @@ -1771,3 +1815,343 @@ struct WorkSubagentStrip: View { return String(trimmed.prefix(limit - 1)) + "…" } } + +struct WorkSubagentActivePopup: View { + let count: Int + let onOpen: () -> Void + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 8) { + Image(systemName: "person.2.fill") + .font(.system(size: 12, weight: .semibold)) + Text("\(count) active") + .font(.caption.weight(.semibold)) + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(ADEColor.accent) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(ADEColor.cardBackground.opacity(0.76), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(ADEColor.accent.opacity(0.22), lineWidth: 1) + ) + .contentShape(Capsule(style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel("\(count) active subagent\(count == 1 ? "" : "s")") + } +} + +struct WorkSubagentDrawerSheet: View { + let snapshots: [WorkSubagentSnapshot] + let provider: String? + let selectedTaskId: String? + let probingTaskId: String? + @Binding var expandedTaskIds: Set + let onSelect: @MainActor (WorkSubagentSnapshot) async -> Void + + private var foreground: [WorkSubagentSnapshot] { + snapshots.filter { !$0.background } + } + + private var background: [WorkSubagentSnapshot] { + snapshots.filter(\.background) + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 16) { + if !foreground.isEmpty { + section(title: "Subagents", snapshots: foreground) + } + if !background.isEmpty { + section(title: "Background", snapshots: background) + } + if snapshots.isEmpty { + ADEEmptyStateView( + symbol: "person.2", + title: "No subagents", + message: "This chat has not started any subagents yet." + ) + .padding(.top, 24) + } + } + .padding(16) + } + .scrollIndicators(.hidden) + .background(workChatCanvasBackground.ignoresSafeArea()) + .navigationTitle("Subagents") + .navigationBarTitleDisplayMode(.inline) + } + } + + @ViewBuilder + private func section(title: String, snapshots: [WorkSubagentSnapshot]) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + VStack(spacing: 6) { + ForEach(snapshots) { snapshot in + row(snapshot) + } + } + } + } + + @ViewBuilder + private func row(_ snapshot: WorkSubagentSnapshot) -> some View { + let selected = selectedTaskId == snapshot.taskId + let expanded = expandedTaskIds.contains(snapshot.taskId) + let elapsed = workSubagentElapsedLabel(snapshot) + let detailText = drawerDetailText(snapshot) + let lastToolName = trimmedNonEmpty(snapshot.lastToolName) + let subtitle = drawerSubtitleText(snapshot, elapsed: elapsed, detailText: detailText) + let showsDisclosure = snapshot.status == .running || detailText != nil || lastToolName != nil + Button { + Task { await onSelect(snapshot) } + } label: { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 10) { + WorkSubagentGlyph(id: snapshot.agentId ?? snapshot.taskId, status: snapshot.status) + VStack(alignment: .leading, spacing: 2) { + Text(workSubagentMeaningfulName(snapshot)) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(rowTitleColor(snapshot)) + .lineLimit(1) + .truncationMode(.tail) + if let subtitle { + Text(subtitle) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + } + Spacer(minLength: 0) + HStack(spacing: 8) { + if probingTaskId == snapshot.taskId { + ProgressView() + .controlSize(.small) + } + WorkSubagentStatusChip(status: snapshot.status) + if showsDisclosure { + Image(systemName: selected ? "arrow.uturn.left" : "chevron.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + } + } + } + + if expanded, detailText != nil || lastToolName != nil { + VStack(alignment: .leading, spacing: 5) { + if let detailText { + Text(detailText) + } + if let tool = lastToolName { + Text("last: \(tool)") + .font(.caption2.monospaced()) + .foregroundStyle(ADEColor.textMuted) + } + } + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 34) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(selected ? ADEColor.accent.opacity(0.12) : ADEColor.cardBackground.opacity(0.52)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(selected ? ADEColor.accent.opacity(0.45) : ADEColor.glassBorder, lineWidth: 1) + ) + } + .buttonStyle(.plain) + } + + private func drawerDetailText(_ snapshot: WorkSubagentSnapshot) -> String? { + if let summary = filteredSubagentDetail(snapshot.latestSummary) { + return summary + } + + return filteredSubagentDetail(snapshot.description) + } + + private func drawerSubtitleText( + _ snapshot: WorkSubagentSnapshot, + elapsed: String?, + detailText: String? + ) -> String? { + var parts: [String] = [] + if let elapsed { + parts.append(elapsed) + } + if snapshot.background { + parts.append("background") + } + if let detailText { + parts.append(truncatedDrawerText(detailText, limit: 58)) + } + return parts.isEmpty ? nil : parts.joined(separator: " · ") + } + + private func filteredSubagentDetail(_ value: String?) -> String? { + guard let trimmed = trimmedNonEmpty(value) else { + return nil + } + switch trimmed.lowercased() { + case "agent closed", "agent stopped", "subagent closed", "subagent stopped": + return nil + default: + return trimmed + } + } + + private func trimmedNonEmpty(_ value: String?) -> String? { + guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return trimmed + } + + private func truncatedDrawerText(_ value: String, limit: Int) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > limit, limit > 1 else { + return trimmed + } + return String(trimmed.prefix(limit - 1)) + "…" + } + + private func rowTitleColor(_ snapshot: WorkSubagentSnapshot) -> Color { + switch snapshot.status { + case .running: return ADEColor.accent + case .failed: return ADEColor.danger + case .succeeded: return ADEColor.textPrimary + case .stopped: return ADEColor.textMuted + } + } +} + +private struct WorkSubagentStatusChip: View { + let status: WorkSubagentSnapshot.Status + + var body: some View { + Text(workSubagentStatusLabel(status)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(tint.opacity(0.13), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(0.28), lineWidth: 0.75) + ) + .accessibilityLabel(workSubagentStatusLabel(status)) + } + + private var tint: Color { + workSubagentStatusTint(status) + } +} + +private struct WorkSubagentGlyph: View { + let id: String + let status: WorkSubagentSnapshot.Status + + private var color: Color { + let palette: [Color] = [ADEColor.accent, ADEColor.success, ADEColor.warning, ADEColor.info, ADEColor.danger] + return palette[abs(workStableSubagentHash(id)) % palette.count] + } + + var body: some View { + ZStack(alignment: .bottomTrailing) { + LazyVGrid(columns: Array(repeating: GridItem(.fixed(5), spacing: 1), count: 3), spacing: 1) { + ForEach(0..<9, id: \.self) { index in + RoundedRectangle(cornerRadius: 1.5, style: .continuous) + .fill(workSubagentGlyphBit(id: id, index: index) ? color : color.opacity(0.22)) + .frame(width: 5, height: 5) + } + } + .padding(5) + .background(color.opacity(0.12), in: RoundedRectangle(cornerRadius: 7, style: .continuous)) + + Circle() + .fill(statusColor) + .frame(width: 7, height: 7) + .overlay(Circle().stroke(workChatCanvasBackground, lineWidth: 1.5)) + } + .frame(width: 28, height: 28) + } + + private var statusColor: Color { + workSubagentStatusTint(status) + } +} + +private func workStableSubagentHash(_ value: String) -> Int { + var hash = 5381 + for scalar in value.unicodeScalars { + hash = ((hash << 5) &+ hash) &+ Int(scalar.value) + } + return hash +} + +private func workSubagentGlyphBit(id: String, index: Int) -> Bool { + let hash = abs(workStableSubagentHash("\(id):\(index)")) + return hash % 3 != 0 +} + +private func workSubagentStatusLabel(_ status: WorkSubagentSnapshot.Status) -> String { + switch status { + case .running: return "Running" + case .succeeded: return "Completed" + case .failed: return "Failed" + case .stopped: return "Stopped" + } +} + +private func workSubagentStatusTint(_ status: WorkSubagentSnapshot.Status) -> Color { + switch status { + case .running: return ADEColor.accent + case .succeeded: return ADEColor.success + case .failed: return ADEColor.danger + case .stopped: return ADEColor.warning + } +} + +private func workSubagentElapsedLabel(_ snapshot: WorkSubagentSnapshot) -> String? { + guard let startedAt = snapshot.startedAt, + let start = parseWorkTimestampForSubagent(startedAt) + else { return nil } + let end = snapshot.status == .running + ? Date() + : snapshot.updatedAt.flatMap(parseWorkTimestampForSubagent) ?? Date() + return WorkActivityIndicator.formatElapsedSeconds(Int(max(0, end.timeIntervalSince(start)))) +} + +private func parseWorkTimestampForSubagent(_ value: String) -> Date? { + workSubagentIsoFormatter.date(from: value) ?? workSubagentIsoFallbackFormatter.date(from: value) +} + +private let workSubagentIsoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +private let workSubagentIsoFallbackFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 188ef03b2..1349fdcf1 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -2,82 +2,779 @@ import SwiftUI import UIKit import AVKit +struct WorkTimelineIncrementalCache { + fileprivate var transcriptCount = 0 + fileprivate var transcriptHeadKey: String? + fileprivate var transcriptTailKey: String? + fileprivate var fallbackSignature = 0 + fileprivate var artifactSignature = 0 + fileprivate var localEchoSignature = 0 + fileprivate var localEchoCount = 0 + fileprivate var localEchoTailId: String? + + mutating func reset() { + transcriptCount = 0 + transcriptHeadKey = nil + transcriptTailKey = nil + fallbackSignature = 0 + artifactSignature = 0 + localEchoSignature = 0 + localEchoCount = 0 + localEchoTailId = nil + } + + mutating func record( + transcript: [WorkChatEnvelope], + fallbackEntries: [AgentChatTranscriptEntry], + artifacts: [ComputerUseArtifactSummary], + localEchoMessages: [WorkLocalEchoMessage] + ) { + transcriptCount = transcript.count + transcriptHeadKey = transcript.first.map(workIncrementalEnvelopeKey) + transcriptTailKey = transcript.last.map(workIncrementalEnvelopeKey) + fallbackSignature = workIncrementalFallbackSignature(fallbackEntries, transcriptIsEmpty: transcript.isEmpty) + artifactSignature = workIncrementalArtifactSignature(artifacts) + localEchoSignature = workIncrementalLocalEchoSignature(localEchoMessages) + localEchoCount = localEchoMessages.count + localEchoTailId = localEchoMessages.last?.id + } +} + +private actor WorkTimelineSnapshotBuildCoordinator { + static let shared = WorkTimelineSnapshotBuildCoordinator() + + private var latestRequestIdsByScope: [String: Int] = [:] + + func reserve(scope: String) -> Int { + let nextRequestId = (latestRequestIdsByScope[scope] ?? 0) + 1 + latestRequestIdsByScope[scope] = nextRequestId + return nextRequestId + } + + func build( + scope: String, + requestId: Int, + transcript: [WorkChatEnvelope], + fallbackEntries: [AgentChatTranscriptEntry], + artifacts: [ComputerUseArtifactSummary], + localEchoMessages: [WorkLocalEchoMessage] + ) -> WorkChatTimelineSnapshot? { + guard latestRequestIdsByScope[scope] == requestId, !Task.isCancelled else { return nil } + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) + guard latestRequestIdsByScope[scope] == requestId, !Task.isCancelled else { return nil } + return snapshot + } +} + +private func workSnapshotByApplyingAssistantTextTail( + to snapshot: WorkChatTimelineSnapshot, + cache: WorkTimelineIncrementalCache, + transcript: [WorkChatEnvelope], + fallbackEntries: [AgentChatTranscriptEntry], + artifacts: [ComputerUseArtifactSummary], + localEchoMessages: [WorkLocalEchoMessage] +) -> WorkChatTimelineSnapshot? { + guard !snapshot.timeline.isEmpty, + cache.transcriptCount > 0, + !transcript.isEmpty, + cache.transcriptHeadKey == transcript.first.map(workIncrementalEnvelopeKey), + cache.fallbackSignature == workIncrementalFallbackSignature(fallbackEntries, transcriptIsEmpty: transcript.isEmpty), + cache.artifactSignature == workIncrementalArtifactSignature(artifacts), + cache.localEchoSignature == workIncrementalLocalEchoSignature(localEchoMessages) + else { return nil } + + let candidateEnvelopes: ArraySlice + if transcript.count == cache.transcriptCount { + guard cache.transcriptTailKey == transcript.last.map(workIncrementalEnvelopeKey), + let last = transcript.last, + workIncrementalEnvelopeCanApplyWithoutFullRebuild(last) + else { return nil } + candidateEnvelopes = transcript[(transcript.count - 1).. cache.transcriptCount { + let previousTailIndex = cache.transcriptCount - 1 + guard transcript.indices.contains(previousTailIndex), + workIncrementalEnvelopeKey(transcript[previousTailIndex]) == cache.transcriptTailKey + else { return nil } + candidateEnvelopes = transcript[cache.transcriptCount.. WorkChatTimelineSnapshot? { + guard !snapshot.timeline.isEmpty, + cache.transcriptCount == transcript.count, + cache.transcriptHeadKey == transcript.first.map(workIncrementalEnvelopeKey), + cache.transcriptTailKey == transcript.last.map(workIncrementalEnvelopeKey), + cache.fallbackSignature == workIncrementalFallbackSignature(fallbackEntries, transcriptIsEmpty: transcript.isEmpty), + cache.artifactSignature == workIncrementalArtifactSignature(artifacts), + localEchoMessages.count > cache.localEchoCount + else { return nil } + + if cache.localEchoCount > 0 { + let previousEchoIndex = cache.localEchoCount - 1 + guard localEchoMessages.indices.contains(previousEchoIndex), + localEchoMessages[previousEchoIndex].id == cache.localEchoTailId + else { return nil } + } + + var timeline = snapshot.timeline + for echo in localEchoMessages[cache.localEchoCount.. Bool { + if workIncrementalEnvelopeIsLiveMetadata(envelope) { + return true + } + if case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, let processed) = envelope.event { + guard deliveryState != "queued" || steerId == nil else { return false } + workIncrementalRemoveDuplicateEchoes(matching: text, from: &timeline) + let message = WorkChatMessage( + id: envelope.id, + role: "user", + markdown: text, + timestamp: envelope.timestamp, + turnId: turnId, + itemId: nil, + steerId: steerId, + deliveryState: deliveryState, + processed: processed, + attachments: attachments + ) + timeline.append(WorkTimelineEntry( + id: "message-\(message.id)", + timestamp: envelope.timestamp, + rank: workIncrementalNextMessageRank(in: timeline), + payload: .message(message) + )) + return true + } + + guard case .assistantText(let text, let turnId, let itemId) = envelope.event else { + return false + } + + if let targetIndex = workIncrementalAssistantTargetIndex( + in: timeline, + turnId: turnId, + itemId: itemId, + envelopeId: envelope.id + ) { + guard case .message(var message) = timeline[targetIndex].payload else { return false } + message.markdown = mergeWorkStreamingText(message.markdown, text) + message.assistantPreview = nil + timeline[targetIndex] = WorkTimelineEntry( + id: timeline[targetIndex].id, + timestamp: timeline[targetIndex].timestamp, + rank: timeline[targetIndex].rank, + payload: .message(message) + ) + return true + } + + let message = WorkChatMessage( + id: envelope.id, + role: "assistant", + markdown: text, + timestamp: envelope.timestamp, + turnId: turnId, + itemId: itemId + ) + timeline.append(WorkTimelineEntry( + id: "message-\(message.id)", + timestamp: envelope.timestamp, + rank: workIncrementalNextMessageRank(in: timeline), + payload: .message(message) + )) + return true +} + +private func workIncrementalAssistantTargetIndex( + in timeline: [WorkTimelineEntry], + turnId: String?, + itemId: String?, + envelopeId: String +) -> Int? { + let entryId = "message-\(envelopeId)" + if let exactIndex = timeline.indices.last(where: { timeline[$0].id == entryId }) { + return exactIndex + } + + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !normalizedItemId.isEmpty, + let itemIndex = timeline.indices.reversed().first(where: { index in + guard case .message(let message) = timeline[index].payload, + message.role == "assistant", + (message.itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "") == normalizedItemId + else { return false } + return workIncrementalStableItemTurnIdsMatch(message.turnId, turnId) + }) { + return itemIndex + } + + guard normalizedItemId.isEmpty, + let lastMessageIndex = timeline.indices.reversed().first(where: { index in + if case .message = timeline[index].payload { return true } + return false + }), + case .message(let message) = timeline[lastMessageIndex].payload, + message.role == "assistant", + workIncrementalTurnIdsMatch(message.turnId, turnId) + else { return nil } + return lastMessageIndex +} + +private func workIncrementalTurnIdsMatch(_ lhs: String?, _ rhs: String?) -> Bool { + let left = lhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let right = rhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return left == right +} + +private func workIncrementalStableItemTurnIdsMatch(_ lhs: String?, _ rhs: String?) -> Bool { + let left = lhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let right = rhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return left.isEmpty || right.isEmpty || left == right +} + +private func workIncrementalEnvelopeIsAssistantText(_ envelope: WorkChatEnvelope) -> Bool { + if case .assistantText = envelope.event { return true } + return false +} + +private func workIncrementalEnvelopeCanApplyWithoutFullRebuild(_ envelope: WorkChatEnvelope) -> Bool { + if workIncrementalEnvelopeIsAssistantText(envelope) { return true } + if workIncrementalEnvelopeIsLiveMetadata(envelope) { return true } + if case .userMessage(_, _, _, let steerId, let deliveryState, _) = envelope.event { + return deliveryState != "queued" || steerId == nil + } + return false +} + +private func workIncrementalEnvelopeIsLiveMetadata(_ envelope: WorkChatEnvelope) -> Bool { + switch envelope.event { + case .activity, .tokens, .toolUseSummary: + return true + case .reasoning: + // Reasoning cards can be materialized by the terminal rebuild. Updating + // them for every live token is the same pathological full-scan shape as + // assistant text, but with less user value while the answer is still moving. + return true + case .status(let turnStatus, let message, _): + let normalizedStatus = turnStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedMessage = message?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard normalizedMessage.isEmpty || normalizedMessage == normalizedStatus else { + return false + } + switch normalizedStatus { + case "started", "active", "running", "inprogress", "in_progress", "in-progress": + return true + default: + return false + } + default: + return false + } +} + +private func workIncrementalEnvelopeOrderIsAppendOnly( + previous: WorkChatEnvelope, + suffix: ArraySlice +) -> Bool { + var last = previous + for envelope in suffix { + if envelope.timestamp < last.timestamp { return false } + if envelope.timestamp == last.timestamp, + (envelope.sequence ?? 0) < (last.sequence ?? 0) { + return false + } + last = envelope + } + return true +} + +private func workIncrementalSortTimeline(_ timeline: [WorkTimelineEntry]) -> [WorkTimelineEntry] { + timeline.sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { + return lhs.rank < rhs.rank + } + return lhs.timestamp < rhs.timestamp + } +} + +private func workIncrementalNextMessageRank(in timeline: [WorkTimelineEntry]) -> Int { + let messageCount = timeline.reduce(0) { count, entry in + if case .message = entry.payload { return count + 1 } + return count + } + return messageCount +} + +private func workIncrementalEchoCount(in timeline: [WorkTimelineEntry]) -> Int { + timeline.reduce(0) { count, entry in + entry.id.hasPrefix("echo-") ? count + 1 : count + } +} + +private func workIncrementalRemoveDuplicateEchoes(matching text: String, from timeline: inout [WorkTimelineEntry]) { + let normalized = normalizedWorkLocalEchoText(text) + guard !normalized.isEmpty else { return } + timeline.removeAll { entry in + guard entry.id.hasPrefix("echo-"), + case .message(let message) = entry.payload, + message.role == "user" + else { return false } + return normalizedWorkLocalEchoText(message.markdown) == normalized + } +} + +private func workIncrementalLatestTimestamp( + existing: String?, + envelopes: ArraySlice +) -> String? { + var latest = existing + for envelope in envelopes where !envelope.timestamp.isEmpty { + if latest.map({ envelope.timestamp > $0 }) ?? true { + latest = envelope.timestamp + } + } + return latest +} + +private func workIncrementalLatestTimestamp( + existing: String?, + localEchoMessages: ArraySlice +) -> String? { + var latest = existing + for echo in localEchoMessages where !echo.timestamp.isEmpty { + if latest.map({ echo.timestamp > $0 }) ?? true { + latest = echo.timestamp + } + } + return latest +} + +private func workIncrementalLatestAssistantId(_ timeline: [WorkTimelineEntry]) -> String? { + for entry in timeline.reversed() { + guard case .message(let message) = entry.payload else { continue } + if message.role == "assistant" { + return message.id + } + } + return nil +} + +private func workIncrementalEnvelopeKey(_ envelope: WorkChatEnvelope) -> String { + workChatEnvelopeMergeKey(envelope) +} + +private func workIncrementalSnapshotSignature( + base: Int, + transcript: [WorkChatEnvelope], + timeline: [WorkTimelineEntry] +) -> Int { + var hasher = Hasher() + hasher.combine("assistant-tail") + hasher.combine(base) + hasher.combine(transcript.count) + if let tail = transcript.last { + hasher.combine(workIncrementalEnvelopeKey(tail)) + hasher.combine(tail.timestamp) + hasher.combine(tail.sequence) + if case .assistantText(let text, let turnId, let itemId) = tail.event { + hasher.combine(text.utf8.count) + hasher.combine(text.hashValue) + hasher.combine(turnId) + hasher.combine(itemId) + } + } + if let latestAssistantId = workIncrementalLatestAssistantId(timeline) { + hasher.combine(latestAssistantId) + } + return hasher.finalize() +} + +private func workIncrementalFallbackSignature(_ fallbackEntries: [AgentChatTranscriptEntry], transcriptIsEmpty: Bool) -> Int { + guard transcriptIsEmpty else { return 0 } + var hasher = Hasher() + hasher.combine(fallbackEntries.count) + for entry in fallbackEntries { + hasher.combine(entry.id) + hasher.combine(entry.role) + hasher.combine(entry.timestamp) + hasher.combine(entry.text.utf8.count) + hasher.combine(entry.text.hashValue) + hasher.combine(entry.turnId) + hasher.combine(entry.messageId) + hasher.combine(entry.itemId) + } + return hasher.finalize() +} + +private func workIncrementalArtifactSignature(_ artifacts: [ComputerUseArtifactSummary]) -> Int { + var hasher = Hasher() + hasher.combine(artifacts.count) + for artifact in artifacts { + hasher.combine(artifact.id) + hasher.combine(artifact.artifactKind) + hasher.combine(artifact.title) + hasher.combine(artifact.uri) + hasher.combine(artifact.createdAt) + hasher.combine(artifact.reviewState) + hasher.combine(artifact.workflowState) + } + return hasher.finalize() +} + +private func workIncrementalLocalEchoSignature(_ localEchoMessages: [WorkLocalEchoMessage]) -> Int { + var hasher = Hasher() + hasher.combine(localEchoMessages.count) + for echo in localEchoMessages { + hasher.combine(echo.id) + hasher.combine(echo.text.utf8.count) + hasher.combine(echo.text.hashValue) + hasher.combine(echo.timestamp) + hasher.combine(echo.deliveryState) + } + return hasher.finalize() +} + extension WorkChatSessionView { + @MainActor + func prepareScrollStateForCurrentSessionIfNeeded(reason: String) { + guard scrollStateSessionId != session.id else { return } + resetScrollStateForCurrentSession(reason: reason) + } + + @MainActor + func resetScrollStateForCurrentSession(reason: String) { + scrollStateSessionId = session.id + visibleTimelineCount = workTimelinePageSize + isNearBottom = true + unreadBelowCount = 0 + lastTimelineTailId = nil + scrollMetrics = WorkChatScrollMetrics() + timelineDragActive = false + bottomStickinessReleasedByUser = false + pendingInitialBottomPinSessionId = session.id + cancelLatestPinTask() + timelineIncrementalCache.reset() + } + + @MainActor + func resolvePendingInitialBottomPinAfterLayout(_ proxy: ScrollViewProxy, reason: String) { + guard pendingInitialBottomPinSessionId == session.id else { return } + guard let tailId = timeline.last?.id, !tailId.isEmpty else { return } + guard visibleTimeline.contains(where: { $0.id == tailId }) else { + return + } + + pendingInitialBottomPinSessionId = nil + forcePinToLatestAfterLayout(proxy, reason: "initial-\(reason)") + } + @MainActor func scheduleTimelineSnapshotRebuild() { - timelineRebuildTask?.cancel() + resetTimelineSourceIfNeeded() + guard !clearTimelineSnapshotForEmptyInputsIfNeeded() else { return } + timelineRebuildGeneration += 1 - let generation = timelineRebuildGeneration - let transcriptSnapshot = transcript - let fallbackSnapshot = fallbackEntries - let artifactSnapshot = artifacts - let echoSnapshot = localEchoMessages - workChatScrollLog.notice( - "snapshot_rebuild_scheduled session=\(session.id, privacy: .public) generation=\(generation, privacy: .public) transcript=\(transcriptSnapshot.count, privacy: .public) fallback=\(fallbackSnapshot.count, privacy: .public) artifacts=\(artifactSnapshot.count, privacy: .public) localEcho=\(echoSnapshot.count, privacy: .public) currentTimeline=\(timelineSnapshot.timeline.count, privacy: .public) currentTail=\(timelineSnapshot.timeline.last?.id ?? "none", privacy: .public)" - ) + timelineRebuildPending = true + guard timelineRebuildTask == nil else { return } // .userInitiated, not .utility: this rebuild feeds the visible streaming // transcript, and utility-priority tasks get starved while SwiftUI is // busy — which showed up as multi-second delta-to-screen latency. // - // One-frame debounce: fold the multiple onChange triggers from a single - // refresh, but don't let the transcript visibly outrun the bottom lock. - timelineRebuildTask = Task.detached(priority: .userInitiated) { - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let nextSnapshot = buildWorkChatTimelineSnapshot( - transcript: transcriptSnapshot, - fallbackEntries: fallbackSnapshot, - artifacts: artifactSnapshot, - localEchoMessages: echoSnapshot - ) - await MainActor.run { - guard generation == timelineRebuildGeneration, !Task.isCancelled else { return } - let previousTimelineCount = timelineSnapshot.timeline.count - let previousTailId = timelineSnapshot.timeline.last?.id - let snapshotChanged = nextSnapshot != timelineSnapshot - workChatScrollLog.notice( - "snapshot_rebuild_applied session=\(session.id, privacy: .public) generation=\(generation, privacy: .public) changed=\(snapshotChanged, privacy: .public) oldTimeline=\(previousTimelineCount, privacy: .public) newTimeline=\(nextSnapshot.timeline.count, privacy: .public) oldTail=\(previousTailId ?? "none", privacy: .public) newTail=\(nextSnapshot.timeline.last?.id ?? "none", privacy: .public) transcript=\(transcriptSnapshot.count, privacy: .public) fallback=\(fallbackSnapshot.count, privacy: .public)" - ) - if nextSnapshot != timelineSnapshot { + // Coalesced worker: cancelling a detached task does not stop it once it is + // already inside the expensive transcript fold. Keep at most one fold + // running and loop only when a newer delta arrived while it was building. + timelineRebuildTask = Task { @MainActor in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(90)) + guard !Task.isCancelled else { break } + + timelineRebuildPending = false + let generation = timelineRebuildGeneration + let transcriptSnapshot = transcript + let fallbackSnapshot = fallbackEntries + let artifactSnapshot = artifacts + let echoSnapshot = localEchoMessages + let buildScope = timelineBuildScopeKey + + if applyIncrementalTimelineSnapshotIfPossible( + transcript: transcriptSnapshot, + fallbackEntries: fallbackSnapshot, + artifacts: artifactSnapshot, + localEchoMessages: echoSnapshot + ) { + if timelineRebuildPending { + continue + } + timelineRebuildTask = nil + break + } + + let requestId = await WorkTimelineSnapshotBuildCoordinator.shared.reserve(scope: buildScope) + guard let nextSnapshot = await WorkTimelineSnapshotBuildCoordinator.shared.build( + scope: buildScope, + requestId: requestId, + transcript: transcriptSnapshot, + fallbackEntries: fallbackSnapshot, + artifacts: artifactSnapshot, + localEchoMessages: echoSnapshot + ) else { continue } + + guard !Task.isCancelled else { break } + guard generation == timelineRebuildGeneration else { continue } + if nextSnapshot != timelineSnapshot || (timelineSnapshot.timeline.isEmpty && !nextSnapshot.timeline.isEmpty) { timelineSnapshot = nextSnapshot } + timelineIncrementalCache.record( + transcript: transcriptSnapshot, + fallbackEntries: fallbackSnapshot, + artifacts: artifactSnapshot, + localEchoMessages: echoSnapshot + ) refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) + if isNearBottom, !timelineDragActive { + timelineLayoutPinToken &+= 1 + } + + if timelineRebuildPending { + continue + } timelineRebuildTask = nil + break } } } + var timelineBuildScopeKey: String { + [ + timelineBuildScopeId, + session.id, + selectedSubagentTaskId ?? "main" + ].joined(separator: "|") + } + + var timelineInputRecoveryKey: String { + [ + session.id, + selectedSubagentTaskId ?? "main", + String(transcriptRenderSignature), + String(fallbackEntriesRenderSignature), + String(artifactsRenderSignature), + String(localEchoMessagesRenderSignature) + ].joined(separator: "|") + } + @MainActor - func cancelScheduledTimelineSnapshotRebuild() { - if timelineRebuildTask != nil { - workChatScrollLog.notice( - "snapshot_rebuild_cancelled session=\(session.id, privacy: .public) generation=\(timelineRebuildGeneration, privacy: .public) timeline=\(timelineSnapshot.timeline.count, privacy: .public) tail=\(timelineSnapshot.timeline.last?.id ?? "none", privacy: .public)" - ) + func recoverEmptyTimelineSnapshotIfNeeded() { + guard timelineSnapshot.timeline.isEmpty else { return } + guard !transcript.isEmpty || !fallbackEntries.isEmpty || !localEchoMessages.isEmpty || !artifacts.isEmpty else { + return } + + cancelScheduledTimelineSnapshotRebuild() + rebuildTimelineSnapshot() + } + + @MainActor + func cancelScheduledTimelineSnapshotRebuild() { timelineRebuildTask?.cancel() timelineRebuildTask = nil + timelineRebuildPending = false + cancelLatestPinTask() } @MainActor - func rebuildTimelineSnapshot() { - let nextSnapshot = buildWorkChatTimelineSnapshot( + func applyIncrementalTimelineSnapshotIfPossible( + transcript: [WorkChatEnvelope], + fallbackEntries: [AgentChatTranscriptEntry], + artifacts: [ComputerUseArtifactSummary], + localEchoMessages: [WorkLocalEchoMessage] + ) -> Bool { + let nextSnapshot = workSnapshotByApplyingLocalEchoTail( + to: timelineSnapshot, + cache: timelineIncrementalCache, + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) ?? workSnapshotByApplyingAssistantTextTail( + to: timelineSnapshot, + cache: timelineIncrementalCache, transcript: transcript, fallbackEntries: fallbackEntries, artifacts: artifacts, localEchoMessages: localEchoMessages ) - guard nextSnapshot != timelineSnapshot else { - workChatScrollLog.notice( - "snapshot_rebuild_sync_unchanged session=\(session.id, privacy: .public) timeline=\(timelineSnapshot.timeline.count, privacy: .public) tail=\(timelineSnapshot.timeline.last?.id ?? "none", privacy: .public)" - ) - return + guard let nextSnapshot else { + return false } - workChatScrollLog.notice( - "snapshot_rebuild_sync_applied session=\(session.id, privacy: .public) oldTimeline=\(timelineSnapshot.timeline.count, privacy: .public) newTimeline=\(nextSnapshot.timeline.count, privacy: .public) oldTail=\(timelineSnapshot.timeline.last?.id ?? "none", privacy: .public) newTail=\(nextSnapshot.timeline.last?.id ?? "none", privacy: .public)" + + timelineSnapshot = nextSnapshot + timelineIncrementalCache.record( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) + refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) + if isNearBottom, !timelineDragActive { + timelineLayoutPinToken &+= 1 + } + return true + } + + @MainActor + func rebuildTimelineSnapshot() { + resetTimelineSourceIfNeeded() + guard !clearTimelineSnapshotForEmptyInputsIfNeeded() else { return } + + let nextSnapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages ) + guard nextSnapshot != timelineSnapshot || (timelineSnapshot.timeline.isEmpty && !nextSnapshot.timeline.isEmpty) else { return } timelineSnapshot = nextSnapshot + timelineIncrementalCache.record( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) refreshTimelinePresentation(sourceTimeline: nextSnapshot.timeline) + if isNearBottom, !timelineDragActive { + timelineLayoutPinToken &+= 1 + } + } + + @MainActor + func resetTimelineSourceIfNeeded() { + let nextSourceKey = selectedSubagentTaskId ?? "main" + guard timelineSourceKey != nextSourceKey else { return } + + timelineSourceKey = nextSourceKey + visibleTimelineCount = workTimelinePageSize + isNearBottom = true + unreadBelowCount = 0 + lastTimelineTailId = nil + scrollMetrics = WorkChatScrollMetrics() + timelineDragActive = false + bottomStickinessReleasedByUser = false + pendingInitialBottomPinSessionId = session.id + cancelLatestPinTask() + timelineRebuildTask?.cancel() + timelineRebuildTask = nil + timelineRebuildPending = false + timelineIncrementalCache.reset() + timelineSnapshot = .empty + timelinePresentation = .empty + } + + @MainActor + func clearTimelineSnapshotForEmptyInputsIfNeeded() -> Bool { + guard transcript.isEmpty, + fallbackEntries.isEmpty, + localEchoMessages.isEmpty, + artifacts.isEmpty + else { + return false + } + + timelineRebuildTask?.cancel() + timelineRebuildTask = nil + timelineRebuildPending = false + timelineIncrementalCache.record( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) + + let alreadyEmpty = timelineSnapshot == .empty && timelinePresentation == .empty + if !alreadyEmpty { + timelineSnapshot = .empty + timelinePresentation = .empty + timelineLayoutPinToken &+= 1 + } + return true } @MainActor @@ -91,16 +788,10 @@ extension WorkChatSessionView { @MainActor func loadEarlierTimelineEntries() { - workChatScrollLog.notice( - "load_earlier_tapped session=\(session.id, privacy: .public) beforeLimit=\(visibleTimelineCount, privacy: .public) hidden=\(hiddenTimelineCount, privacy: .public) hasOlderHost=\(hasOlderTranscriptHistory, privacy: .public) timeline=\(timeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public)" - ) withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { visibleTimelineCount += workTimelinePageSize refreshTimelinePresentation() } - workChatScrollLog.notice( - "load_earlier_applied session=\(session.id, privacy: .public) afterLimit=\(visibleTimelineCount, privacy: .public) hidden=\(hiddenTimelineCount, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) firstVisible=\(visibleTimeline.first?.id ?? "none", privacy: .public) lastVisible=\(visibleTimeline.last?.id ?? "none", privacy: .public)" - ) // Once the locally-buffered timeline is nearly exhausted, pull the next // older transcript page from the host so scroll-back continues through // the full history instead of stopping at the initial tail fetch. @@ -112,19 +803,29 @@ extension WorkChatSessionView { } @MainActor - func updateBottomStickiness(distanceFromBottom rawDistance: CGFloat, proxy: ScrollViewProxy) { + func updateBottomStickiness(distanceFromBottom rawDistance: CGFloat, proxy _: ScrollViewProxy) { let distance = max(0, rawDistance) - let previousDistance = lastScrollDistanceFromBottom - lastScrollDistanceFromBottom = distance + scrollMetrics.distanceFromBottom = distance + + if bottomStickinessReleasedByUser { + guard distance <= workChatStickResumeThreshold, !timelineDragActive else { return } + bottomStickinessReleasedByUser = false + if !isNearBottom { + isNearBottom = true + } + if unreadBelowCount > 0 { + withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + unreadBelowCount = 0 + } + } + return + } let nextIsNearBottom = isNearBottom ? !timelineDragActive : (!timelineDragActive && distance <= workChatStickResumeThreshold) if nextIsNearBottom != isNearBottom { - workChatScrollLog.notice( - "bottom_lock_changed session=\(session.id, privacy: .public) locked=\(nextIsNearBottom, privacy: .public) distance=\(distance, privacy: .public) previousDistance=\(previousDistance, privacy: .public) dragActive=\(timelineDragActive, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) isNearBottom = nextIsNearBottom } @@ -135,33 +836,28 @@ extension WorkChatSessionView { unreadBelowCount = 0 } } - - // Desktop follows content-size changes, not just new row counts. The - // bottom geometry distance changes when a streaming row grows, so keep - // pinned users at the true bottom even when the timeline tail id is stable. - if distance > 1 { - workChatScrollLog.notice( - "bottom_lock_autoscroll session=\(session.id, privacy: .public) distance=\(distance, privacy: .public) previousDistance=\(previousDistance, privacy: .public) timeline=\(timeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public)" - ) - pinToLatestAfterLayout(proxy, reason: "bottom-geometry") - } } @MainActor func releaseBottomStickinessForUserScroll(reason: String) { + if pendingInitialBottomPinSessionId == session.id { + pendingInitialBottomPinSessionId = nil + } guard isNearBottom else { return } - workChatScrollLog.notice( - "bottom_lock_released session=\(session.id, privacy: .public) reason=\(reason, privacy: .public) distance=\(lastScrollDistanceFromBottom, privacy: .public) timeline=\(timeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public)" - ) + bottomStickinessReleasedByUser = true isNearBottom = false } + @MainActor + func allowBottomStickinessResumeFromUserScroll(reason: String) { + guard bottomStickinessReleasedByUser else { return } + bottomStickinessReleasedByUser = false + } + @MainActor func scrollToLatest(_ proxy: ScrollViewProxy, animated: Bool) { - let targetId = "chat-end" - workChatScrollLog.notice( - "scroll_to_latest session=\(session.id, privacy: .public) target=\(targetId, privacy: .public) animated=\(animated, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) nearBottom=\(isNearBottom, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) + bottomStickinessReleasedByUser = false + let targetId = latestScrollTargetId if animated { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { proxy.scrollTo(targetId, anchor: .bottom) @@ -179,17 +875,57 @@ extension WorkChatSessionView { @MainActor func pinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { guard isNearBottom, !timelineDragActive else { return } - workChatScrollLog.notice( - "pin_latest_after_layout session=\(session.id, privacy: .public) reason=\(reason, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) distance=\(lastScrollDistanceFromBottom, privacy: .public)" - ) - scrollToLatest(proxy, animated: false) - Task { @MainActor in + latestPinGeneration &+= 1 + let generation = latestPinGeneration + latestPinTask?.cancel() + latestPinTask = Task { @MainActor in + guard generation == latestPinGeneration, isNearBottom, !timelineDragActive else { return } + scrollToLatest(proxy, animated: false) try? await Task.sleep(for: .milliseconds(16)) - guard isNearBottom, !timelineDragActive else { return } + guard !Task.isCancelled, + generation == latestPinGeneration, + isNearBottom, + !timelineDragActive else { return } + scrollToLatest(proxy, animated: false) + if generation == latestPinGeneration { + latestPinTask = nil + } + } + } + + @MainActor + func forcePinToLatestAfterLayout(_ proxy: ScrollViewProxy, reason: String) { + isNearBottom = true + if unreadBelowCount > 0 { + unreadBelowCount = 0 + } + latestPinGeneration &+= 1 + let generation = latestPinGeneration + latestPinTask?.cancel() + latestPinTask = Task { @MainActor in + guard generation == latestPinGeneration, isNearBottom, !timelineDragActive else { return } scrollToLatest(proxy, animated: false) + for delay in [16, 80, 180, 320] { + try? await Task.sleep(for: .milliseconds(delay)) + guard !Task.isCancelled, + generation == latestPinGeneration, + isNearBottom, + !timelineDragActive else { return } + scrollToLatest(proxy, animated: false) + } + if generation == latestPinGeneration { + latestPinTask = nil + } } } + @MainActor + func cancelLatestPinTask() { + latestPinGeneration &+= 1 + latestPinTask?.cancel() + latestPinTask = nil + } + @MainActor func runSessionAction(_ action: @escaping @MainActor () async -> Void) async { actionInFlight = true diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index fda09c995..f23b0a1ad 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -10,9 +10,69 @@ extension WorkChatSessionView { /// keeps the whole-text block cache path. var streamingAssistantMessageId: String? { guard shouldShowInterruptControl else { return nil } + return timelineSnapshot.latestMessageAssistantId + } + + @ViewBuilder + func timelineRenderEntryView( + for entry: WorkTimelineRenderEntry, + proxy: ScrollViewProxy, + streamingAssistantMessageId: String?, + maxUserBubbleWidth: CGFloat? + ) -> some View { + switch entry.payload { + case .entry(let timelineEntry): + timelineEntryView( + for: timelineEntry, + proxy: proxy, + streamingAssistantMessageId: streamingAssistantMessageId, + maxUserBubbleWidth: maxUserBubbleWidth + ) + case .assistantMarkdownBlock(let model): + WorkAssistantMarkdownBlockRow( + model: model, + onCopyMessage: { copyAssistantMarkdown(messageId: model.messageId) } + ) + .equatable() + case .assistantMonospaced(let model): + WorkAssistantMonospacedRow( + model: model, + onCopyMessage: { copyAssistantMarkdown(messageId: model.messageId) } + ) + .equatable() + case .assistantControls(let model): + WorkAssistantMessageControlsView( + controls: model, + onCopyMessage: { copyAssistantMarkdown(messageId: model.messageId) }, + onShowMore: { + assistantLineBudgets[model.messageId] = model.nextLineBudget + refreshTimelinePresentation() + if isNearBottom { + pinToLatestAfterLayout(proxy, reason: "assistant-show-more") + } + } + ) + .equatable() + } + } + + func copyAssistantMarkdown(messageId: String) { + guard let markdown = assistantMarkdown(messageId: messageId) else { return } + UIPasteboard.general.string = markdown + } + + private func assistantMarkdown(messageId: String) -> String? { + for entry in visibleTimeline.reversed() { + if case .message(let message) = entry.payload, + message.id == messageId { + return message.markdown + } + } for entry in timelineSnapshot.timeline.reversed() { - guard case .message(let message) = entry.payload else { continue } - return message.role == "assistant" ? message.id : nil + if case .message(let message) = entry.payload, + message.id == messageId { + return message.markdown + } } return nil } @@ -38,8 +98,8 @@ extension WorkChatSessionView { case .usageSummary(let summary): WorkTurnUsageSummaryBanner( summary: summary, - provider: chatSummary?.provider, - modelLabel: chatSummary.map { prettyWorkChatModelName($0.model) } + provider: chatSummaryContext.provider, + modelLabel: chatSummaryContext.modelLabel ) case .commandCard(let commandCard): WorkCommandCardView(card: commandCard) @@ -95,7 +155,7 @@ extension WorkChatSessionView { } } }, - fallbackProvider: chatSummary?.provider + fallbackProvider: chatSummaryContext.provider ) .id("pending-question-\(question.id)") case .pendingPermission(let permission): @@ -108,25 +168,8 @@ extension WorkChatSessionView { } } ) - case .pendingPlanApproval(let plan): - WorkPlanReviewCard( - plan: plan, - busy: actionInFlight || !isLive, - onDecision: { decision, feedback in - await runSessionAction { - // Approve: send "accept" decision directly. - // Reject: send "decline"; if the user typed feedback, also - // queue it as a follow-up steer message so the agent sees the - // revision notes in the next turn. - await onApproveRequest(plan.id, decision) - if decision == .decline, let feedback, !feedback.isEmpty { - _ = await onSend(feedback) - } - } - }, - fallbackProvider: chatSummary?.provider - ) - .id("pending-question-\(plan.id)") + case .pendingPlanApproval: + EmptyView() case .pendingModelSelection(let request): WorkModelSelectionPendingCard( request: request, @@ -222,3 +265,100 @@ extension WorkChatSessionView { ) } } + +struct WorkAssistantMarkdownBlockRow: View, Equatable { + let model: WorkAssistantMarkdownBlockRenderModel + let onCopyMessage: () -> Void + + static func == (lhs: WorkAssistantMarkdownBlockRow, rhs: WorkAssistantMarkdownBlockRow) -> Bool { + lhs.model == rhs.model + } + + var body: some View { + WorkMarkdownBlockView(block: model.block) + .frame(maxWidth: .infinity, alignment: .leading) + .contextMenu { + Button(action: onCopyMessage) { + Label("Copy message", systemImage: "doc.on.doc") + } + } + .accessibilityElement(children: .contain) + .adeInspectable( + "Work.Chat.MessageBubble.Assistant.Block", + metadata: [ + "messageId": model.messageId, + "turnId": model.turnId ?? "", + "itemId": model.itemId ?? "", + "blockId": model.block.id + ] + ) + } +} + +struct WorkAssistantMonospacedRow: View, Equatable { + let model: WorkAssistantMonospacedRenderModel + let onCopyMessage: () -> Void + + static func == (lhs: WorkAssistantMonospacedRow, rhs: WorkAssistantMonospacedRow) -> Bool { + lhs.model == rhs.model + } + + var body: some View { + WorkAssistantMonospacedPreview(text: model.text) + .accessibilityLabel(model.accessibilityLabel) + .contextMenu { + Button(action: onCopyMessage) { + Label("Copy message", systemImage: "doc.on.doc") + } + } + .adeInspectable( + "Work.Chat.MessageBubble.Assistant.Monospace", + metadata: [ + "messageId": model.messageId, + "turnId": model.turnId ?? "", + "itemId": model.itemId ?? "" + ] + ) + } +} + +struct WorkAssistantMessageControlsView: View, Equatable { + let controls: WorkAssistantMessageControlsModel + let onCopyMessage: () -> Void + let onShowMore: () -> Void + + static func == (lhs: WorkAssistantMessageControlsView, rhs: WorkAssistantMessageControlsView) -> Bool { + lhs.controls == rhs.controls + } + + var body: some View { + HStack(spacing: 12) { + Text(controls.summaryText) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + + Spacer(minLength: 0) + + Button(action: onCopyMessage) { + Label("Copy full", systemImage: "doc.on.doc") + .labelStyle(.titleAndIcon) + .font(.caption2.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(ADEColor.textSecondary) + + if controls.canShowMore { + Button(action: onShowMore) { + Label("Show more", systemImage: "chevron.down") + .labelStyle(.titleAndIcon) + .font(.caption2.weight(.semibold)) + } + .buttonStyle(.plain) + .foregroundStyle(ADEColor.accent) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .contain) + .accessibilityLabel("Assistant response preview. \(controls.visibleLineCount) of \(controls.totalLineCount) lines shown.") + } +} diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 781d6c836..6e0c46af9 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -1,25 +1,126 @@ import SwiftUI import UIKit import AVKit -import OSLog -let workChatScrollLog = Logger(subsystem: "com.ade.ios", category: "WorkChatScroll") let workChatScrollCoordinateSpace = "WorkChatScrollCoordinateSpace" let workChatStickThreshold: CGFloat = 160 -let workChatStickResumeThreshold: CGFloat = 24 +let workChatStickResumeThreshold: CGFloat = 48 let workChatTouchScrollDeadband: CGFloat = 2 +let workChatBottomAnchorSpacerHeight: CGFloat = 1 +let workChatContentBottomGutterHeight: CGFloat = 2 +let workChatSubagentActivePopupHeight: CGFloat = 34 + +final class WorkChatScrollMetrics { + var distanceFromBottom: CGFloat = 0 +} + +struct WorkChatSummaryRenderContext: Equatable { + let isAvailable: Bool + let provider: String + let model: String + let modelId: String? + let reasoningEffort: String + let effectiveFastMode: Bool + let runtimeMode: String + let fastModeSupported: Bool + let idleSinceAt: String? + let endedAt: String? + let lastOutputPreview: String? + let requestedCwd: String? + let modelLabel: String + let contextWindowFallback: Int? + + init(_ summary: AgentChatSessionSummary?) { + guard let summary else { + self.isAvailable = false + self.provider = "" + self.model = "" + self.modelId = nil + self.reasoningEffort = "" + self.effectiveFastMode = false + self.runtimeMode = "" + self.fastModeSupported = false + self.idleSinceAt = nil + self.endedAt = nil + self.lastOutputPreview = nil + self.requestedCwd = nil + self.modelLabel = "Model" + self.contextWindowFallback = nil + return + } + + self.isAvailable = true + self.provider = summary.provider + self.model = summary.model + self.modelId = summary.modelId + self.reasoningEffort = summary.reasoningEffort ?? "" + self.effectiveFastMode = summary.effectiveFastMode + self.runtimeMode = workInitialRuntimeMode(summary) + self.fastModeSupported = workChatComposerSupportsFastMode(summary) + self.idleSinceAt = summary.idleSinceAt + self.endedAt = summary.endedAt + self.lastOutputPreview = summary.lastOutputPreview + self.requestedCwd = summary.requestedCwd + self.modelLabel = prettyWorkChatModelName(summary.model) + self.contextWindowFallback = workContextWindowFallback(modelId: summary.modelId, model: summary.model) + } + + var currentModelId: String { + modelId ?? model + } +} + +struct WorkChatSessionRenderContext: Equatable { + let id: String + let laneId: String + let chatIdleSinceAt: String? + let endedAt: String? + let lastOutputPreview: String? + let normalizedStatus: String + + init(_ session: TerminalSessionSummary) { + self.id = session.id + self.laneId = session.laneId + self.chatIdleSinceAt = session.chatIdleSinceAt + self.endedAt = session.endedAt + self.lastOutputPreview = session.lastOutputPreview + self.normalizedStatus = normalizedWorkChatSessionStatus(session: session, summary: nil) + } +} + +private struct WorkChatSummaryTimelineKey: Equatable { + let provider: String + let model: String + let modelId: String? + + init(_ context: WorkChatSummaryRenderContext) { + self.provider = context.provider + self.model = context.model + self.modelId = context.modelId + } +} struct WorkChatSessionView: View { @Environment(\.accessibilityReduceMotion) var reduceMotion - @EnvironmentObject private var syncService: SyncService - let session: TerminalSessionSummary - let chatSummary: AgentChatSessionSummary? + let session: WorkChatSessionRenderContext + let chatSummaryContext: WorkChatSummaryRenderContext let transcript: [WorkChatEnvelope] + let transcriptRenderSignature: Int let fallbackEntries: [AgentChatTranscriptEntry] + let fallbackEntriesRenderSignature: Int let artifacts: [ComputerUseArtifactSummary] + let artifactsRenderSignature: Int let optimisticPendingSteers: [WorkPendingSteerModel] + let optimisticPendingSteersRenderSignature: Int let localEchoMessages: [WorkLocalEchoMessage] + let localEchoMessagesRenderSignature: Int + let expandedToolCardIdsSnapshot: Set + let expandedToolCardIdsRenderSignature: Int + let artifactContentRenderSignature: Int + let artifactDrawerPresentedSnapshot: Bool + let sendingSnapshot: Bool + let errorMessageSnapshot: String? @Binding var expandedToolCardIds: Set @Binding var artifactContent: [String: WorkLoadedArtifactContent] @Binding var fullscreenImage: WorkFullscreenImage? @@ -35,25 +136,40 @@ struct WorkChatSessionView: View { @State var lastTimelineTailId: String? @State var scrollViewportHeight: CGFloat = 0 @State var scrollViewportWidth: CGFloat = 0 - @State var lastScrollDistanceFromBottom: CGFloat = 0 + @State var composerLayoutHeight: CGFloat = 150 + @State var scrollMetrics = WorkChatScrollMetrics() @State var timelineDragActive = false + @State var bottomStickinessReleasedByUser = false @State var timelineSnapshot = WorkChatTimelineSnapshot.empty @State var timelinePresentation = WorkTimelinePresentation.empty + @State var timelineIncrementalCache = WorkTimelineIncrementalCache() + @State var timelineSourceKey: String? @State var timelineRebuildTask: Task? + @State var timelineRebuildPending = false @State var timelineRebuildGeneration = 0 + @State var timelineBuildScopeId = UUID().uuidString + @State var latestPinTask: Task? + @State var latestPinGeneration = 0 @State var assistantPreviewCache = WorkAssistantPreviewCache() + @State var assistantLineBudgets: [String: Int] = [:] @State var composerSettingMutationInFlight = false @State var composerSettingMutationGeneration = 0 @State var pendingCodexFastMode: Bool? + @State var scrollStateSessionId: String? + @State var pendingInitialBottomPinSessionId: String? + @State var timelineLayoutPinToken = 0 let isLive: Bool + let hostUnreachable: Bool let canComposeMessages: Bool let canSendMessages: Bool let sendWillQueue: Bool + let sendWillQueueIsReconnect: Bool + var inputLockMessage: String? = nil let transitionNamespace: Namespace.ID? let onOpenLane: (() -> Void)? let onSend: @MainActor (String) async -> Bool let onInterrupt: @MainActor () async -> Void - let onApproveRequest: @MainActor (String, AgentChatApprovalDecision) async -> Void + let onApproveRequest: @MainActor (String, AgentChatApprovalDecision, String?) async -> Void let onRespondToQuestion: @MainActor (String, String, AgentChatInputAnswerValue?, String?) async -> Void let onSubmitQuestionAnswers: @MainActor (String, [String: AgentChatInputAnswerValue], String?) async -> Void let onDeclineQuestion: @MainActor (String) async -> Void @@ -72,16 +188,22 @@ struct WorkChatSessionView: View { let onSelectEffort: @MainActor (String) async -> Void let onSelectCodexFastMode: @MainActor (Bool) async -> Bool + var resolvedSessionStatus: String? = nil var lanes: [LaneSummary] = [] + var lanesRenderSignature: Int = 0 // Host-side scroll-back: true while older transcript pages remain on the // host beyond what the phone has fetched; the callback pulls the next page. var hasOlderTranscriptHistory: Bool = false var onLoadOlderTranscript: (@MainActor () async -> Void)? = nil + var subagentSnapshots: [WorkSubagentSnapshot] = [] + var subagentSnapshotsRenderSignature: Int = 0 + var selectedSubagentTaskId: String? = nil + var onOpenSubagents: (() -> Void)? = nil /// Live "turn is running" signal from the sync layer (chat_subscribe ack + /// live status/done events). Covers the gap where the synced session row /// still says idle while chat events are already streaming — without it /// the chat renders output with no stop button or working indicator. - var liveTurnActiveHint: Bool = false + var liveTurnActiveHint: Bool? = nil @State var steerEditDrafts: [String: String] = [:] @State var modelPickerPresented = false @@ -94,14 +216,18 @@ struct WorkChatSessionView: View { @State var lastBlockingPendingInputId: String? var sessionStatus: String { - normalizedWorkChatSessionStatus(session: session, summary: chatSummary) + resolvedSessionStatus ?? session.normalizedStatus + } + + private var chatSummaryTimelineKey: WorkChatSummaryTimelineKey { + WorkChatSummaryTimelineKey(chatSummaryContext) } /// Terminal transcript signal from the local event window. When present, it /// beats stale session rows / subscribe hints that can lag a just-finished /// turn by a few seconds. var transcriptLatestTurnEnded: Bool { - workTranscriptLatestTurnEnded(transcript) + timelineSnapshot.transcriptLatestTurnEnded } /// The live turn hint can be stale if mobile misses the final `done` event. @@ -110,18 +236,25 @@ struct WorkChatSessionView: View { var sessionRowEndedAfterLatestTranscript: Bool { guard sessionStatus == "idle" || sessionStatus == "ended" else { return false } let rowEndedAt = [ - chatSummary?.idleSinceAt, - chatSummary?.endedAt, + chatSummaryContext.idleSinceAt, + chatSummaryContext.endedAt, session.chatIdleSinceAt, session.endedAt ] - .compactMap(workParsedDate) + .compactMap { value in + value?.isEmpty == false ? value : nil + } .max() guard let rowEndedAt else { return false } - guard let latestTranscriptAt = transcript.compactMap({ workParsedDate($0.timestamp) }).max() else { + guard let latestTranscriptAt = timelineSnapshot.latestTranscriptTimestamp else { return false } + if rowEndedAt >= latestTranscriptAt { + return true + } + guard let rowEndedDate = workParsedDate(rowEndedAt), + let latestTranscriptDate = workParsedDate(latestTranscriptAt) else { return false } - return rowEndedAt >= latestTranscriptAt.addingTimeInterval(-0.25) + return rowEndedDate >= latestTranscriptDate.addingTimeInterval(-0.25) } /// Single source of truth for "the assistant is generating right now". @@ -139,7 +272,7 @@ struct WorkChatSessionView: View { } var shouldShowInterruptControl: Bool { - workChatShouldShowInterruptControl(isStreamingTurn: isStreamingTurn, transcript: transcript) + isStreamingTurn && timelineSnapshot.transcriptHasInterruptibleActivity } var pendingInputs: [WorkPendingInputItem] { @@ -157,10 +290,23 @@ struct WorkChatSessionView: View { pendingInputs.first } + var pendingPlanApproval: WorkPendingPlanApprovalModel? { + pendingInputs.compactMap { item -> WorkPendingPlanApprovalModel? in + if case .planApproval(let model) = item { + return model + } + return nil + }.first + } + var hasPendingInputGate: Bool { workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) } + var composerPlaceholderText: String { + workChatComposerPlaceholder(pendingInputs: pendingInputs, sessionStatus: sessionStatus) + } + /// Stable id of the first blocking pending input awaiting a reply, or nil when /// none is open. Drives the arrival haptic + elevate-into-view so a blocking /// question/plan gate can't be silently missed. Only fires when the session is @@ -170,14 +316,12 @@ struct WorkChatSessionView: View { return primaryPendingInput?.id } - /// Scroll anchor for the first blocking inline pending card. Questions and - /// plan approvals render inline under "pending-question-"; approval gates - /// render in the top overview section, so we just pin to the top for those. + /// Scroll anchor for the first blocking inline pending card. Plan approvals + /// live in the composer strip, so they stay visible without transcript scroll. private var blockingPendingScrollAnchor: String? { switch primaryPendingInput { case .question(let model): return "pending-question-\(model.id)" - case .planApproval(let model): return "pending-question-\(model.id)" - case .permission, .modelSelection, .approval, .none: return nil + case .permission, .modelSelection, .approval, .planApproval, .none: return nil } } @@ -210,7 +354,7 @@ struct WorkChatSessionView: View { } private var awaitingPromptPreview: String? { - [chatSummary?.lastOutputPreview, session.lastOutputPreview] + [chatSummaryContext.lastOutputPreview, session.lastOutputPreview] .compactMap { value -> String? in guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) @@ -258,17 +402,21 @@ struct WorkChatSessionView: View { timelineSnapshot.timeline } - /// Timeline with synthetic turn-separator pills inserted before each new - /// user-message turn. Cached alongside the visible slice so focus and - /// keyboard layout changes do not rebuild transcript arrays. - var timelineWithSeparators: [WorkTimelineEntry] { - timelinePresentation.entries - } - var visibleTimeline: [WorkTimelineEntry] { timelinePresentation.visibleEntries } + var visibleTimelineRenderEntries: [WorkTimelineRenderEntry] { + timelinePresentation.renderEntries + } + + var latestScrollTargetId: String { + if isStreamingTurn { + return "chat-streaming-status" + } + return visibleTimelineRenderEntries.last?.id ?? "chat-end" + } + var hiddenTimelineCount: Int { timelinePresentation.hiddenCount } @@ -279,27 +427,34 @@ struct WorkChatSessionView: View { var nextPresentation = makeWorkTimelinePresentation( timeline: timeline, visibleCount: visibleTimelineCount, - chatSummary: chatSummary, + chatSummary: chatSummaryContext, transcript: transcript, - assistantPreviewCache: assistantPreviewCache + assistantPreviewCache: assistantPreviewCache, + assistantLineBudgets: assistantLineBudgets, + streamingAssistantMessageId: streamingAssistantMessageId + ) + let timelineDelta = nextPresentation.timelineCount - timelinePresentation.timelineCount + let prependedHistory = ( + timelineDelta > 0 + && timelinePresentation.timelineFirstId != nil + && timelinePresentation.timelineLastId != nil + && timelinePresentation.timelineLastId == nextPresentation.timelineLastId + && timelinePresentation.timelineFirstId != nextPresentation.timelineFirstId ) - if isNearBottom, - timelinePresentation.hiddenCount > 0, - nextPresentation.entries.count > timelinePresentation.entries.count { - visibleTimelineCount += nextPresentation.entries.count - timelinePresentation.entries.count + if prependedHistory { + visibleTimelineCount += timelineDelta nextPresentation = makeWorkTimelinePresentation( timeline: timeline, visibleCount: visibleTimelineCount, - chatSummary: chatSummary, + chatSummary: chatSummaryContext, transcript: transcript, - assistantPreviewCache: assistantPreviewCache + assistantPreviewCache: assistantPreviewCache, + assistantLineBudgets: assistantLineBudgets, + streamingAssistantMessageId: streamingAssistantMessageId ) } guard nextPresentation != timelinePresentation else { return } timelinePresentation = nextPresentation - workChatScrollLog.notice( - "presentation_update session=\(session.id, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(nextPresentation.visibleEntries.count, privacy: .public) hidden=\(nextPresentation.hiddenCount, privacy: .public) firstVisible=\(nextPresentation.visibleEntries.first?.id ?? "none", privacy: .public) lastVisible=\(nextPresentation.visibleEntries.last?.id ?? "none", privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) limit=\(visibleTimelineCount, privacy: .public) nearBottom=\(isNearBottom, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) } var canCompose: Bool { @@ -317,18 +472,21 @@ struct WorkChatSessionView: View { } var composerFeedback: String? { + if let inputLockMessage { + return inputLockMessage + } if sending && !sendWillQueue { return "Sending message to machine..." } - if sendWillQueue, sessionStatus == "active" { - return "Message will stage behind the active turn." - } - if sendWillQueue, pendingSteers.isEmpty { + if sendWillQueueIsReconnect, pendingSteers.isEmpty { return "Machine is reconnecting. Send will queue until it is back." } 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.isEmpty { return "Answer the waiting prompt above, or decline it before sending another message." } @@ -339,12 +497,7 @@ struct WorkChatSessionView: View { } var jumpToLatestPillBottomPadding: CGFloat { - // The pill is an overlay, so it needs to sit above the safe-area composer - // instead of covering the Send/Stop control. Staged steers add a second - // composer band, so give the pill extra air when that strip is present. - if !pendingSteers.isEmpty { return 220 } - if !pendingInputs.isEmpty { return 150 } - return 116 + 16 } var maxUserBubbleWidth: CGFloat? { @@ -371,7 +524,7 @@ struct WorkChatSessionView: View { busy: actionInFlight, onDecision: { decision in await runSessionAction { - await onApproveRequest(approval.id, decision) + await onApproveRequest(approval.id, decision, nil) } } ) @@ -392,7 +545,7 @@ struct WorkChatSessionView: View { // Connection-caused failures are communicated via the top-right gear, but // cached/offline chat actions still need their own visible errors. - if let errorMessage, !syncService.connectionState.isHostUnreachable { + if let errorMessage, !hostUnreachable { ADENoticeCard( title: "Chat error", message: errorMessage, @@ -409,8 +562,10 @@ struct WorkChatSessionView: View { if timeline.isEmpty { ADEEmptyStateView( symbol: "bubble.left.and.bubble.right", - title: "No chat messages yet", - message: isLive ? "Send a message to start streaming the transcript." : "Reconnect to load the latest chat history from the machine." + title: selectedSubagentTaskId == nil ? "No chat messages yet" : "No subagent transcript", + message: selectedSubagentTaskId == nil + ? (isLive ? "Send a message to start streaming the transcript." : "Reconnect to load the latest chat history from the machine.") + : "This subagent did not publish detailed transcript output." ) } else { if hiddenTimelineCount > 0 || hasOlderTranscriptHistory { @@ -438,8 +593,8 @@ struct WorkChatSessionView: View { let streamingMessageId = streamingAssistantMessageId let userBubbleWidth = maxUserBubbleWidth - ForEach(visibleTimeline) { entry in - timelineEntryView( + ForEach(visibleTimelineRenderEntries) { entry in + timelineRenderEntryView( for: entry, proxy: proxy, streamingAssistantMessageId: streamingMessageId, @@ -449,11 +604,16 @@ struct WorkChatSessionView: View { } } + @ViewBuilder var streamingStatusSection: some View { - WorkActivityIndicator( - transcript: transcript, - isStreaming: isStreamingTurn - ) + if isStreamingTurn { + WorkActivityIndicator( + transcript: transcript, + isStreaming: true + ) + .id("chat-streaming-status") + .frame(maxWidth: .infinity, minHeight: 30, alignment: .leading) + } } /// Single desktop-shaped composer card: text field on top, chip strip and @@ -464,6 +624,14 @@ struct WorkChatSessionView: View { // The redundant ENDED/RUNNING status pill row has been retired. Chat // lifecycle controls live outside the composer; this space is reserved // for pending input and send feedback. + let runningSubagentCount = workSubagentRunningCount(subagentSnapshots) + if inputLockMessage == nil, + runningSubagentCount > 0, + let onOpenSubagents { + WorkSubagentActivePopup(count: runningSubagentCount, onOpen: onOpenSubagents) + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: workChatSubagentActivePopupHeight, alignment: .leading) + } if !pendingSteers.isEmpty { WorkQueuedSteerStrip( @@ -523,12 +691,31 @@ struct WorkChatSessionView: View { ) } + if let planApproval = pendingPlanApproval { + WorkPlanComposerStrip( + plan: planApproval, + busy: actionInFlight || !isLive, + fallbackProvider: chatSummaryContext.provider, + onDecision: { decision, feedback in + await runSessionAction { + await onApproveRequest(planApproval.id, decision, feedback) + } + } + ) + .id(planApproval.id) + } + WorkChatComposerCard( - chatSummary: chatSummary, - usageViewModel: workContextUsageViewModel(transcript: transcript, summary: chatSummary), + chatSummary: chatSummaryContext, + usageViewModel: workContextUsageViewModel( + transcript: transcript, + provider: chatSummaryContext.provider, + fallbackContextWindow: chatSummaryContext.contextWindowFallback + ), dictationTargetId: "work-chat:\(session.id)", pendingInputCount: pendingInputs.count, awaitingInputGate: hasPendingInputGate, + composerPlaceholder: composerPlaceholderText, canCompose: canCompose, canSend: canSend && !composerSettingMutationInFlight, sending: sending && !sendWillQueue, @@ -542,13 +729,13 @@ struct WorkChatSessionView: View { onInterrupt: { await runSessionAction(onInterrupt) }, - onOpenModelPicker: chatSummary == nil ? nil : { modelPickerPresented = true }, - onSelectRuntimeMode: chatSummary == nil ? nil : { mode in + onOpenModelPicker: !chatSummaryContext.isAvailable ? nil : { modelPickerPresented = true }, + onSelectRuntimeMode: !chatSummaryContext.isAvailable ? nil : { mode in runComposerSettingMutation { await onSelectRuntimeMode(mode) } }, - onToggleCodexFastMode: chatSummary == nil ? nil : { enabled in + onToggleCodexFastMode: !chatSummaryContext.isAvailable ? nil : { enabled in pendingCodexFastMode = enabled runComposerSettingMutation(onFailure: { pendingCodexFastMode = nil @@ -563,258 +750,442 @@ struct WorkChatSessionView: View { ) } .padding(.horizontal, 16) - .padding(.top, 8) + .padding(.top, 4) .padding(.bottom, 0) } var body: some View { ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - sessionOverviewSection - if !timelineSnapshot.subagentSnapshots.isEmpty { - WorkSubagentStrip(snapshots: timelineSnapshot.subagentSnapshots) - } - timelineSection(proxy: proxy) - streamingStatusSection - - Color.clear - .frame(height: 1) - .id("chat-end") - .background( - GeometryReader { geometry in - Color.clear.preference( - key: WorkChatContentBottomPreferenceKey.self, - value: geometry.frame(in: .named(workChatScrollCoordinateSpace)).maxY - ) + VStack(spacing: 0) { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + sessionOverviewSection + timelineSection(proxy: proxy) + streamingStatusSection + + Color.clear + .frame(height: workChatContentBottomGutterHeight + workChatBottomAnchorSpacerHeight) + .id("chat-end") + .transaction { transaction in + transaction.animation = nil } - ) - .onAppear { - workChatScrollLog.notice( - "bottom_sentinel_appeared session=\(session.id, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) lastVisible=\(visibleTimeline.last?.id ?? "none", privacy: .public) unread=\(unreadBelowCount, privacy: .public) distance=\(lastScrollDistanceFromBottom, privacy: .public)" + .background( + GeometryReader { geometry in + Color.clear.preference( + key: WorkChatContentBottomPreferenceKey.self, + value: geometry.frame(in: .named(workChatScrollCoordinateSpace)).maxY + ) + } ) - } - .onDisappear { - workChatScrollLog.notice( - "bottom_sentinel_disappeared session=\(session.id, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) lastVisible=\(visibleTimeline.last?.id ?? "none", privacy: .public) unread=\(unreadBelowCount, privacy: .public) distance=\(lastScrollDistanceFromBottom, privacy: .public)" - ) - } - } - .padding(16) - .modifier( - WorkChatTranscriptEnvironmentModifier( - provider: chatSummary?.provider, - modelId: chatSummary?.modelId ?? chatSummary?.model, - modelLabel: chatSummary.map { prettyWorkChatModelName($0.model) }, - laneId: session.laneId, - requestedCwd: chatSummary?.requestedCwd - ) - ) - } - .scrollIndicators(.hidden) - .scrollDismissesKeyboard(.interactively) - .coordinateSpace(name: workChatScrollCoordinateSpace) - .background( - GeometryReader { geometry in - Color.clear.preference( - key: WorkChatViewportHeightPreferenceKey.self, - value: geometry.size.height + } + .padding(16) + .frame( + maxWidth: .infinity, + minHeight: max(scrollViewportHeight, 0), + alignment: .bottomLeading ) - .preference( - key: WorkChatViewportWidthPreferenceKey.self, - value: geometry.size.width + .modifier( + WorkChatTranscriptEnvironmentModifier( + provider: chatSummaryContext.provider, + modelId: chatSummaryContext.currentModelId, + modelLabel: chatSummaryContext.modelLabel, + laneId: session.laneId, + requestedCwd: chatSummaryContext.requestedCwd + ) ) } - ) - .background(workChatCanvasBackground.ignoresSafeArea()) - .adeNavigationGlass() - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - timelineDragActive = true - if value.translation.height > workChatTouchScrollDeadband { - releaseBottomStickinessForUserScroll(reason: "drag") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .layoutPriority(1) + .clipped() + .scrollIndicators(.hidden) + .scrollDismissesKeyboard(.interactively) + .defaultScrollAnchor(.bottom) + .coordinateSpace(name: workChatScrollCoordinateSpace) + .background( + GeometryReader { geometry in + Color.clear + .preference( + key: WorkChatViewportHeightPreferenceKey.self, + value: geometry.size.height + ) + .preference( + key: WorkChatViewportWidthPreferenceKey.self, + value: geometry.size.width + ) } + ) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if !timelineDragActive { + timelineDragActive = true + } + if value.translation.height > workChatTouchScrollDeadband { + releaseBottomStickinessForUserScroll(reason: "drag") + } else if value.translation.height < -workChatTouchScrollDeadband { + allowBottomStickinessResumeFromUserScroll(reason: "drag") + } + } + .onEnded { value in + let releasedBottomStickiness = value.translation.height > workChatTouchScrollDeadband + if timelineDragActive { + timelineDragActive = false + } + guard !releasedBottomStickiness else { return } + updateBottomStickiness(distanceFromBottom: scrollMetrics.distanceFromBottom, proxy: proxy) + } + ) + .overlay(alignment: .top) { + WorkChatNavigationBackdrop() } - .onEnded { _ in - timelineDragActive = false - updateBottomStickiness(distanceFromBottom: lastScrollDistanceFromBottom, proxy: proxy) + .overlay(alignment: .bottomTrailing) { + if unreadBelowCount > 0 || !isNearBottom { + WorkJumpToLatestPill(count: unreadBelowCount) { + isNearBottom = true + if timelineDragActive { + timelineDragActive = false + } + scrollToLatest(proxy, animated: true) + unreadBelowCount = 0 + } + .padding(.trailing, 16) + .padding(.bottom, jumpToLatestPillBottomPadding) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } } - ) - .safeAreaInset(edge: .bottom, spacing: 0) { + composerInset(proxy: proxy) + .fixedSize(horizontal: false, vertical: true) .background(alignment: .bottom) { WorkChatComposerBackdrop() } + .background( + GeometryReader { geometry in + Color.clear.preference( + key: WorkChatComposerLayoutHeightPreferenceKey.self, + value: geometry.size.height + ) + } + ) } - .overlay(alignment: .top) { - WorkChatNavigationBackdrop() - } - .overlay(alignment: .bottomTrailing) { - if unreadBelowCount > 0 || !isNearBottom { - WorkJumpToLatestPill(count: unreadBelowCount) { - workChatScrollLog.notice( - "jump_to_latest_tapped session=\(session.id, privacy: .public) unread=\(unreadBelowCount, privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) lastVisible=\(visibleTimeline.last?.id ?? "none", privacy: .public)" - ) - isNearBottom = true - timelineDragActive = false - scrollToLatest(proxy, animated: true) - unreadBelowCount = 0 - } - .padding(.trailing, 16) - .padding(.bottom, jumpToLatestPillBottomPadding) - .transition(.move(edge: .trailing).combined(with: .opacity)) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(workChatCanvasBackground.ignoresSafeArea()) + .adeNavigationGlass() + .onPreferenceChange(WorkChatViewportHeightPreferenceKey.self) { height in + let changed = abs(scrollViewportHeight - height) > 1 + scrollViewportHeight = height + guard changed, isNearBottom else { return } + pinToLatestAfterLayout(proxy, reason: "viewport-height") } - } - .onPreferenceChange(WorkChatViewportHeightPreferenceKey.self) { height in - scrollViewportHeight = height - } - .onPreferenceChange(WorkChatViewportWidthPreferenceKey.self) { width in - scrollViewportWidth = width - } - .onPreferenceChange(WorkChatContentBottomPreferenceKey.self) { bottomY in - guard scrollViewportHeight > 1 else { return } - updateBottomStickiness( - distanceFromBottom: max(0, bottomY - scrollViewportHeight), - proxy: proxy - ) - } - .onChange(of: timeline.count) { oldCount, newCount in - let previousTailId = lastTimelineTailId - lastTimelineTailId = timeline.last?.id - let delta = newCount - oldCount - guard delta > 0 else { - workChatScrollLog.notice( - "timeline_count_changed_no_growth session=\(session.id, privacy: .public) old=\(oldCount, privacy: .public) new=\(newCount, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) previousTail=\(previousTailId ?? "none", privacy: .public) nearBottom=\(isNearBottom, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) - return + .onPreferenceChange(WorkChatViewportWidthPreferenceKey.self) { width in + scrollViewportWidth = width } - // Older-page prepends grow the timeline above the viewport — the - // newest entry stays put. Don't autoscroll to the bottom or flag - // the prepended entries as "new messages below". - if let previousTailId, previousTailId == timeline.last?.id { - workChatScrollLog.notice( - "timeline_growth_skipped_prepended session=\(session.id, privacy: .public) old=\(oldCount, privacy: .public) new=\(newCount, privacy: .public) delta=\(delta, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) nearBottom=\(isNearBottom, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) - return + .onPreferenceChange(WorkChatComposerLayoutHeightPreferenceKey.self) { height in + guard height > 0, abs(composerLayoutHeight - height) > 1 else { return } + composerLayoutHeight = height + guard isNearBottom else { return } + pinToLatestAfterLayout(proxy, reason: "composer-height") } - if isNearBottom { - workChatScrollLog.notice( - "timeline_growth_autoscroll session=\(session.id, privacy: .public) old=\(oldCount, privacy: .public) new=\(newCount, privacy: .public) delta=\(delta, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) previousTail=\(previousTailId ?? "none", privacy: .public)" - ) - pinToLatestAfterLayout(proxy, reason: "timeline-growth") - } else { - let nextCount = unreadBelowCount + delta - workChatScrollLog.notice( - "timeline_growth_unread session=\(session.id, privacy: .public) old=\(oldCount, privacy: .public) new=\(newCount, privacy: .public) delta=\(delta, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public) unread=\(nextCount, privacy: .public)" + .onPreferenceChange(WorkChatContentBottomPreferenceKey.self) { bottomY in + guard scrollViewportHeight > 1 else { return } + updateBottomStickiness( + distanceFromBottom: max(0, bottomY - scrollViewportHeight), + proxy: proxy ) - if unreadBelowCount == 0 { - withAnimation(ADEMotion.standard(reduceMotion: reduceMotion)) { + resolvePendingInitialBottomPinAfterLayout(proxy, reason: "content-bottom") + } + .onChange(of: timeline.count) { oldCount, newCount in + let previousTailId = lastTimelineTailId + lastTimelineTailId = timeline.last?.id + let delta = newCount - oldCount + guard delta > 0 else { return } + // Older-page prepends grow the timeline above the viewport — the + // newest entry stays put. Don't autoscroll to the bottom or flag + // the prepended entries as "new messages below". + if let previousTailId, previousTailId == timeline.last?.id { + return + } + if isNearBottom { + pinToLatestAfterLayout(proxy, reason: "timeline-growth") + } else { + let nextCount = unreadBelowCount + delta + if unreadBelowCount == 0 { + withAnimation(ADEMotion.standard(reduceMotion: reduceMotion)) { + unreadBelowCount = nextCount + } + } else { unreadBelowCount = nextCount } + } + } + .onChange(of: timeline.last?.id) { oldTailId, newTailId in + guard oldTailId != newTailId else { return } + lastTimelineTailId = newTailId + guard oldTailId != nil, newTailId != nil, isNearBottom else { return } + pinToLatestAfterLayout(proxy, reason: "timeline-tail") + } + .onChange(of: workSubagentRunningCount(subagentSnapshots)) { _, _ in + guard isNearBottom else { return } + pinToLatestAfterLayout(proxy, reason: "subagent-active-count") + } + .onChange(of: timelineLayoutPinToken) { _, _ in + if pendingInitialBottomPinSessionId == session.id { + forcePinToLatestAfterLayout(proxy, reason: "initial-timeline-layout") } else { - unreadBelowCount = nextCount + pinToLatestAfterLayout(proxy, reason: "timeline-layout") } } - } - .onChange(of: timeline.last?.id) { oldTailId, newTailId in - guard oldTailId != newTailId else { return } - workChatScrollLog.notice( - "timeline_tail_changed session=\(session.id, privacy: .public) oldTail=\(oldTailId ?? "none", privacy: .public) newTail=\(newTailId ?? "none", privacy: .public) timeline=\(timeline.count, privacy: .public) visible=\(visibleTimeline.count, privacy: .public) lastVisible=\(visibleTimeline.last?.id ?? "none", privacy: .public) nearBottom=\(isNearBottom, privacy: .public) unread=\(unreadBelowCount, privacy: .public)" - ) - lastTimelineTailId = newTailId - guard oldTailId != nil, newTailId != nil, isNearBottom else { return } - pinToLatestAfterLayout(proxy, reason: "timeline-tail") - } - .onChange(of: isNearBottom) { _, nearBottom in - guard nearBottom, unreadBelowCount > 0 else { return } - workChatScrollLog.notice( - "near_bottom_clears_unread session=\(session.id, privacy: .public) unread=\(unreadBelowCount, privacy: .public) timeline=\(timeline.count, privacy: .public) tail=\(timeline.last?.id ?? "none", privacy: .public)" - ) - withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { - unreadBelowCount = 0 + .onChange(of: isNearBottom) { _, nearBottom in + guard nearBottom, unreadBelowCount > 0 else { return } + withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + unreadBelowCount = 0 + } } - } - .onAppear { - refreshTimelinePresentation() - scheduleTimelineSnapshotRebuild() - // Seed the blocking-input tracker so an already-open gate on first - // render doesn't re-fire the haptic, but a gate that arrives later does. - lastBlockingPendingInputId = blockingPendingInputId - } - .onDisappear { - cancelScheduledTimelineSnapshotRebuild() - } - .onChange(of: chatSummary) { _, _ in - refreshTimelinePresentation() - } - .onChange(of: chatSummary?.effectiveFastMode) { _, newValue in - if let pendingCodexFastMode, pendingCodexFastMode == (newValue == true) { - self.pendingCodexFastMode = nil + .onAppear { + prepareScrollStateForCurrentSessionIfNeeded(reason: "appear") + if transcript.isEmpty && fallbackEntries.isEmpty { + scheduleTimelineSnapshotRebuild() + } else { + rebuildTimelineSnapshot() + } + // Seed the blocking-input tracker so an already-open gate on first + // render doesn't re-fire the haptic, but a gate that arrives later does. + lastBlockingPendingInputId = blockingPendingInputId } - } - .onChange(of: session.id) { _, _ in - pendingCodexFastMode = nil - composerSettingMutationInFlight = false - composerSettingMutationGeneration &+= 1 - } - .onChange(of: transcript) { _, _ in - scheduleTimelineSnapshotRebuild() - } - .onChange(of: fallbackEntries) { _, _ in - scheduleTimelineSnapshotRebuild() - } - .onChange(of: artifacts) { _, _ in - scheduleTimelineSnapshotRebuild() - } - .onChange(of: localEchoMessages) { _, _ in - scheduleTimelineSnapshotRebuild() - } - .onChange(of: blockingPendingInputId) { _, newId in - handleBlockingPendingInputChange(newId, proxy: proxy) - } - .sensoryFeedback(.impact(weight: .light), trigger: blockingPendingHapticToken) - .sheet(isPresented: $artifactDrawerPresented) { - WorkArtifactDrawerSheet( - artifacts: artifacts, - artifactContent: $artifactContent, - isRefreshing: artifactRefreshInFlight, - refreshError: artifactRefreshError, - onRefresh: onRefreshArtifacts, - onLoadArtifact: onLoadArtifact - ) - .presentationDetents([.medium, .large]) - .presentationDragIndicator(.visible) - } - .sheet(isPresented: $modelPickerPresented) { - let currentModelId = chatSummary?.modelId ?? chatSummary?.model ?? "" - WorkModelPickerSheet( - currentModelId: currentModelId, - currentProvider: chatSummary?.provider ?? "", - currentReasoningEffort: chatSummary?.reasoningEffort ?? "", - isBusy: modelUpdateInFlight, - onSelect: { option, pickedReasoning, _ in - Task { @MainActor in - modelUpdateInFlight = true - defer { modelUpdateInFlight = false } - let wasCurrentModel = workModelIdsEquivalent(option.id, currentModelId) - if !wasCurrentModel { - await onSelectModel(option.id) - } - guard !Task.isCancelled else { return } - let currentReasoning = chatSummary?.reasoningEffort ?? "" - let nextReasoning = pickedReasoning ?? "" - if nextReasoning != currentReasoning { - await onSelectEffort(nextReasoning) + .task(id: timelineInputRecoveryKey) { + recoverEmptyTimelineSnapshotIfNeeded() + } + .onDisappear { + cancelScheduledTimelineSnapshotRebuild() + } + .onChange(of: chatSummaryTimelineKey) { _, _ in + refreshTimelinePresentation() + } + .onChange(of: chatSummaryContext.effectiveFastMode) { _, newValue in + if let pendingCodexFastMode, pendingCodexFastMode == newValue { + self.pendingCodexFastMode = nil + } + } + .onChange(of: session.id) { _, _ in + pendingCodexFastMode = nil + lastBlockingPendingInputId = nil + blockingPendingHapticToken = 0 + assistantLineBudgets.removeAll() + composerSettingMutationInFlight = false + composerSettingMutationGeneration &+= 1 + resetScrollStateForCurrentSession(reason: "session-change") + cancelScheduledTimelineSnapshotRebuild() + timelineSnapshot = .empty + timelinePresentation = .empty + scheduleTimelineSnapshotRebuild() + } + .onChange(of: transcript) { _, _ in + if timelineSnapshot.timeline.isEmpty, !transcript.isEmpty { + cancelScheduledTimelineSnapshotRebuild() + rebuildTimelineSnapshot() + } else { + scheduleTimelineSnapshotRebuild() + } + } + .onChange(of: fallbackEntries) { _, _ in + guard transcript.isEmpty else { + return + } + if timelineSnapshot.timeline.isEmpty, !fallbackEntries.isEmpty { + cancelScheduledTimelineSnapshotRebuild() + rebuildTimelineSnapshot() + } else { + scheduleTimelineSnapshotRebuild() + } + } + .onChange(of: artifacts) { _, _ in + scheduleTimelineSnapshotRebuild() + } + .onChange(of: localEchoMessages) { _, _ in + scheduleTimelineSnapshotRebuild() + } + .onChange(of: blockingPendingInputId) { _, newId in + handleBlockingPendingInputChange(newId, proxy: proxy) + } + .sensoryFeedback(.impact(weight: .light), trigger: blockingPendingHapticToken) + .sheet(isPresented: $artifactDrawerPresented) { + WorkArtifactDrawerSheet( + artifacts: artifacts, + artifactContent: $artifactContent, + isRefreshing: artifactRefreshInFlight, + refreshError: artifactRefreshError, + onRefresh: onRefreshArtifacts, + onLoadArtifact: onLoadArtifact + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } + .sheet(isPresented: $modelPickerPresented) { + let currentModelId = chatSummaryContext.currentModelId + WorkModelPickerSheet( + currentModelId: currentModelId, + currentProvider: chatSummaryContext.provider, + currentReasoningEffort: chatSummaryContext.reasoningEffort, + isBusy: modelUpdateInFlight, + onSelect: { option, pickedReasoning, _ in + Task { @MainActor in + modelUpdateInFlight = true + defer { modelUpdateInFlight = false } + let wasCurrentModel = workModelIdsEquivalent(option.id, currentModelId) + if !wasCurrentModel { + await onSelectModel(option.id) + } + guard !Task.isCancelled else { return } + let currentReasoning = chatSummaryContext.reasoningEffort + let nextReasoning = pickedReasoning ?? "" + if nextReasoning != currentReasoning { + await onSelectEffort(nextReasoning) + } + guard !Task.isCancelled else { return } + modelPickerPresented = false } - guard !Task.isCancelled else { return } - modelPickerPresented = false } - } - ) + ) + } } } +} + +func workLoadedArtifactContentRenderSignature(_ content: [String: WorkLoadedArtifactContent]) -> Int { + var hasher = Hasher() + hasher.combine(content.count) + for key in content.keys.sorted() { + hasher.combine(key) + guard let value = content[key] else { continue } + switch value { + case .image(let image): + hasher.combine("image") + hasher.combine(Int(image.size.width.rounded())) + hasher.combine(Int(image.size.height.rounded())) + hasher.combine(Int(image.scale.rounded())) + case .video(let url): + hasher.combine("video") + hasher.combine(url.absoluteString) + case .remoteURL(let url): + hasher.combine("remoteURL") + hasher.combine(url.absoluteString) + case .text(let text): + hasher.combine("text") + hasher.combine(text.utf8.count) + hasher.combine(text.hashValue) + case .error(let message): + hasher.combine("error") + hasher.combine(message) + } + } + return hasher.finalize() +} + +func workChatEnvelopeListRenderSignature(_ transcript: [WorkChatEnvelope]) -> Int { + var hasher = Hasher() + hasher.combine(transcript.count) + for envelope in transcript { + hasher.combine(workChatEnvelopeMergeKey(envelope)) + hasher.combine(envelope.sequence) + hasher.combine(envelope.timestamp) + if case .assistantText(let text, _, _) = envelope.event { + hasher.combine(text.utf8.count) + hasher.combine(text.hashValue) + } + } + return hasher.finalize() +} + +func workFallbackEntriesRenderSignature(_ entries: [AgentChatTranscriptEntry]) -> Int { + var hasher = Hasher() + hasher.combine(entries.count) + for entry in entries { + hasher.combine(workTranscriptEntryIdentity(entry)) + } + return hasher.finalize() +} + +func workArtifactSummariesRenderSignature(_ artifacts: [ComputerUseArtifactSummary]) -> Int { + var hasher = Hasher() + hasher.combine(artifacts.count) + for artifact in artifacts { + hasher.combine(artifact.id) + hasher.combine(artifact.uri) + hasher.combine(artifact.title) + hasher.combine(artifact.reviewState) + hasher.combine(artifact.workflowState) + } + return hasher.finalize() +} + +func workPendingSteersRenderSignature(_ steers: [WorkPendingSteerModel]) -> Int { + var hasher = Hasher() + hasher.combine(steers.count) + for steer in steers { + hasher.combine(steer.id) + hasher.combine(steer.text.utf8.count) + hasher.combine(steer.text.hashValue) + hasher.combine(steer.turnId) + hasher.combine(steer.timestamp) + } + return hasher.finalize() +} + +func workLocalEchoMessagesRenderSignature(_ messages: [WorkLocalEchoMessage]) -> Int { + var hasher = Hasher() + hasher.combine(messages.count) + for message in messages { + hasher.combine(message.id) + hasher.combine(message.text.utf8.count) + hasher.combine(message.text.hashValue) + hasher.combine(message.timestamp) + hasher.combine(message.deliveryState) + } + return hasher.finalize() +} + +func workExpandedToolCardIdsRenderSignature(_ ids: Set) -> Int { + var hasher = Hasher() + hasher.combine(ids.count) + for id in ids.sorted() { + hasher.combine(id) } + return hasher.finalize() +} + +func workSubagentSnapshotsRenderSignature(_ snapshots: [WorkSubagentSnapshot]) -> Int { + var hasher = Hasher() + hasher.combine(snapshots.count) + for snapshot in snapshots { + hasher.combine(snapshot.taskId) + hasher.combine(snapshot.agentId) + hasher.combine(snapshot.agentType) + hasher.combine(snapshot.parentToolUseId) + hasher.combine(snapshot.description) + hasher.combine(snapshot.background) + hasher.combine(snapshot.status) + hasher.combine(snapshot.lastToolName) + hasher.combine(snapshot.latestSummary) + hasher.combine(snapshot.turnId) + hasher.combine(snapshot.startedAt) + hasher.combine(snapshot.updatedAt) + } + return hasher.finalize() +} + +func workLaneListRenderSignature(_ lanes: [LaneSummary]) -> Int { + var hasher = Hasher() + hasher.combine(lanes.count) + for lane in lanes { + hasher.combine(lane.id) + hasher.combine(lane.name) + hasher.combine(lane.color) + hasher.combine(lane.icon) + hasher.combine(lane.status.dirty) + hasher.combine(lane.status.ahead) + hasher.combine(lane.status.behind) + } + return hasher.finalize() } private struct WorkChatViewportHeightPreferenceKey: PreferenceKey { @@ -835,6 +1206,15 @@ private struct WorkChatViewportWidthPreferenceKey: PreferenceKey { } } +private struct WorkChatComposerLayoutHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { value = next } + } +} + private struct WorkChatContentBottomPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = 0 @@ -873,7 +1253,7 @@ private struct WorkChatComposerBackdrop: View { var body: some View { LinearGradient( colors: [ - workChatCanvasBackground.opacity(0), + workChatCanvasBackground.opacity(0.98), workChatCanvasBackground.opacity(0.94), workChatCanvasBackground ], @@ -886,43 +1266,147 @@ private struct WorkChatComposerBackdrop: View { } struct WorkTimelinePresentation: Equatable { - let entries: [WorkTimelineEntry] let visibleEntries: [WorkTimelineEntry] + let renderEntries: [WorkTimelineRenderEntry] + let timelineCount: Int + let timelineFirstId: String? + let timelineLastId: String? let hiddenCount: Int + let signature: Int static let empty = WorkTimelinePresentation( - entries: [], visibleEntries: [], - hiddenCount: 0 + renderEntries: [], + timelineCount: 0, + timelineFirstId: nil, + timelineLastId: nil, + hiddenCount: 0, + signature: 0 ) + + static func == (lhs: WorkTimelinePresentation, rhs: WorkTimelinePresentation) -> Bool { + lhs.signature == rhs.signature + } } private func makeWorkTimelinePresentation( timeline: [WorkTimelineEntry], visibleCount: Int, - chatSummary: AgentChatSessionSummary?, + chatSummary: WorkChatSummaryRenderContext, transcript: [WorkChatEnvelope], - assistantPreviewCache: WorkAssistantPreviewCache + assistantPreviewCache: WorkAssistantPreviewCache, + assistantLineBudgets: [String: Int], + streamingAssistantMessageId: String? ) -> WorkTimelinePresentation { - let entries = injectWorkTurnSeparators( - into: timeline, - chatSummary: chatSummary, + let rawVisibleEntries = visibleWorkTimelineEntries(from: timeline, visibleCount: visibleCount) + let visibleEntriesWithSeparators = injectWorkTurnSeparators( + into: rawVisibleEntries, + provider: chatSummary.provider, + model: chatSummary.model, + modelId: chatSummary.modelId, transcript: transcript ) let visibleEntries = workTimelineEntriesWithAssistantPreviews( - visibleWorkTimelineEntries(from: entries, visibleCount: visibleCount), - cache: assistantPreviewCache + visibleEntriesWithSeparators, + cache: assistantPreviewCache, + assistantLineBudgets: assistantLineBudgets, + tailAnchoredAssistantMessageId: workLatestAssistantMessageId(in: timeline) ) + let renderEntries = workTimelineRenderEntries( + from: visibleEntries, + streamingAssistantMessageId: streamingAssistantMessageId, + splitAssistantMessageId: workLatestAssistantMessageId(in: timeline), + assistantLineBudgets: assistantLineBudgets + ) + let hiddenCount = max(timeline.count - rawVisibleEntries.count, 0) return WorkTimelinePresentation( - entries: entries, visibleEntries: visibleEntries, - hiddenCount: max(entries.count - visibleEntries.count, 0) + renderEntries: renderEntries, + timelineCount: timeline.count, + timelineFirstId: timeline.first?.id, + timelineLastId: timeline.last?.id, + hiddenCount: hiddenCount, + signature: workTimelinePresentationSignature( + timelineCount: timeline.count, + timelineFirstId: timeline.first?.id, + timelineLastId: timeline.last?.id, + visibleEntries: visibleEntries, + renderEntries: renderEntries, + hiddenCount: hiddenCount + ) ) } +private func workTimelinePresentationSignature( + timelineCount: Int, + timelineFirstId: String?, + timelineLastId: String?, + visibleEntries: [WorkTimelineEntry], + renderEntries: [WorkTimelineRenderEntry], + hiddenCount: Int +) -> Int { + var hasher = Hasher() + hasher.combine(hiddenCount) + hasher.combine(timelineCount) + hasher.combine(timelineFirstId) + hasher.combine(timelineLastId) + hasher.combine(visibleEntries.count) + hasher.combine(visibleEntries.first?.id) + hasher.combine(visibleEntries.last?.id) + hasher.combine(renderEntries.count) + for entry in renderEntries { + hasher.combine(entry.id) + hasher.combine(entry.sourceEntryId) + hasher.combine(entry.timestamp) + switch entry.payload { + case .entry(let timelineEntry): + hasher.combine(timelineEntry.id) + hasher.combine(timelineEntry.timestamp) + hasher.combine(timelineEntry.rank) + if case .message(let message) = timelineEntry.payload { + hasher.combine(message.id) + hasher.combine(message.role) + workTimelineCombineTextSignature(message.markdown, into: &hasher) + if let preview = message.assistantPreview { + workTimelineCombineTextSignature(preview.text, into: &hasher) + hasher.combine(preview.isTruncated) + hasher.combine(preview.visibleLineCount) + hasher.combine(preview.totalLineCount) + } + } + case .assistantMarkdownBlock(let model): + hasher.combine(model.id) + hasher.combine(model.messageId) + hasher.combine(model.block.id) + workTimelineCombineTextSignature(model.block.kind.cacheKey, into: &hasher) + case .assistantMonospaced(let model): + hasher.combine(model.id) + hasher.combine(model.messageId) + workTimelineCombineTextSignature(model.text, into: &hasher) + workTimelineCombineTextSignature(model.accessibilityLabel, into: &hasher) + case .assistantControls(let model): + hasher.combine(model.id) + hasher.combine(model.messageId) + hasher.combine(model.summaryText) + hasher.combine(model.visibleLineCount) + hasher.combine(model.totalLineCount) + hasher.combine(model.canShowMore) + hasher.combine(model.nextLineBudget) + } + } + return hasher.finalize() +} + +private func workTimelineCombineTextSignature(_ text: String, into hasher: inout Hasher) { + hasher.combine(text.utf8.count) + hasher.combine(text.hashValue) +} + private func workTimelineEntriesWithAssistantPreviews( _ entries: [WorkTimelineEntry], - cache: WorkAssistantPreviewCache + cache: WorkAssistantPreviewCache, + assistantLineBudgets: [String: Int], + tailAnchoredAssistantMessageId: String? ) -> [WorkTimelineEntry] { var visibleAssistantMessageIds = Set() let hydratedEntries = entries.map { entry -> WorkTimelineEntry in @@ -931,7 +1415,23 @@ private func workTimelineEntriesWithAssistantPreviews( else { return entry } visibleAssistantMessageIds.insert(message.id) - message.assistantPreview = cache.preview(for: message) + let previewAnchor: WorkAssistantMessagePreviewAnchor = message.id == tailAnchoredAssistantMessageId ? .tail : .head + let tailCanRenderFull = previewAnchor == .tail + && !workAssistantMessageUsesMonospacedPreview(message.markdown) + && workAssistantMessageLineCount(message.markdown) <= workAssistantMessageTailFullLineBudget + && message.markdown.count <= workAssistantMessageTailFullCharacterBudget + let lineBudget = assistantLineBudgets[message.id] + ?? (tailCanRenderFull ? workAssistantMessageTailFullLineBudget : workAssistantMessageInitialLineBudget) + if lineBudget == workAssistantMessageInitialLineBudget { + message.assistantPreview = cache.preview(for: message, anchor: previewAnchor) + } else { + message.assistantPreview = workAssistantMessagePreview( + message.markdown, + lineBudget: lineBudget, + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: lineBudget), + anchor: previewAnchor + ) + } return WorkTimelineEntry( id: entry.id, timestamp: entry.timestamp, @@ -943,6 +1443,117 @@ private func workTimelineEntriesWithAssistantPreviews( return hydratedEntries } +private func workLatestAssistantMessageId(in timeline: [WorkTimelineEntry]) -> String? { + for entry in timeline.reversed() { + guard case .message(let message) = entry.payload, + message.role == "assistant" + else { continue } + return message.id + } + return nil +} + +func workTimelineRenderEntries( + from entries: [WorkTimelineEntry], + streamingAssistantMessageId: String?, + splitAssistantMessageId: String? = nil, + assistantLineBudgets: [String: Int] = [:] +) -> [WorkTimelineRenderEntry] { + var rendered: [WorkTimelineRenderEntry] = [] + rendered.reserveCapacity(entries.count) + + for entry in entries { + guard case .message(let message) = entry.payload, + message.role == "assistant" + else { + rendered.append(WorkTimelineRenderEntry( + id: entry.id, + sourceEntryId: entry.id, + timestamp: entry.timestamp, + payload: .entry(entry) + )) + continue + } + + let preview = message.assistantPreview ?? workInitialAssistantMessagePreview(message.markdown) + let shouldSplitAssistantMessage = ( + message.id == streamingAssistantMessageId + || message.id == splitAssistantMessageId + ) + guard shouldSplitAssistantMessage else { + rendered.append(WorkTimelineRenderEntry( + id: entry.id, + sourceEntryId: entry.id, + timestamp: entry.timestamp, + payload: .entry(entry) + )) + continue + } + + let requestedLineBudget = assistantLineBudgets[message.id] ?? workAssistantMessageInitialLineBudget + let maxLineBudget = workAssistantMessageMaxLineBudget(for: message.markdown) + let nextLineBudget = min(requestedLineBudget + workAssistantMessageLineBudgetStep, maxLineBudget) + let accessibilityLabel = workAssistantMessageAccessibilityLabel(preview) + + if workAssistantMessageUsesMonospacedPreview(preview.text) { + let model = WorkAssistantMonospacedRenderModel( + id: "\(entry.id)-assistant-monospace", + messageId: message.id, + turnId: message.turnId, + itemId: message.itemId, + text: preview.text, + accessibilityLabel: accessibilityLabel + ) + rendered.append(WorkTimelineRenderEntry( + id: model.id, + sourceEntryId: entry.id, + timestamp: entry.timestamp, + payload: .assistantMonospaced(model) + )) + } else { + let blocks = message.id == streamingAssistantMessageId + ? parseMarkdownBlocksForStreaming(preview.text, cacheKey: "\(message.id):preview") + : parseMarkdownBlocks(preview.text) + rendered.reserveCapacity(rendered.count + blocks.count + (preview.isTruncated ? 1 : 0)) + for block in blocks { + let model = WorkAssistantMarkdownBlockRenderModel( + id: "\(entry.id)-\(block.id)", + messageId: message.id, + turnId: message.turnId, + itemId: message.itemId, + block: block + ) + rendered.append(WorkTimelineRenderEntry( + id: model.id, + sourceEntryId: entry.id, + timestamp: entry.timestamp, + payload: .assistantMarkdownBlock(model) + )) + } + } + + if preview.isTruncated { + let controls = WorkAssistantMessageControlsModel( + id: "\(entry.id)-assistant-controls", + messageId: message.id, + summaryText: workAssistantMessagePreviewSummaryText(preview), + visibleLineCount: preview.visibleLineCount, + totalLineCount: preview.totalLineCount, + canShowMore: requestedLineBudget < maxLineBudget, + nextLineBudget: nextLineBudget + ) + rendered.append(WorkTimelineRenderEntry( + id: controls.id, + sourceEntryId: entry.id, + timestamp: entry.timestamp, + payload: .assistantControls(controls) + )) + } + } + + return rendered +} + func mergeWorkPendingSteers( optimistic: [WorkPendingSteerModel], canonical: [WorkPendingSteerModel] @@ -959,11 +1570,12 @@ func mergeWorkPendingSteers( } private struct WorkChatComposerCard: View { - let chatSummary: AgentChatSessionSummary? + let chatSummary: WorkChatSummaryRenderContext let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String let pendingInputCount: Int let awaitingInputGate: Bool + let composerPlaceholder: String let canCompose: Bool let canSend: Bool let sending: Bool @@ -989,6 +1601,7 @@ private struct WorkChatComposerCard: View { dictationTargetId: dictationTargetId, pendingInputCount: pendingInputCount, awaitingInputGate: awaitingInputGate, + composerPlaceholder: composerPlaceholder, canCompose: canCompose, canSend: canSend, sending: sending, @@ -1011,32 +1624,20 @@ private struct WorkChatComposerCard: View { private var composerSurface: some View { RoundedRectangle(cornerRadius: 24, style: .continuous) .fill(ADEColor.composerBackground) - .glassEffect(in: .rect(cornerRadius: 24)) - .overlay( - RoundedRectangle(cornerRadius: 24, style: .continuous) - .fill( - LinearGradient( - colors: [Color.white.opacity(0.10), .clear], - startPoint: .top, - endPoint: .bottom - ) - ) - .allowsHitTesting(false) - ) .overlay( RoundedRectangle(cornerRadius: 24, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.42), radius: 18, y: 8) } } private struct WorkChatComposerDraftInput: View { - let chatSummary: AgentChatSessionSummary? + let chatSummary: WorkChatSummaryRenderContext let usageViewModel: WorkContextUsageViewModel? let dictationTargetId: String let pendingInputCount: Int let awaitingInputGate: Bool + let composerPlaceholder: String let canCompose: Bool let canSend: Bool let sending: Bool @@ -1061,10 +1662,7 @@ private struct WorkChatComposerDraftInput: View { WorkChatComposerTextField( draftState: draftState, canCompose: canCompose, - placeholder: workChatComposerPlaceholder( - pendingInputCount: pendingInputCount, - sessionStatus: awaitingInputGate ? "awaiting-input" : "" - ) + placeholder: composerPlaceholder ) if showInterrupt && draftState.hasSendableText { @@ -1106,7 +1704,7 @@ private struct WorkChatComposerDraftInput: View { ) { WorkContextUsagePopover( usage: usageViewModel, - modelLabel: chatSummary?.model + modelLabel: chatSummary.modelLabel ) .frame(maxWidth: 320, alignment: .leading) .presentationCompactAdaptation(.popover) @@ -1362,7 +1960,7 @@ private struct WorkContextUsagePopover: View { RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(ADEColor.glassBorder.opacity(0.9), lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.32), radius: 14, y: 8) + .shadow(color: Color.black.opacity(0.10), radius: 3, y: 1) .accessibilityIdentifier("Work.Chat.Composer.ContextUsagePopover") } } @@ -1409,8 +2007,8 @@ private struct WorkChatComposerTextField: View { .foregroundStyle(ADEColor.textPrimary) .tint(ADEColor.accent) .disabled(!canCompose) - .autocorrectionDisabled(false) - .textInputAutocapitalization(.sentences) + .autocorrectionDisabled(true) + .textInputAutocapitalization(.never) .focused($composerFocused) .frame(maxWidth: .infinity, minHeight: 24, alignment: .leading) } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 67e6050b6..632037638 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -4,6 +4,12 @@ import AVKit import OSLog private let workChatTranscriptLog = Logger(subsystem: "com.ade.ios", category: "WorkChatTranscript") +private let workStreamingMergeMinimumReplayAnchorLength = 24 +private let workStreamingMergeMaxScanCharacters = 512 +private let workStreamingMergeReplaySearchWindowCharacters = 12_000 +private let workStreamingMergeReplayAnchorLengths = [512, 256, 128, 64, 32, workStreamingMergeMinimumReplayAnchorLength] +private let workStreamingMergeMinimumRepeatedTailLength = 24 +private let workStreamingMergeRepeatedTailWindowCharacters = 4_096 struct WorkErrorPresentation { let title: String @@ -50,15 +56,25 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess messages[lastIndex].steerId == steerId, messages[lastIndex].timestamp == envelope.timestamp { messages[lastIndex].markdown += text - if let attachments, !attachments.isEmpty { - messages[lastIndex].attachments = attachments - } - if let deliveryState { - messages[lastIndex].deliveryState = deliveryState - } - if let processed { - messages[lastIndex].processed = processed - } + mergeWorkUserMessageMetadata( + into: &messages[lastIndex], + attachments: attachments, + deliveryState: deliveryState, + processed: processed + ) + } else if let duplicateIndex = duplicateUserMessageVariantIndex( + in: messages, + text: text, + turnId: turnId, + steerId: steerId, + deliveryState: deliveryState + ) { + mergeWorkUserMessageMetadata( + into: &messages[duplicateIndex], + attachments: attachments, + deliveryState: deliveryState, + processed: processed + ) } else { messages.append(WorkChatMessage( id: envelope.id, @@ -74,16 +90,32 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess )) } case .assistantText(let text, let turnId, let itemId): + let text = workStreamingTextByCollapsingRepeatedTailReplay(text) let metadata = turnId .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .flatMap { metadataByTurn[$0] } - let canMergeWithPreviousAssistant = itemId != nil || previousEnvelopeWasAssistantText - if let lastIndex = messages.indices.last, + let isLiveFragment = envelope.sequence != nil + let canMergeWithPreviousAssistant = itemId != nil || (previousEnvelopeWasAssistantText && isLiveFragment) + if let itemIndex = assistantFragmentIndexByItemId( + in: messages, + turnId: turnId, + itemId: itemId + ) { + messages[itemIndex].markdown = mergeWorkStreamingText(messages[itemIndex].markdown, text) + messages[itemIndex].assistantPreview = nil + if messages[itemIndex].turnProvider == nil { + messages[itemIndex].turnProvider = metadata?.provider + } + if messages[itemIndex].turnModelId == nil { + messages[itemIndex].turnModelId = metadata?.modelId + } + } else if let lastIndex = messages.indices.last, messages[lastIndex].role == "assistant", messages[lastIndex].turnId == turnId, messages[lastIndex].itemId == itemId, canMergeWithPreviousAssistant { messages[lastIndex].markdown = mergeWorkStreamingText(messages[lastIndex].markdown, text) + messages[lastIndex].assistantPreview = nil } else if let duplicateIndex = duplicateAssistantFragmentIndex( in: messages, turnId: turnId, @@ -115,6 +147,75 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess return messages } +private func assistantFragmentIndexByItemId( + in messages: [WorkChatMessage], + turnId: String?, + itemId: String? +) -> Int? { + let normalizedItemId = normalizedAssistantItemId(itemId) + guard !normalizedItemId.isEmpty else { return nil } + return messages.indices.reversed().first { index in + let message = messages[index] + guard message.role == "assistant", + normalizedAssistantItemId(message.itemId) == normalizedItemId + else { return false } + return assistantTurnIdsAllowStableItemMerge(message.turnId, turnId) + } +} + +private func normalizedAssistantItemId(_ itemId: String?) -> String { + itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" +} + +private func assistantTurnIdsAllowStableItemMerge(_ lhs: String?, _ rhs: String?) -> Bool { + let left = lhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let right = rhs?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return left.isEmpty || right.isEmpty || left == right +} + +private func duplicateUserMessageVariantIndex( + in messages: [WorkChatMessage], + text: String, + turnId: String?, + steerId: String?, + deliveryState: String? +) -> Int? { + guard deliveryState != "queued" else { return nil } + let normalizedText = normalizedWorkLocalEchoText(text) + guard !normalizedText.isEmpty else { return nil } + let normalizedTurnId = turnId?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSteerId = steerId?.trimmingCharacters(in: .whitespacesAndNewlines) + guard normalizedTurnId?.isEmpty == false || normalizedSteerId?.isEmpty == false else { return nil } + return messages.lastIndex { message in + guard message.role == "user" else { return false } + guard !(message.deliveryState == "queued" && message.steerId != nil) else { return false } + let sameTurn = normalizedTurnId?.isEmpty == false && message.turnId == turnId + let sameSteer = normalizedSteerId?.isEmpty == false && message.steerId == steerId + guard sameTurn || sameSteer else { return false } + return normalizedWorkLocalEchoText(message.markdown) == normalizedText + } +} + +private func mergeWorkUserMessageMetadata( + into message: inout WorkChatMessage, + attachments: [AgentChatFileRef]?, + deliveryState: String?, + processed: Bool? +) { + if let attachments, !attachments.isEmpty { + let existing = message.attachments ?? [] + let existingKeys = Set(existing.map { "\($0.type)|\($0.path)|\($0.url ?? "")" }) + let mergedAttachments = existing + attachments.filter { !existingKeys.contains("\($0.type)|\($0.path)|\($0.url ?? "")") } + message.attachments = mergedAttachments + } + if let deliveryState { + message.deliveryState = deliveryState + } + if let processed { + message.processed = processed + } +} + private func duplicateAssistantFragmentIndex( in messages: [WorkChatMessage], turnId: String?, @@ -154,13 +255,36 @@ func mergeWorkStreamingText(_ existing: String, _ incoming: String) -> String { if incoming.isEmpty { return existing } if existing == incoming { return existing } if incoming.hasPrefix(existing) { return incoming } + if existing.hasPrefix(incoming), + workStreamingExistingOnlyAddsRepeatedIncomingTail(existing: existing, incoming: incoming) { + return incoming + } if existing.hasPrefix(incoming) { return existing } + let normalizedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedIncoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines) + if !normalizedIncoming.isEmpty, + normalizedExisting.hasSuffix(normalizedIncoming) { + return existing + } + if !normalizedExisting.isEmpty, + normalizedIncoming.hasPrefix(normalizedExisting) { + return incoming + } + if workStreamingTextHasMultiwordReplayShape(incoming), + existing.hasSuffix(incoming) { + return existing + } + if let mergedReplay = mergeWorkStreamingReplayText(existing: existing, incoming: incoming) { + return workStreamingTextByCollapsingRepeatedTail(mergedReplay) + } - let maxOverlap = min(existing.count, incoming.count) - guard maxOverlap > 0 else { return existing + incoming } + let maxOverlap = min(min(existing.count, incoming.count), workStreamingMergeMaxScanCharacters) + guard maxOverlap > 0 else { + return existing + incoming + } // The hasPrefix checks above handle the common streaming-duplication cases, so this - // O(n*m) overlap scan only runs for rare partial-overlap chunks where n and m are small. + // overlap scan is capped to keep long transcript rebuilds bounded. for length in stride(from: maxOverlap, through: 1, by: -1) { let existingSuffix = existing.suffix(length) let incomingPrefix = incoming.prefix(length) @@ -172,14 +296,203 @@ func mergeWorkStreamingText(_ existing: String, _ incoming: String) -> String { return existing + incoming } +private func workStreamingExistingOnlyAddsRepeatedIncomingTail(existing: String, incoming: String) -> Bool { + guard existing.count > incoming.count else { return false } + let extraStart = existing.index(existing.startIndex, offsetBy: incoming.count) + let extra = String(existing[extraStart...]) + let extraWords = workStreamingNormalizedWords(extra) + guard !extraWords.isEmpty, extraWords.count <= 24 else { return false } + + let incomingWords = workStreamingNormalizedWords(incoming) + guard !incomingWords.isEmpty else { return false } + + let maxPhraseLength = min(extraWords.count, incomingWords.count, 12) + guard maxPhraseLength > 0 else { return false } + + for phraseLength in 1...maxPhraseLength where extraWords.count % phraseLength == 0 { + let phrase = Array(extraWords.prefix(phraseLength)) + guard phrase.count >= 2 || phrase.first?.count ?? 0 >= 4 else { continue } + guard Array(incomingWords.suffix(phraseLength)) == phrase else { continue } + + var index = phraseLength + var repeatsIncomingTail = true + while index < extraWords.count { + let end = index + phraseLength + guard Array(extraWords[index.. [String] { + text + .lowercased() + .split { scalar in + !scalar.isLetter && !scalar.isNumber + } + .map(String.init) +} + +private func mergeWorkStreamingReplayText(existing: String, incoming: String) -> String? { + let existingCount = existing.count + let incomingCount = incoming.count + guard existingCount >= workStreamingMergeMinimumReplayAnchorLength, + incomingCount >= workStreamingMergeMinimumReplayAnchorLength else { + return nil + } + + let searchWindowLength = min(existingCount, workStreamingMergeReplaySearchWindowCharacters) + let searchStart = existing.index(existing.endIndex, offsetBy: -searchWindowLength) + let searchableExisting = existing[searchStart...] + let maxAnchorLength = min( + min(incomingCount, searchableExisting.count), + workStreamingMergeMaxScanCharacters + ) + guard maxAnchorLength >= workStreamingMergeMinimumReplayAnchorLength else { return nil } + + var anchorLengths = [maxAnchorLength] + anchorLengths.append(contentsOf: workStreamingMergeReplayAnchorLengths.filter { + $0 < maxAnchorLength && $0 >= workStreamingMergeMinimumReplayAnchorLength + }) + + for length in anchorLengths { + let incomingAnchor = incoming.prefix(length) + guard let anchorRange = searchableExisting.range(of: incomingAnchor, options: [.backwards]) else { + continue + } + + let existingReplayTail = searchableExisting[anchorRange.lowerBound...] + if incoming.hasPrefix(existingReplayTail) { + return String(existing[.. Bool { + guard text.count >= workStreamingMergeMinimumRepeatedTailLength else { return false } + return text.range(of: #"\S\s+\S"#, options: .regularExpression) != nil +} + +private func workStreamingTextByCollapsingRepeatedTail(_ text: String) -> String { + guard text.count >= workStreamingMergeMinimumRepeatedTailLength * 2 else { return text } + + let tailWindow = String(text.suffix(workStreamingMergeRepeatedTailWindowCharacters)) + let collapsedTail = workStreamingTextTailRemovingAdjacentRepeats(tailWindow) + guard collapsedTail.count < tailWindow.count else { return text } + + let prefix = text.dropLast(tailWindow.count) + return String(prefix) + collapsedTail +} + +private func workStreamingTextByCollapsingRepeatedTailReplay(_ text: String) -> String { + var result = workStreamingTextByCollapsingRepeatedTail(text) + var changed = true + + while changed { + changed = false + let words = workStreamingWordsWithRanges(result) + let maxPhraseLength = min(12, words.count / 2) + guard maxPhraseLength >= 2 else { break } + + for phraseLength in 2...maxPhraseLength { + let tailStart = words.count - phraseLength + let previousStart = tailStart - phraseLength + let tailWords = words[tailStart..= 12 else { continue } + + var removalStart = tailRange.lowerBound + while removalStart > result.startIndex { + let previous = result.index(before: removalStart) + guard result[previous].isWhitespace else { break } + removalStart = previous + } + result.removeSubrange(removalStart.. [(word: String, range: Range)] { + var words: [(word: String, range: Range)] = [] + var cursor = text.startIndex + + while cursor < text.endIndex { + while cursor < text.endIndex, !text[cursor].isLetter, !text[cursor].isNumber { + cursor = text.index(after: cursor) + } + guard cursor < text.endIndex else { break } + + let start = cursor + while cursor < text.endIndex, text[cursor].isLetter || text[cursor].isNumber { + cursor = text.index(after: cursor) + } + let range = start.. String { + var result = text + var changed = true + + while changed { + changed = false + let count = result.count + let maxLength = min(count / 2, workStreamingMergeMaxScanCharacters) + guard maxLength >= workStreamingMergeMinimumRepeatedTailLength else { break } + + for length in stride(from: maxLength, through: workStreamingMergeMinimumRepeatedTailLength, by: -1) { + let suffixStart = result.index(result.endIndex, offsetBy: -length) + guard let previousStart = result.index(suffixStart, offsetBy: -length, limitedBy: result.startIndex) else { + continue + } + let previous = result[previousStart.. [WorkChatEnvelope] { entries.map { entry in - WorkChatEnvelope( + let messageId = entry.messageId?.trimmingCharacters(in: .whitespacesAndNewlines) + let fallbackItemId = entry.itemId?.trimmingCharacters(in: .whitespacesAndNewlines) + let itemId = messageId?.isEmpty == false ? messageId : (fallbackItemId?.isEmpty == false ? fallbackItemId : nil) + return WorkChatEnvelope( sessionId: sessionId, timestamp: entry.timestamp, sequence: nil, event: entry.role == "assistant" - ? .assistantText(text: entry.text, turnId: entry.turnId, itemId: nil) + ? .assistantText(text: entry.text, turnId: entry.turnId, itemId: itemId) : .userMessage(text: entry.text, attachments: nil, turnId: entry.turnId, steerId: nil, deliveryState: nil, processed: nil) ) } @@ -338,27 +651,64 @@ private func replaceTruncatedTextEnvelope( ) -> Bool { guard let fallbackIdentity = workTextRoleTurnKey(for: fallback), let fallbackText = workTextEnvelopeText(fallback), - !fallbackText.isEmpty + !fallbackText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return false } + if textEnvelopeAlreadyPresentForBackfill(fallback: fallback, merged: transcript) { + let beforeCount = transcript.count + transcript.removeAll { candidate in + guard workTextRoleTurnKey(for: candidate) == fallbackIdentity, + let candidateText = workTextEnvelopeText(candidate) + else { return false } + return workTextShouldReplaceWithCanonicalFallback(candidateText: candidateText, fallbackText: fallbackText) + } + let removedStaleCandidate = transcript.count != beforeCount + if removedStaleCandidate { + transcript.append(fallback) + } + return removedStaleCandidate + } + guard let index = transcript.firstIndex(where: { candidate in guard workTextRoleTurnKey(for: candidate) == fallbackIdentity, let candidateText = workTextEnvelopeText(candidate) else { return false } - let normalizedCandidate = candidateText.trimmingCharacters(in: .whitespacesAndNewlines) - let normalizedFallback = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !normalizedCandidate.isEmpty, normalizedFallback.count > normalizedCandidate.count else { - return false - } - return normalizedFallback.contains(normalizedCandidate) - || normalizedFallback.hasSuffix(normalizedCandidate) - || normalizedFallback.hasPrefix(normalizedCandidate) + return workTextShouldReplaceWithCanonicalFallback(candidateText: candidateText, fallbackText: fallbackText) }) else { return false } transcript[index] = fallback return true } +private func workTextShouldReplaceWithCanonicalFallback(candidateText: String, fallbackText: String) -> Bool { + let normalizedCandidate = candidateText.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedFallback = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedCandidate.isEmpty, + !normalizedFallback.isEmpty, + normalizedCandidate != normalizedFallback + else { return false } + + if workTextIsTruncatedVersion(normalizedCandidate, of: normalizedFallback) { + return true + } + + return workStreamingExistingOnlyAddsRepeatedIncomingTail( + existing: normalizedCandidate, + incoming: normalizedFallback + ) +} + +private func workTextIsTruncatedVersion(_ candidateText: String, of fallbackText: String) -> Bool { + let normalizedCandidate = candidateText.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedFallback = fallbackText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !normalizedCandidate.isEmpty, normalizedFallback.count > normalizedCandidate.count else { + return false + } + return normalizedFallback.contains(normalizedCandidate) + || normalizedFallback.hasSuffix(normalizedCandidate) + || normalizedFallback.hasPrefix(normalizedCandidate) +} + /// Identity key for a user/assistant text envelope used for backfill dedup. /// Fallback entries set `itemId: nil`, live envelopes carry an SDK-assigned /// id — so plain equality on merge keys would treat "same message, different @@ -453,23 +803,34 @@ private func textEnvelopeAlreadyPresentForBackfill( !fallbackText.isEmpty else { return false } let fallbackTurnId = workTextTurnId(for: fallback) + let fallbackRole = workTextRole(for: fallback) for candidate in merged { - guard workTextRole(for: candidate) == workTextRole(for: fallback), - let candidateText = workTextEnvelopeText(candidate)?.trimmingCharacters(in: .whitespacesAndNewlines), - candidateText == fallbackText + guard workTextRole(for: candidate) == fallbackRole, + let candidateText = workTextEnvelopeText(candidate)?.trimmingCharacters(in: .whitespacesAndNewlines) else { continue } let candidateTurnId = workTextTurnId(for: candidate) if !fallbackTurnId.isEmpty, !candidateTurnId.isEmpty { guard fallbackTurnId == candidateTurnId else { continue } - return true + } else { + guard candidate.timestamp == fallback.timestamp else { continue } } - if candidate.timestamp == fallback.timestamp { + if candidateText == fallbackText || + workTextContainsBackfillFragment(candidateText: candidateText, fallbackText: fallbackText) { return true } } return false } +private func workTextContainsBackfillFragment(candidateText: String, fallbackText: String) -> Bool { + guard candidateText.count >= fallbackText.count, + fallbackText.count >= workStreamingMergeMinimumRepeatedTailLength + else { return false } + return candidateText.contains(fallbackText) + || candidateText.hasSuffix(fallbackText) + || candidateText.hasPrefix(fallbackText) +} + private func workTextRole(for envelope: WorkChatEnvelope) -> String? { switch envelope.event { case .userMessage: @@ -562,7 +923,6 @@ func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [Wor func mergeWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope]) -> [WorkChatEnvelope] { guard !live.isEmpty else { return base } - guard !base.isEmpty else { return live } var merged: [WorkChatEnvelope] = [] merged.reserveCapacity(base.count + live.count) @@ -571,7 +931,7 @@ func mergeWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope] for envelope in base + live { let key = workChatEnvelopeMergeKey(envelope) if let existing = indexByKey[key] { - merged[existing] = envelope + merged[existing] = mergedWorkChatEnvelope(existing: merged[existing], incoming: envelope) } else { indexByKey[key] = merged.count merged.append(envelope) @@ -586,6 +946,73 @@ func mergeWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope] } } +func appendWorkChatTranscripts(base: [WorkChatEnvelope], live: [WorkChatEnvelope]) -> [WorkChatEnvelope] { + guard !live.isEmpty else { return base } + + var merged = base + merged.reserveCapacity(base.count + live.count) + var indexByKey: [String: Int] = [:] + indexByKey.reserveCapacity(base.count + live.count) + for (index, envelope) in merged.enumerated() { + indexByKey[workChatEnvelopeMergeKey(envelope)] = index + } + + var needsSort = false + for envelope in live { + let key = workChatEnvelopeMergeKey(envelope) + if let existing = indexByKey[key] { + let previous = merged[existing] + merged[existing] = mergedWorkChatEnvelope(existing: previous, incoming: envelope) + if previous.timestamp != envelope.timestamp || previous.sequence != envelope.sequence { + needsSort = true + } + continue + } + + if let last = merged.last, !workChatEnvelopeSortPrecedesOrMatches(last, envelope) { + needsSort = true + } + indexByKey[key] = merged.count + merged.append(envelope) + } + + guard needsSort else { return merged } + return merged.sorted { lhs, rhs in + if lhs.timestamp == rhs.timestamp { + return (lhs.sequence ?? 0) < (rhs.sequence ?? 0) + } + return lhs.timestamp < rhs.timestamp + } +} + +private func workChatEnvelopeSortPrecedesOrMatches(_ lhs: WorkChatEnvelope, _ rhs: WorkChatEnvelope) -> Bool { + if lhs.timestamp == rhs.timestamp { + return (lhs.sequence ?? 0) <= (rhs.sequence ?? 0) + } + return lhs.timestamp <= rhs.timestamp +} + +private func mergedWorkChatEnvelope(existing: WorkChatEnvelope, incoming: WorkChatEnvelope) -> WorkChatEnvelope { + switch (existing.event, incoming.event) { + case ( + .assistantText(let existingText, let existingTurnId, let existingItemId), + .assistantText(let incomingText, let incomingTurnId, let incomingItemId) + ): + return WorkChatEnvelope( + sessionId: incoming.sessionId, + timestamp: incoming.timestamp, + sequence: incoming.sequence, + event: .assistantText( + text: mergeWorkStreamingText(existingText, incomingText), + turnId: incomingTurnId ?? existingTurnId, + itemId: incomingItemId ?? existingItemId + ) + ) + default: + return incoming + } +} + enum WorkPendingInputItem: Identifiable, Equatable { case approval(WorkPendingApprovalModel) case question(WorkPendingQuestionModel) @@ -1104,7 +1531,26 @@ func sortedWorkChatEnvelopes(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvel } func workChatEnvelopeMergeKey(_ envelope: WorkChatEnvelope) -> String { - "\(envelope.sessionId)|\(envelope.timestamp)|\(workChatEventMergeKey(envelope.event))" + if let stableKey = workChatEnvelopeStableUpdateKey(envelope) { + return stableKey + } + return "\(envelope.sessionId)|\(envelope.timestamp)|\(workChatEventMergeKey(envelope.event))" +} + +private func workChatEnvelopeStableUpdateKey(_ envelope: WorkChatEnvelope) -> String? { + switch envelope.event { + case .assistantText(_, let turnId, let itemId): + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard !normalizedItemId.isEmpty else { return nil } + return [ + envelope.sessionId, + "assistant_text", + turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + normalizedItemId, + ].joined(separator: "|") + default: + return nil + } } func workChatEventMergeKey(_ event: WorkChatEvent) -> String { @@ -1118,7 +1564,11 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { let attachmentDigest = (attachments ?? []).map { "\($0.type):\($0.path)" }.joined(separator: ",") return ["user_message", turnId ?? "", steerId ?? "", deliveryState ?? "", processed.map { $0 ? "1" : "0" } ?? "", attachmentDigest, text].joined(separator: "|") case .assistantText(let text, let turnId, let itemId): - return ["text", turnId ?? "", itemId ?? "", text].joined(separator: "|") + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !normalizedItemId.isEmpty { + return ["text", turnId ?? "", normalizedItemId].joined(separator: "|") + } + return ["text", turnId ?? "", text].joined(separator: "|") case .toolCall(let tool, let argsText, let itemId, let parentItemId, let turnId): return ["tool_call", turnId ?? "", itemId, parentItemId ?? "", tool, argsText].joined(separator: "|") case .toolResult(let tool, let resultText, let itemId, let parentItemId, let turnId, let status): @@ -1128,12 +1578,12 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { case .plan(let steps, let explanation, let turnId): let stepDigest = steps.map { "\($0.status):\($0.text)" }.joined(separator: "\n") return ["plan", turnId ?? "", explanation ?? "", stepDigest].joined(separator: "|") - case .subagentStarted(let taskId, let description, let background, let turnId): - return ["subagent_started", turnId ?? "", taskId, description, background ? "1" : "0"].joined(separator: "|") - case .subagentProgress(let taskId, let description, let summary, let toolName, let turnId): - return ["subagent_progress", turnId ?? "", taskId, description ?? "", summary, toolName ?? ""].joined(separator: "|") - case .subagentResult(let taskId, let status, let summary, let turnId): - return ["subagent_result", turnId ?? "", taskId, status, summary].joined(separator: "|") + case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let turnId): + return ["subagent_started", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", description, background ? "1" : "0"].joined(separator: "|") + case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, let toolName, let turnId): + return ["subagent_progress", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", description ?? "", summary, toolName ?? ""].joined(separator: "|") + case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let turnId): + return ["subagent_result", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", status, summary].joined(separator: "|") case .structuredQuestion(let question, let options, let itemId, let turnId): let digest = options.map { "\($0.label)\t\($0.value)\t\($0.description ?? "")\t\($0.recommended ? "1" : "0")" }.joined(separator: "\n") return ["structured_question", turnId ?? "", itemId, question, digest].joined(separator: "|") diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index 1a5cb6b0e..ec2f67ed4 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -71,12 +71,37 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { case .plan(let steps, let explanation, let turnId): let mapped = steps.map { WorkPlanStep(text: $0.text, status: $0.status) } return .plan(steps: mapped, explanation: explanation, turnId: turnId) - case .subagentStarted(let taskId, let description, let background, let turnId): - return .subagentStarted(taskId: taskId, description: description, background: background ?? false, turnId: turnId) - case .subagentProgress(let taskId, let description, let summary, _, let lastToolName, let turnId): - return .subagentProgress(taskId: taskId, description: description, summary: summary, toolName: lastToolName, turnId: turnId) - case .subagentResult(let taskId, let status, let summary, _, let turnId): - return .subagentResult(taskId: taskId, status: status.rawValue, summary: summary, turnId: turnId) + case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let turnId): + return .subagentStarted( + taskId: taskId, + agentId: agentId, + agentType: agentType, + parentToolUseId: parentToolUseId, + description: description, + background: background ?? false, + turnId: turnId + ) + case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, _, let lastToolName, let turnId): + return .subagentProgress( + taskId: taskId, + agentId: agentId, + agentType: agentType, + parentToolUseId: parentToolUseId, + description: description, + summary: summary, + toolName: lastToolName, + turnId: turnId + ) + case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, _, let turnId): + return .subagentResult( + taskId: taskId, + agentId: agentId, + agentType: agentType, + parentToolUseId: parentToolUseId, + status: status.rawValue, + summary: summary, + turnId: turnId + ) case .structuredQuestion(let question, let options, let itemId, let turnId): let mapped = (options ?? []).map { opt in WorkPendingQuestionOption( diff --git a/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift b/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift index 781f16463..fef95d816 100644 --- a/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift +++ b/apps/ios/ADE/Views/Work/WorkMarkdownParsing.swift @@ -6,7 +6,7 @@ enum WorkMarkdownBlockKind: Equatable { case paragraph(String) case heading(Int, String) case unorderedList([String]) - case orderedList([String]) + case orderedList(start: Int, items: [String]) case blockquote([String]) case table(headers: [String], rows: [[String]]) case code(language: String?, code: String) @@ -20,8 +20,8 @@ enum WorkMarkdownBlockKind: Equatable { return "heading|\(level)|\(text)" case .unorderedList(let items): return "unorderedList|\(items.joined(separator: "\u{001F}"))" - case .orderedList(let items): - return "orderedList|\(items.joined(separator: "\u{001F}"))" + case .orderedList(let start, let items): + return "orderedList|\(start)|\(items.joined(separator: "\u{001F}"))" case .blockquote(let lines): return "blockquote|\(lines.joined(separator: "\u{001F}"))" case .table(let headers, let rows): @@ -222,7 +222,7 @@ private func parseMarkdownBlocksInternal(_ markdown: String) -> [WorkMarkdownBlo } if let ordered = parseList(startingAt: index, in: lines, ordered: true) { - appendBlock(.orderedList(ordered.items)) + appendBlock(.orderedList(start: ordered.startNumber ?? 1, items: ordered.items)) index = ordered.nextIndex continue } @@ -248,18 +248,22 @@ private func parseMarkdownBlocksInternal(_ markdown: String) -> [WorkMarkdownBlo return blocks } -func parseList(startingAt index: Int, in lines: [String], ordered: Bool) -> (items: [String], nextIndex: Int)? { +func parseList(startingAt index: Int, in lines: [String], ordered: Bool) -> (items: [String], nextIndex: Int, startNumber: Int?)? { guard index < lines.count else { return nil } guard let regex = workMarkdownListRegex(ordered: ordered) else { return nil } var cursor = index var items: [String] = [] + var startNumber: Int? while cursor < lines.count { let line = lines[cursor].trimmingCharacters(in: .whitespaces) guard let item = markdownListItemText(line, regex: regex) else { break } + if ordered, startNumber == nil { + startNumber = markdownOrderedListItemNumber(line) + } items.append(item) cursor += 1 } - return items.isEmpty ? nil : (items, cursor) + return items.isEmpty ? nil : (items, cursor, startNumber) } func isMarkdownListItem(_ line: String, ordered: Bool) -> Bool { @@ -289,6 +293,16 @@ func markdownListItemText(_ line: String, regex: NSRegularExpression) -> String? return text } +func markdownOrderedListItemNumber(_ line: String) -> Int? { + guard let regex = ADECodeRenderingCache.shared.regex(for: #"^(\d+)\.\s+"#) else { return nil } + let range = NSRange(location: 0, length: (line as NSString).length) + guard let match = regex.firstMatch(in: line, options: [], range: range), + match.numberOfRanges > 1, + let numberRange = Range(match.range(at: 1), in: line) + else { return nil } + return Int(String(line[numberRange])) +} + func workMarkdownListRegex(ordered: Bool) -> NSRegularExpression? { let pattern = ordered ? #"^\d+\.\s+"# : #"^[-*+]\s+"# return ADECodeRenderingCache.shared.regex(for: pattern) diff --git a/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift b/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift index 95d7bf135..7ded11de6 100644 --- a/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift +++ b/apps/ios/ADE/Views/Work/WorkMarkdownViews.swift @@ -10,6 +10,7 @@ struct WorkInlineMarkdownText: View { .foregroundStyle(ADEColor.textPrimary) .tint(ADEColor.accent) .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) } } @@ -32,57 +33,65 @@ struct WorkMarkdownRenderer: View { var body: some View { VStack(alignment: .leading, spacing: 10) { ForEach(blocks) { block in - switch block.kind { - case .paragraph(let text): - WorkInlineMarkdownText(text: text) - case .heading(let level, let text): - WorkInlineMarkdownText(text: text) - .font(headingFont(level: level)) - case .unorderedList(let items): - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(items.enumerated()), id: \.offset) { _, item in - HStack(alignment: .top, spacing: 8) { - Text("•") - .foregroundStyle(ADEColor.accent) - WorkInlineMarkdownText(text: item) - } - } + WorkMarkdownBlockView(block: block) + } + } + } +} + +struct WorkMarkdownBlockView: View { + let block: WorkMarkdownBlock + + var body: some View { + switch block.kind { + case .paragraph(let text): + WorkInlineMarkdownText(text: text) + case .heading(let level, let text): + WorkInlineMarkdownText(text: text) + .font(headingFont(level: level)) + case .unorderedList(let items): + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(items.enumerated()), id: \.offset) { _, item in + HStack(alignment: .top, spacing: 8) { + Text("•") + .foregroundStyle(ADEColor.accent) + WorkInlineMarkdownText(text: item) } - case .orderedList(let items): - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(items.enumerated()), id: \.offset) { index, item in - HStack(alignment: .top, spacing: 8) { - Text("\(index + 1).") - .foregroundStyle(ADEColor.accent) - WorkInlineMarkdownText(text: item) - } - } + } + } + case .orderedList(let start, let items): + VStack(alignment: .leading, spacing: 6) { + ForEach(Array(items.enumerated()), id: \.offset) { index, item in + HStack(alignment: .top, spacing: 8) { + Text("\(start + index).") + .foregroundStyle(ADEColor.accent) + WorkInlineMarkdownText(text: item) } - case .blockquote(let lines): - HStack(alignment: .top, spacing: 10) { - Rectangle() - .fill(ADEColor.accent.opacity(0.55)) - .frame(width: 3) - VStack(alignment: .leading, spacing: 4) { - ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - WorkInlineMarkdownText(text: line) - } - } + } + } + case .blockquote(let lines): + HStack(alignment: .top, spacing: 10) { + Rectangle() + .fill(ADEColor.accent.opacity(0.55)) + .frame(width: 3) + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + WorkInlineMarkdownText(text: line) } - .padding(10) - .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) - case .table(let headers, let rows): - WorkMarkdownTable(headers: headers, rows: rows) - case .code(let language, let code): - WorkCodeBlockView(language: language, code: code) - case .rule: - Divider() } } + .padding(10) + .background(ADEColor.surfaceBackground.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + case .table(let headers, let rows): + WorkMarkdownTable(headers: headers, rows: rows) + case .code(let language, let code): + WorkCodeBlockView(language: language, code: code) + case .rule: + Divider() } } - func headingFont(level: Int) -> Font { + private func headingFont(level: Int) -> Font { switch level { case 1: return .title3.weight(.bold) case 2: return .headline.weight(.bold) diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 815817d52..83d7e075b 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -384,6 +384,74 @@ enum WorkTimelinePayload: Equatable { case pendingModelSelection(WorkPendingModelSelectionModel) } +struct WorkAssistantMarkdownBlockRenderModel: Identifiable, Equatable { + let id: String + let messageId: String + let turnId: String? + let itemId: String? + let block: WorkMarkdownBlock + + static func == (lhs: WorkAssistantMarkdownBlockRenderModel, rhs: WorkAssistantMarkdownBlockRenderModel) -> Bool { + lhs.id == rhs.id + && lhs.messageId == rhs.messageId + && lhs.turnId == rhs.turnId + && lhs.itemId == rhs.itemId + && lhs.block == rhs.block + } +} + +struct WorkAssistantMonospacedRenderModel: Identifiable, Equatable { + let id: String + let messageId: String + let turnId: String? + let itemId: String? + let text: String + let accessibilityLabel: String + + static func == (lhs: WorkAssistantMonospacedRenderModel, rhs: WorkAssistantMonospacedRenderModel) -> Bool { + lhs.id == rhs.id + && lhs.messageId == rhs.messageId + && lhs.turnId == rhs.turnId + && lhs.itemId == rhs.itemId + && lhs.text == rhs.text + && lhs.accessibilityLabel == rhs.accessibilityLabel + } +} + +struct WorkAssistantMessageControlsModel: Identifiable, Equatable { + let id: String + let messageId: String + let summaryText: String + let visibleLineCount: Int + let totalLineCount: Int + let canShowMore: Bool + let nextLineBudget: Int + + static func == (lhs: WorkAssistantMessageControlsModel, rhs: WorkAssistantMessageControlsModel) -> Bool { + lhs.id == rhs.id + && lhs.messageId == rhs.messageId + && lhs.summaryText == rhs.summaryText + && lhs.visibleLineCount == rhs.visibleLineCount + && lhs.totalLineCount == rhs.totalLineCount + && lhs.canShowMore == rhs.canShowMore + && lhs.nextLineBudget == rhs.nextLineBudget + } +} + +enum WorkTimelineRenderPayload: Equatable { + case entry(WorkTimelineEntry) + case assistantMarkdownBlock(WorkAssistantMarkdownBlockRenderModel) + case assistantMonospaced(WorkAssistantMonospacedRenderModel) + case assistantControls(WorkAssistantMessageControlsModel) +} + +struct WorkTimelineRenderEntry: Identifiable, Equatable { + let id: String + let sourceEntryId: String + let timestamp: String + let payload: WorkTimelineRenderPayload +} + /// One member of a `WorkToolGroupModel`. Carries enough context for the /// collapsed mini-row (icon, title, status) and hands the full payload back /// when the group expands into per-entry cards. @@ -461,6 +529,10 @@ struct WorkTurnEndMarker: Equatable { let turnId: String let time: String let workedDurationLabel: String + let status: String + let provider: String + let modelLabel: String + let modelId: String? } struct WorkTimelineEntry: Identifiable, Equatable { @@ -471,20 +543,36 @@ struct WorkTimelineEntry: Identifiable, Equatable { } struct WorkSubagentSnapshot: Identifiable, Equatable { - enum Status: Equatable { case running, succeeded, failed } + enum Status: Equatable { case running, succeeded, failed, stopped } let taskId: String + let agentId: String? + let agentType: String? + let parentToolUseId: String? let description: String let background: Bool let status: Status let lastToolName: String? let latestSummary: String? let turnId: String? + let startedAt: String? + let updatedAt: String? + + var id: String { taskId } +} + +struct WorkSubagentSelection: Identifiable, Equatable { + let taskId: String + let agentId: String? + let name: String + let status: WorkSubagentSnapshot.Status + let background: Bool var id: String { taskId } } struct WorkChatTimelineSnapshot: Equatable { + var signature: Int var pendingInputs: [WorkPendingInputItem] var pendingSteers: [WorkPendingSteerModel] var toolCards: [WorkToolCardModel] @@ -493,9 +581,14 @@ struct WorkChatTimelineSnapshot: Equatable { var fileChangeCards: [WorkFileChangeCardModel] var subagentSnapshots: [WorkSubagentSnapshot] var transcriptIndicatesActiveTurn: Bool + var transcriptLatestTurnEnded: Bool + var transcriptHasInterruptibleActivity: Bool + var latestTranscriptTimestamp: String? + var latestMessageAssistantId: String? var timeline: [WorkTimelineEntry] static let empty = WorkChatTimelineSnapshot( + signature: 0, pendingInputs: [], pendingSteers: [], toolCards: [], @@ -504,8 +597,16 @@ struct WorkChatTimelineSnapshot: Equatable { fileChangeCards: [], subagentSnapshots: [], transcriptIndicatesActiveTurn: false, + transcriptLatestTurnEnded: false, + transcriptHasInterruptibleActivity: false, + latestTranscriptTimestamp: nil, + latestMessageAssistantId: nil, timeline: [] ) + + static func == (lhs: WorkChatTimelineSnapshot, rhs: WorkChatTimelineSnapshot) -> Bool { + lhs.signature == rhs.signature + } } struct WorkPlanStep: Equatable, Hashable { @@ -619,7 +720,23 @@ func workRemoveLoadedArtifactTempFile(_ content: WorkLoadedArtifactContent?) { } struct WorkChatEnvelope: Identifiable, Equatable { - var id: String { "\(sessionId):\(sequence ?? -1):\(timestamp):\(event.typeKey)" } + var id: String { + switch event { + case .assistantText(_, let turnId, let itemId): + let normalizedItemId = itemId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !normalizedItemId.isEmpty { + return [ + sessionId, + "assistant-text", + turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "", + normalizedItemId, + ].joined(separator: ":") + } + default: + break + } + return "\(sessionId):\(sequence ?? -1):\(timestamp):\(event.typeKey)" + } let sessionId: String let timestamp: String let sequence: Int? @@ -633,9 +750,9 @@ enum WorkChatEvent: Equatable { case toolResult(tool: String, resultText: String, itemId: String, parentItemId: String?, turnId: String?, status: WorkToolCardStatus) case activity(kind: String, detail: String?, turnId: String?) case plan(steps: [WorkPlanStep], explanation: String?, turnId: String?) - case subagentStarted(taskId: String, description: String, background: Bool, turnId: String?) - case subagentProgress(taskId: String, description: String?, summary: String, toolName: String?, turnId: String?) - case subagentResult(taskId: String, status: String, summary: String, turnId: String?) + case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String, background: Bool, turnId: String?) + case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String?, summary: String, toolName: String?, turnId: String?) + case subagentResult(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, status: String, summary: String, turnId: String?) case structuredQuestion(question: String, options: [WorkPendingQuestionOption], itemId: String, turnId: String?) case approvalRequest(description: String, detail: String?, itemId: String, turnId: String?) case pendingInputResolved(itemId: String, resolution: String, turnId: String?) diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 2ca2d444e..80cdbcc57 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -867,6 +867,51 @@ func buildWorkToolCards( return orderedIds.compactMap { cards[$0] } } +func buildWorkMobileTimelineToolCards( + from transcript: [WorkChatEnvelope], + suppressedPendingItemIds: Set = [] +) -> [WorkToolCardModel] { + var cards: [String: WorkToolCardModel] = [:] + var orderedIds: [String] = [] + + for envelope in transcript { + switch envelope.event { + case .toolCall(let tool, let argsText, let itemId, _, _): + guard isQuestionInputToolName(tool), + !suppressedPendingItemIds.contains(itemId), + pendingWorkQuestionFromAskUserToolCall(argsText: argsText, itemId: itemId) == nil + else { continue } + if cards[itemId] == nil { + orderedIds.append(itemId) + } + cards[itemId] = WorkToolCardModel( + id: itemId, + toolName: tool, + status: .running, + startedAt: envelope.timestamp, + completedAt: nil, + argsText: nonEmpty(argsText), + resultText: cards[itemId]?.resultText + ) + case .toolResult(let tool, let resultText, let itemId, _, _, let status): + guard isQuestionInputToolName(tool), let existing = cards[itemId] else { continue } + cards[itemId] = WorkToolCardModel( + id: itemId, + toolName: existing.toolName, + status: status, + startedAt: existing.startedAt, + completedAt: envelope.timestamp, + argsText: existing.argsText, + resultText: nonEmpty(resultText) + ) + default: + continue + } + } + + return orderedIds.compactMap { cards[$0] } +} + func parseANSISegments(_ input: String) -> [ANSISegment] { var segments: [ANSISegment] = [] var buffer = "" diff --git a/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift new file mode 100644 index 000000000..0b64972f2 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift @@ -0,0 +1,333 @@ +import SwiftUI +import UIKit + +func workPlanResolvedProvider(source: String, fallbackProvider: String?) -> String? { + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? fallbackProvider : trimmed +} + +struct WorkPlanAccentGradient: View { + let accent: Color + + var body: some View { + LinearGradient( + colors: [Color.clear, accent.opacity(0.55), Color.clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(height: 1) + } +} + +struct WorkPlanProviderHeader: View { + let plan: WorkPendingPlanApprovalModel + var fallbackProvider: String? = nil + var showsChevron: Bool = true + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + HStack(spacing: 7) { + WorkProviderBareLogo( + provider: resolvedProvider, + fallbackSymbol: providerIcon(resolvedProvider ?? ""), + tint: accent, + size: 16 + ) + + Text(plan.providerHeaderVerb(fallbackProvider: fallbackProvider)) + .font(.caption.weight(.semibold)) + .foregroundStyle(accent) + .lineLimit(1) + .truncationMode(.tail) + + if showsChevron { + Image(systemName: "chevron.up") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(accent.opacity(0.55)) + } + } + } +} + +struct WorkPlanComposerCopyButton: View { + let text: String + var accent: Color + @State private var copied = false + + var body: some View { + Button { + UIPasteboard.general.string = text + copied = true + Task { @MainActor in + try? await Task.sleep(nanoseconds: 1_400_000_000) + copied = false + } + } label: { + Label(copied ? "Copied" : "Copy plan", systemImage: copied ? "checkmark" : "doc.on.doc") + .font(.caption.weight(.semibold)) + .foregroundStyle(copied ? ADEColor.success : accent) + } + .buttonStyle(.plain) + .accessibilityLabel(copied ? "Copied to clipboard" : "Copy plan") + } +} + +struct WorkPlanRejectFeedbackSection: View { + @Binding var feedbackText: String + let busy: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Text("Feedback (optional)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + + TextField("Describe what to change...", text: $feedbackText, axis: .vertical) + .lineLimit(2...4) + .adeInsetField(cornerRadius: 12, padding: 10) + .disabled(busy) + } + .transition(.opacity.combined(with: .move(edge: .top))) + } +} + +struct WorkPlanApprovalActionRow: View { + @Binding var rejectFlowVisible: Bool + @Binding var feedbackText: String + let busy: Bool + let onDecision: @MainActor (AgentChatApprovalDecision, String?) async -> Void + + var body: some View { + HStack(spacing: 8) { + if rejectFlowVisible { + Button { + let feedback = feedbackText.trimmingCharacters(in: .whitespacesAndNewlines) + Task { await onDecision(.decline, feedback.isEmpty ? nil : feedback) } + } label: { + Text("Send Rejection") + .font(.caption.weight(.semibold)) + .foregroundStyle(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ADEColor.danger.opacity(0.82), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Send plan rejection") + + Button { + withAnimation(.smooth(duration: 0.2)) { + rejectFlowVisible = false + feedbackText = "" + } + } label: { + Text("Cancel") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 8) + .background(ADEColor.surfaceBackground.opacity(0.70), in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(ADEColor.border.opacity(0.22), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(busy) + .accessibilityLabel("Cancel plan rejection") + } + } + .animation(.smooth(duration: 0.2), value: rejectFlowVisible) + } +} + +struct WorkPlanComposerStrip: View { + let plan: WorkPendingPlanApprovalModel + let busy: Bool + var fallbackProvider: String? = nil + let onDecision: @MainActor (AgentChatApprovalDecision, String?) async -> Void + + @State private var rejectFlowVisible = false + @State private var feedbackText = "" + @State private var isPlanExpanded = false + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + VStack(alignment: .leading, spacing: rejectFlowVisible ? 8 : 0) { + compactRow + .frame(height: workChatSubagentActivePopupHeight) + + if rejectFlowVisible { + WorkPlanRejectFeedbackSection(feedbackText: $feedbackText, busy: busy) + WorkPlanApprovalActionRow( + rejectFlowVisible: $rejectFlowVisible, + feedbackText: $feedbackText, + busy: busy, + onDecision: onDecision + ) + } + } + .padding(.horizontal, 10) + .padding(.vertical, rejectFlowVisible ? 9 : 0) + .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: $isPlanExpanded) { + WorkPlanFullScreenView(plan: plan, fallbackProvider: fallbackProvider) + .presentationDetents([.large]) + .presentationDragIndicator(.visible) + } + .animation(.smooth(duration: 0.2), value: rejectFlowVisible) + .accessibilityElement(children: .contain) + .accessibilityLabel("\(workChatSurfaceProviderName(resolvedProvider)) plan ready") + } + + private var compactRow: some View { + HStack(spacing: 8) { + Button { + isPlanExpanded = true + } label: { + WorkPlanProviderHeader(plan: plan, fallbackProvider: fallbackProvider) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(plan.providerHeaderVerb(fallbackProvider: fallbackProvider)). Review full plan.") + .accessibilityHint("Opens the full plan sheet.") + + if !rejectFlowVisible { + compactActionButton( + title: "Approve", + icon: "checkmark", + tint: ADEColor.success, + filled: true, + accessibilityLabel: "Approve plan and begin implementation" + ) { + Task { await onDecision(.accept, nil) } + } + .disabled(busy) + + compactActionButton( + title: "Reject", + icon: "xmark", + tint: ADEColor.danger, + filled: false, + accessibilityLabel: "Reject plan and request revisions" + ) { + withAnimation(.smooth(duration: 0.2)) { + rejectFlowVisible = true + } + } + .disabled(busy) + } + } + } + + private func compactActionButton( + title: String, + icon: String, + tint: Color, + filled: Bool, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> 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) + } +} + +struct WorkPlanFullScreenView: View { + let plan: WorkPendingPlanApprovalModel + var fallbackProvider: String? = nil + @Environment(\.dismiss) private var dismiss + + private var resolvedProvider: String? { + workPlanResolvedProvider(source: plan.source, fallbackProvider: fallbackProvider) + } + + private var accent: Color { + ADEColor.providerChatAccent(for: resolvedProvider) + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + WorkPlanProviderHeader(plan: plan, fallbackProvider: fallbackProvider, showsChevron: false) + + if !plan.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(plan.title) + .font(.title3.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + } + + WorkMarkdownRenderer(markdown: plan.planText) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(20) + } + .scrollIndicators(.hidden) + .background(workChatCanvasBackground.ignoresSafeArea()) + .navigationTitle("Plan ready") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + } + .accessibilityLabel("Dismiss plan") + } + ToolbarItem(placement: .topBarTrailing) { + WorkPlanComposerCopyButton(text: plan.planText, accent: accent) + } + } + } + } +} diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index a329325f5..649ddbac4 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -428,13 +428,24 @@ private enum WorkPreviewData { #Preview("Work chat") { NavigationStack { WorkChatSessionView( - session: WorkPreviewData.terminalSession, - chatSummary: WorkPreviewData.chatSummary, + session: WorkChatSessionRenderContext(WorkPreviewData.terminalSession), + chatSummaryContext: WorkChatSummaryRenderContext(WorkPreviewData.chatSummary), transcript: WorkPreviewData.transcript, + transcriptRenderSignature: workChatEnvelopeListRenderSignature(WorkPreviewData.transcript), fallbackEntries: [], + fallbackEntriesRenderSignature: workFallbackEntriesRenderSignature([]), artifacts: [WorkPreviewData.artifact], + artifactsRenderSignature: workArtifactSummariesRenderSignature([WorkPreviewData.artifact]), optimisticPendingSteers: [], + optimisticPendingSteersRenderSignature: workPendingSteersRenderSignature([]), localEchoMessages: [], + localEchoMessagesRenderSignature: workLocalEchoMessagesRenderSignature([]), + expandedToolCardIdsSnapshot: ["cmd-1"], + expandedToolCardIdsRenderSignature: workExpandedToolCardIdsRenderSignature(["cmd-1"]), + artifactContentRenderSignature: workLoadedArtifactContentRenderSignature([:]), + artifactDrawerPresentedSnapshot: false, + sendingSnapshot: false, + errorMessageSnapshot: nil, expandedToolCardIds: Binding>.constant(["cmd-1"]), artifactContent: .constant([:]), fullscreenImage: Binding.constant(nil), @@ -444,14 +455,16 @@ private enum WorkPreviewData { sending: .constant(false), errorMessage: .constant(nil), isLive: true, + hostUnreachable: false, canComposeMessages: true, canSendMessages: true, sendWillQueue: false, + sendWillQueueIsReconnect: false, transitionNamespace: nil, onOpenLane: {}, onSend: { _ in true }, onInterrupt: {}, - onApproveRequest: { _, _ in }, + onApproveRequest: { _, _, _ in }, onRespondToQuestion: { _, _, _, _ in }, onSubmitQuestionAnswers: { _, _, _ in }, onDeclineQuestion: { _ in }, @@ -468,7 +481,13 @@ private enum WorkPreviewData { onSelectModel: { _ in }, onSelectRuntimeMode: { _ in true }, onSelectEffort: { _ in }, - onSelectCodexFastMode: { _ in true } + onSelectCodexFastMode: { _ in true }, + resolvedSessionStatus: normalizedWorkChatSessionStatus( + session: WorkPreviewData.terminalSession, + summary: WorkPreviewData.chatSummary + ), + lanesRenderSignature: workLaneListRenderSignature([]), + subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature([]) ) } .environmentObject(WorkPreviewData.syncService) diff --git a/apps/ios/ADE/Views/Work/WorkRootComponents.swift b/apps/ios/ADE/Views/Work/WorkRootComponents.swift index 85c5c66b7..fb614aa84 100644 --- a/apps/ios/ADE/Views/Work/WorkRootComponents.swift +++ b/apps/ios/ADE/Views/Work/WorkRootComponents.swift @@ -240,7 +240,6 @@ struct WorkFilterChip: View { selected ? tint.opacity(0.14) : ADEColor.surfaceBackground.opacity(0.5), in: Capsule(style: .continuous) ) - .glassEffect() .overlay( Capsule(style: .continuous) .stroke(selected ? tint.opacity(0.32) : ADEColor.glassBorder, lineWidth: 0.6) @@ -278,8 +277,7 @@ struct WorkFilterMenuLabel: View { .padding(.horizontal, 10) .padding(.vertical, 8) .frame(maxWidth: .infinity) - .background(ADEColor.surfaceBackground.opacity(0.55), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 10)) + .background(ADEColor.surfaceBackground.opacity(0.78), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 10, style: .continuous) .stroke(ADEColor.glassBorder, lineWidth: 0.5) @@ -317,7 +315,6 @@ struct WorkSidebarSectionHeader: View { .padding(.horizontal, 7) .padding(.vertical, 2) .background(ADEColor.surfaceBackground.opacity(0.65), in: Capsule()) - .glassEffect() } .padding(.horizontal, 4) .padding(.vertical, 8) @@ -462,6 +459,7 @@ struct WorkSessionListRow: View { let chatSummary: AgentChatSessionSummary? let isArchived: Bool let transitionNamespace: Namespace.ID? + var compact: Bool = false @Binding var selectedSessionId: String? let isSelecting: Bool let isChecked: Bool @@ -477,6 +475,7 @@ struct WorkSessionListRow: View { let onGoToLane: (TerminalSessionSummary) -> Void var body: some View { + let rowStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) Button { if isSelecting { onToggleSelect(session) @@ -496,10 +495,13 @@ struct WorkSessionListRow: View { lane: lane, pullRequest: pullRequest, chatSummary: chatSummary, + status: rowStatus, isArchived: isArchived, transitionNamespace: transitionNamespace, - isSelectedTransitionSource: selectedSessionId == session.id + isSelectedTransitionSource: selectedSessionId == session.id, + compact: compact ) + .equatable() } } .buttonStyle(.plain) @@ -511,7 +513,7 @@ struct WorkSessionListRow: View { } ) .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if shouldShowStopRuntimeAction { + if isStoppableRuntimeStatus(session, status: rowStatus) { Button("Stop runtime", role: .destructive) { onStopRuntime(session) } @@ -534,7 +536,7 @@ struct WorkSessionListRow: View { } label: { Label("Rename", systemImage: "pencil") } - if shouldShowStopRuntimeAction { + if isStoppableRuntimeStatus(session, status: rowStatus) { Button(role: .destructive) { onStopRuntime(session) } label: { @@ -573,16 +575,67 @@ struct WorkSessionListRow: View { } } - private var status: String { - normalizedWorkChatSessionStatus(session: session, summary: chatSummary) + private var shouldShowDeleteAction: Bool { + isChatSession(session) } +} - private var shouldShowStopRuntimeAction: Bool { - isStoppableRuntimeSession(session, summary: chatSummary) +struct WorkChildShellSection: View { + let group: WorkSessionChildGroup + let collapsed: Bool + let onToggle: () -> Void + let content: () -> Content + + init( + group: WorkSessionChildGroup, + collapsed: Bool, + onToggle: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.group = group + self.collapsed = collapsed + self.onToggle = onToggle + self.content = content } - private var shouldShowDeleteAction: Bool { - isChatSession(session) + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button(action: onToggle) { + HStack(spacing: 5) { + Image(systemName: collapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 8, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 9, alignment: .center) + Image(systemName: "terminal") + .font(.system(size: 9, weight: .medium)) + .foregroundStyle(ADEColor.textMuted) + Text(group.label) + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + .tracking(0.4) + Spacer(minLength: 0) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("\(group.label). Tap to \(collapsed ? "expand" : "collapse").") + + if !collapsed { + VStack(spacing: 3) { + content() + } + } + } + .padding(.leading, 14) + .overlay(alignment: .leading) { + Rectangle() + .fill(ADEColor.glassBorder.opacity(0.75)) + .frame(width: 1) + .padding(.leading, 3) + } } } @@ -685,16 +738,148 @@ struct WorkLanePrIndicator: View { } } -struct WorkSessionRow: View { +private struct WorkSessionRowRenderSignature: Equatable { + let sessionId: String + let title: String + let provider: String? + let symbolProvider: String? + let laneName: String + let laneColor: String? + let laneDirty: Bool + let laneAhead: Int + let laneBehind: Int + let activityTimestamp: String + let previewSource: String? + let pinned: Bool + let pullRequestNumber: Int? + let pullRequestState: String? + let status: String + let isArchived: Bool + let isSelectedTransitionSource: Bool + let compact: Bool + + init( + session: TerminalSessionSummary, + lane: LaneSummary?, + pullRequest: LanePrTag?, + chatSummary: AgentChatSessionSummary?, + status: String, + isArchived: Bool, + isSelectedTransitionSource: Bool, + compact: Bool + ) { + self.sessionId = session.id + self.title = chatSummary?.title ?? session.title + self.provider = chatSummary?.provider ?? session.toolType + self.symbolProvider = chatSummary?.provider + self.laneName = session.laneName + self.laneColor = lane?.color + self.laneDirty = lane?.status.dirty == true + self.laneAhead = lane?.status.ahead ?? 0 + self.laneBehind = lane?.status.behind ?? 0 + self.activityTimestamp = workSessionActivityTimestamp(session: session, summary: chatSummary) + self.previewSource = chatSummary?.summary ?? chatSummary?.lastOutputPreview ?? session.summary ?? session.lastOutputPreview + self.pinned = session.pinned + self.pullRequestNumber = pullRequest?.githubPrNumber + self.pullRequestState = pullRequest.map { lanePrStateLabel($0.state) } + self.status = status + self.isArchived = isArchived + self.isSelectedTransitionSource = isSelectedTransitionSource + self.compact = compact + } +} + +struct WorkSessionRow: View, Equatable { let session: TerminalSessionSummary let lane: LaneSummary? var pullRequest: LanePrTag? = nil let chatSummary: AgentChatSessionSummary? + let status: String let isArchived: Bool let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool + var compact: Bool = false + private let renderSignature: WorkSessionRowRenderSignature + + init( + session: TerminalSessionSummary, + lane: LaneSummary?, + pullRequest: LanePrTag? = nil, + chatSummary: AgentChatSessionSummary?, + status: String, + isArchived: Bool, + transitionNamespace: Namespace.ID?, + isSelectedTransitionSource: Bool, + compact: Bool = false + ) { + self.session = session + self.lane = lane + self.pullRequest = pullRequest + self.chatSummary = chatSummary + self.status = status + self.isArchived = isArchived + self.transitionNamespace = transitionNamespace + self.isSelectedTransitionSource = isSelectedTransitionSource + self.compact = compact + self.renderSignature = WorkSessionRowRenderSignature( + session: session, + lane: lane, + pullRequest: pullRequest, + chatSummary: chatSummary, + status: status, + isArchived: isArchived, + isSelectedTransitionSource: isSelectedTransitionSource, + compact: compact + ) + } + + static func == (lhs: WorkSessionRow, rhs: WorkSessionRow) -> Bool { + lhs.renderSignature == rhs.renderSignature + } var body: some View { + if compact { + compactBody + } else { + standardBody + } + } + + private var compactBody: some View { + HStack(alignment: .center, spacing: 8) { + WorkProviderBareLogo( + provider: chatSummary?.provider ?? session.toolType, + fallbackSymbol: sessionSymbol(session, provider: chatSummary?.provider), + tint: providerTintColor, + size: 20 + ) + + Text(chatSummary?.title ?? session.title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + + Spacer(minLength: 6) + + Text(relativeTimestampCompact(workSessionActivityTimestamp(session: session, summary: chatSummary))) + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(providerTintColor.opacity(0.07), in: RoundedRectangle(cornerRadius: 11, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 11, style: .continuous) + .stroke(providerTintColor.opacity(0.18), lineWidth: 0.6) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityLabel) + } + + private var standardBody: some View { HStack(alignment: .center, spacing: 12) { WorkProviderBareLogo( provider: chatSummary?.provider ?? session.toolType, @@ -805,8 +990,7 @@ struct WorkSessionRow: View { } .padding(14) .frame(maxWidth: .infinity, alignment: .leading) - .background(providerTintColor.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) - .glassEffect(in: .rect(cornerRadius: 16)) + .background(providerTintColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(providerTintColor.opacity(0.25), lineWidth: 0.75) @@ -831,11 +1015,11 @@ struct WorkSessionRow: View { var rowTint: Color { if isArchived { return ADEColor.warning } - return workChatStatusTint(normalizedWorkChatSessionStatus(session: session, summary: chatSummary)) + return workChatStatusTint(status) } var accessibilityLabel: String { - var parts = [chatSummary?.title ?? session.title, session.laneName, sessionStatusLabel(session, summary: chatSummary)] + var parts = [chatSummary?.title ?? session.title, session.laneName, sessionStatusLabel(for: status)] if session.pinned { parts.append("pinned") } diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index 4ebc114aa..a7a25d7cb 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -2,33 +2,6 @@ import SwiftUI import UIKit import AVKit -private struct WorkRootLiveTranscriptBuildInput { - let sessionId: String - let streamedEvents: [AgentChatEventEnvelope] - let terminalTail: String -} - -func workRootLiveTranscriptFingerprint( - chatEventRevision: Int, - streamedEventCount: Int, - terminalBufferRevision: Int?, - terminalTail: String? -) -> String { - guard streamedEventCount == 0 else { - return "events:\(chatEventRevision):\(streamedEventCount)" - } - guard let terminalTail, !terminalTail.isEmpty else { - return "empty" - } - if let terminalBufferRevision { - return "terminal:\(terminalBufferRevision):\(terminalTail.utf8.count)" - } - - var hasher = Hasher() - hasher.combine(terminalTail) - return "terminal:\(terminalTail.utf8.count):\(hasher.finalize())" -} - extension WorkRootScreen { @MainActor func scheduleSessionPresentationRebuild() { @@ -38,6 +11,8 @@ extension WorkRootScreen { let sessionsSnapshot = sessions let chatSummariesSnapshot = chatSummaries let lanesSnapshot = lanes + let pullRequestsSnapshot = pullRequests + let githubPrsSnapshot = syncService.laneGithubPrItems let optimisticSessionsSnapshot = optimisticSessions let archivedSessionIdsSnapshot = archivedSessionIds let selectedStatusSnapshot = selectedStatus @@ -61,7 +36,9 @@ extension WorkRootScreen { searchText: searchTextSnapshot, outputSearchBySessionId: outputSearchBySessionId, organization: organization, - orderedLanes: lanesSnapshot + orderedLanes: lanesSnapshot, + pullRequests: pullRequestsSnapshot, + githubPrs: githubPrsSnapshot ) await MainActor.run { guard generation == sessionPresentationRebuildGeneration, !Task.isCancelled else { return } @@ -254,59 +231,6 @@ extension WorkRootScreen { syncService.cacheChatSummaries(nextSummaries) } - @MainActor - func pollRunningChats() async { - guard isLive, isWorkRootActive else { return } - guard !liveChatSessions.isEmpty else { return } - - var lastTranscriptFingerprint: [String: String] = [:] - while !Task.isCancelled && isLive && isWorkRootActive && !liveChatSessions.isEmpty { - let liveSessions = liveChatSessions - let liveSessionIds = Set(liveSessions.map(\.id)) - transcriptCache.prune(keeping: liveSessionIds) - - var buildInputs: [WorkRootLiveTranscriptBuildInput] = [] - buildInputs.reserveCapacity(liveSessions.count) - for session in liveSessions { - try? await syncService.subscribeToChatEvents(sessionId: session.id) - let streamed = syncService.chatEventHistory(sessionId: session.id) - let revision = syncService.chatEventRevision(for: session.id) - let terminalTail = streamed.isEmpty ? (syncService.terminalBuffers[session.id] ?? "") : "" - let fingerprint = workRootLiveTranscriptFingerprint( - chatEventRevision: revision, - streamedEventCount: streamed.count, - terminalBufferRevision: streamed.isEmpty ? syncService.terminalBufferRevisionsBySessionId[session.id] : nil, - terminalTail: streamed.isEmpty ? terminalTail : nil - ) - if lastTranscriptFingerprint[session.id] == fingerprint { - continue - } - lastTranscriptFingerprint[session.id] = fingerprint - buildInputs.append(WorkRootLiveTranscriptBuildInput( - sessionId: session.id, - streamedEvents: streamed, - terminalTail: terminalTail - )) - } - - if !buildInputs.isEmpty { - let builtTranscripts = await Task.detached(priority: .utility) { - buildInputs.map { input -> (String, [WorkChatEnvelope]) in - let transcript = input.streamedEvents.isEmpty - ? parseWorkChatTranscript(input.terminalTail) - : makeWorkChatTranscript(from: input.streamedEvents) - return (input.sessionId, transcript) - } - }.value - guard !Task.isCancelled, isLive, isWorkRootActive else { return } - for (sessionId, transcript) in builtTranscripts where liveSessionIds.contains(sessionId) { - transcriptCache[sessionId] = transcript - } - } - try? await Task.sleep(nanoseconds: syncService.prefersReducedSyncLoad ? 1_800_000_000 : 900_000_000) - } - } - func toggleArchive(_ session: TerminalSessionSummary) { Task { do { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 2093ee325..f53678da5 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -4,6 +4,7 @@ import AVKit let workDateFormatter = ISO8601DateFormatter() +private let workRootBottomTabBarScrollMargin: CGFloat = 150 func resolvedWorkArchivedSessionIds( localStorage: String, @@ -21,6 +22,7 @@ func resolvedWorkArchivedSessionIds( } struct WorkSessionRoute: Hashable { + let openId: UUID = UUID() let sessionId: String var openingPrompt: String? = nil } @@ -34,6 +36,8 @@ struct WorkRootSessionPresentationTaskKey: Equatable { let sessions: [TerminalSessionSummary] let chatSummaries: [String: AgentChatSessionSummary] let lanes: [LaneSummary] + let pullRequests: [PullRequestListItem] + let githubPrs: [GitHubPrListItem] let optimisticSessions: [String: TerminalSessionSummary] let selectedLaneId: String let selectedStatus: WorkSessionStatusFilter @@ -43,23 +47,6 @@ struct WorkRootSessionPresentationTaskKey: Equatable { let sessionOrganizationRaw: String } -final class WorkRootTranscriptCache { - /// Reference cache by design: live transcript prefetches should not repaint - /// the Work list. Opening a session mutates `path`, which re-evaluates the - /// destination closure and reads the latest storage for `initialTranscript`. - private var storage: [String: [WorkChatEnvelope]] = [:] - - subscript(sessionId: String) -> [WorkChatEnvelope]? { - get { storage[sessionId] } - set { storage[sessionId] = newValue } - } - - func prune(keeping sessionIds: Set) { - guard storage.keys.contains(where: { !sessionIds.contains($0) }) else { return } - storage = storage.filter { sessionIds.contains($0.key) } - } -} - struct WorkRootScreen: View { @Environment(\.accessibilityReduceMotion) var reduceMotion @EnvironmentObject var syncService: SyncService @@ -77,7 +64,6 @@ struct WorkRootScreen: View { /// lane with its PR status next to the lane name. Combined with /// `syncService.laneGithubPrItems` for PRs opened outside ADE. @State var pullRequests: [PullRequestListItem] = [] - @State var transcriptCache = WorkRootTranscriptCache() @State var sessionPresentation = WorkRootSessionPresentation.empty @State var sessionPresentationRebuildTask: Task? @State var sessionPresentationRebuildGeneration = 0 @@ -134,18 +120,18 @@ struct WorkRootScreen: View { } var laneById: [String: LaneSummary] { - Dictionary(lanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + sessionPresentation.laneById + } + + var workOrderedLanes: [LaneSummary] { + sessionPresentation.workOrderedLanes } /// PR status tag per lane id, merging ADE-mapped PRs with branch-matched /// GitHub PRs (same resolution the Lanes tab uses), so the Work session rows /// can show a minimal PR indicator beside the lane name. var lanePrTagsByLaneId: [String: LanePrTag] { - lanePrTagByLaneId( - lanes: lanes, - pullRequests: pullRequests, - githubPrs: syncService.laneGithubPrItems - ) + sessionPresentation.lanePrTagsByLaneId } var mergedSessions: [TerminalSessionSummary] { @@ -232,11 +218,14 @@ struct WorkRootScreen: View { isTabActive && path.isEmpty } - var sessionPresentationTaskKey: WorkRootSessionPresentationTaskKey { - WorkRootSessionPresentationTaskKey( + var sessionPresentationTaskKey: WorkRootSessionPresentationTaskKey? { + guard isWorkRootActive else { return nil } + return WorkRootSessionPresentationTaskKey( sessions: sessions, chatSummaries: chatSummaries, lanes: lanes, + pullRequests: pullRequests, + githubPrs: syncService.laneGithubPrItems, optimisticSessions: optimisticSessions, selectedLaneId: selectedLaneId, selectedStatus: selectedStatus, @@ -292,7 +281,7 @@ struct WorkRootScreen: View { selectedStatus: selectedStatusBinding, organization: sessionOrganizationBinding, filterOpen: $filterPanelOpen, - lanes: lanes, + lanes: workOrderedLanes, liveCount: globalLiveSessionCount, needsInputCount: globalNeedsInputCount, isLive: isLive, @@ -342,10 +331,17 @@ struct WorkRootScreen: View { .listRowBackground(Color.clear) .listRowSeparator(.hidden) } else { + let rowLaneById = laneById + let rowPrTagsByLaneId = lanePrTagsByLaneId + let rowArchivedSessionIds = archivedSessionIds + let rowCollapsedSectionIds = collapsedSectionIds + let rowTopLevelDisplaySessionIds = sessionPresentation.topLevelDisplaySessionIds + let rowChildGroupsByParentId = sessionPresentation.childGroupsByParentId + ForEach(sessionGroups) { group in WorkSidebarSectionHeader( group: group, - collapsed: collapsedSectionIds.contains(group.id), + collapsed: rowCollapsedSectionIds.contains(group.id), onToggle: { withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { toggleCollapsed(group.id) @@ -356,14 +352,14 @@ struct WorkRootScreen: View { .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 2, trailing: 16)) - if !collapsedSectionIds.contains(group.id) { - ForEach(group.sessions) { session in + if !rowCollapsedSectionIds.contains(group.id) { + ForEach(group.sessions.filter { rowTopLevelDisplaySessionIds.contains($0.id) }) { session in WorkSessionListRow( session: session, - lane: laneById[session.laneId], - pullRequest: lanePrTagsByLaneId[session.laneId], + lane: rowLaneById[session.laneId], + pullRequest: rowPrTagsByLaneId[session.laneId], chatSummary: chatSummaries[session.id], - isArchived: archivedSessionIds.contains(session.id), + isArchived: rowArchivedSessionIds.contains(session.id), transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? sessionTransitionNamespace : nil, selectedSessionId: $selectedSessionTransitionId, isSelecting: isSelecting, @@ -383,17 +379,64 @@ struct WorkRootScreen: View { .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .listRowBackground(Color.clear) .listRowSeparator(.hidden) + + if let childGroup = rowChildGroupsByParentId[session.id] { + WorkChildShellSection( + group: childGroup, + collapsed: rowCollapsedSectionIds.contains(childGroup.collapsedSectionId), + onToggle: { + withAnimation(ADEMotion.quick(reduceMotion: reduceMotion)) { + toggleCollapsed(childGroup.collapsedSectionId) + } + } + ) { + ForEach(childGroup.children) { child in + WorkSessionListRow( + session: child, + lane: rowLaneById[child.laneId], + pullRequest: rowPrTagsByLaneId[child.laneId], + chatSummary: chatSummaries[child.id], + isArchived: rowArchivedSessionIds.contains(child.id), + transitionNamespace: nil, + compact: true, + selectedSessionId: $selectedSessionTransitionId, + isSelecting: isSelecting, + isChecked: selectedSessionIds.contains(child.id), + onLongPressSelect: startSelection, + onToggleSelect: toggleSelection, + onOpen: openSession, + onPin: togglePin, + onRename: beginRename, + onStopRuntime: { session in stopRuntimeTarget = session }, + onDelete: deleteChatSession, + onCopyId: copySessionId, + onCopyDeepLink: copySessionDeepLink, + onGoToLane: goToLane + ) + .id(child.id) + } + } + .listRowInsets(EdgeInsets(top: 0, leading: 30, bottom: 6, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + } } } } } } + + Color.clear + .frame(height: workRootBottomTabBarScrollMargin) + .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } .listStyle(.plain) .listSectionSpacing(.compact) .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.interactively) - .contentMargins(.bottom, 72, for: .scrollContent) + .contentMargins(.bottom, workRootBottomTabBarScrollMargin, for: .scrollContent) .adeScreenBackground() .adeNavigationGlass() .navigationTitle("") @@ -491,8 +534,9 @@ struct WorkRootScreen: View { let now = Date() if !sessions.isEmpty { let elapsed = now.timeIntervalSince(lastWorkLocalProjectionReload) - if elapsed < 0.35 { - try? await Task.sleep(for: .milliseconds(max(1, Int((0.35 - elapsed) * 1_000)))) + let minimumProjectionReloadInterval = syncService.prefersReducedSyncLoad ? 1.2 : 0.75 + if elapsed < minimumProjectionReloadInterval { + try? await Task.sleep(for: .milliseconds(max(1, Int((minimumProjectionReloadInterval - elapsed) * 1_000)))) guard !Task.isCancelled, workProjectionReloadKey == revision else { return } } } @@ -502,12 +546,14 @@ struct WorkRootScreen: View { lastWorkProjectionReloadRevision = revision } .task(id: sessionPresentationTaskKey) { + guard sessionPresentationTaskKey != nil else { + sessionPresentationRebuildTask?.cancel() + sessionPresentationRebuildTask = nil + return + } scheduleSessionPresentationRebuild() await hydrateSearchOutputBuffersIfNeeded() } - .task(id: pollingKey) { - await pollRunningChats() - } .task(id: workSessionNavigationRequestKey) { guard isTabActive, workSessionNavigationRequestKey != nil else { return } await handleRequestedWorkSessionNavigation() @@ -535,18 +581,20 @@ struct WorkRootScreen: View { initialOpeningPrompt: route.openingPrompt, initialSession: initialSession, initialChatSummary: chatSummaries[route.sessionId], - initialTranscript: transcriptCache[route.sessionId], + initialTranscript: nil, transitionNamespace: routeTransitionNamespace, isLive: isLive, navigationChrome: .pushedDetail, - lanes: lanes + lanes: workOrderedLanes ) + .equatable() + .id(route.openId) .environmentObject(syncService) .environmentObject(dictationController) } .navigationDestination(for: WorkNewChatRoute.self) { route in WorkNewChatScreen( - lanes: lanes, + lanes: workOrderedLanes, preferredLaneId: route.preferredLaneId, onStarted: { summary, opener in let sessionId = summary.sessionId @@ -632,14 +680,6 @@ struct WorkRootScreen: View { ) } - var pollingKey: String { - guard isWorkRootActive else { return "paused" } - let ids = liveChatSessions.map(\.id).sorted().joined(separator: ",") - // Intentionally omit global DB revision: it changes constantly during host DB sync and was - // restarting this poll loop while the list `.task(id:)` also reloaded sessions every tick. - return "\(isLive)-\(ids)" - } - func clearWorkFilters() { searchText = "" selectedLaneId = "all" diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 57feb2bbc..901cdd0a3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -85,9 +85,15 @@ extension WorkSessionDestinationView { } @MainActor - func approveRequest(itemId: String, decision: AgentChatApprovalDecision) async { + func approveRequest(itemId: String, decision: AgentChatApprovalDecision, responseText: String? = nil) async { do { - try await syncService.approveChatSession(sessionId: sessionId, itemId: itemId, decision: decision) + let responseValue = responseText?.trimmingCharacters(in: .whitespacesAndNewlines) + try await syncService.approveChatSession( + sessionId: sessionId, + itemId: itemId, + decision: decision, + responseText: responseValue?.isEmpty == true ? nil : responseValue + ) await refreshChatStateAfterAction(forceRemote: true) errorMessage = nil } catch { @@ -391,17 +397,17 @@ extension WorkSessionDestinationView { let cacheKey = "work-artifact::\(artifact.id)::\(artifact.uri)" if artifact.artifactKind != "video_recording", let cachedImage = ADEImageCache.shared.cachedImage(for: cacheKey) { - artifactContent[artifact.id] = .image(cachedImage) + setArtifactContent(.image(cachedImage), for: artifact.id) return } if let directURL = URL(string: artifact.uri), directURL.scheme?.hasPrefix("http") == true { if artifact.artifactKind == "video_recording" || (artifact.mimeType?.contains("video") == true) { - artifactContent[artifact.id] = .remoteURL(directURL) + setArtifactContent(.remoteURL(directURL), for: artifact.id) } else if let image = try? await ADEImageCache.shared.loadRemoteImage(from: directURL, cacheKey: cacheKey) { - artifactContent[artifact.id] = .image(image) + setArtifactContent(.image(image), for: artifact.id) } else { - artifactContent[artifact.id] = .error("The machine returned an unreadable image preview.") + setArtifactContent(.error("The machine returned an unreadable image preview."), for: artifact.id) } return } @@ -416,7 +422,7 @@ extension WorkSessionDestinationView { } guard let data else { - artifactContent[artifact.id] = .error("The machine returned an artifact payload that could not be decoded.") + setArtifactContent(.error("The machine returned an artifact payload that could not be decoded."), for: artifact.id) return } @@ -425,15 +431,15 @@ extension WorkSessionDestinationView { .appendingPathComponent("ade-work-artifact-\(artifact.id)") .appendingPathExtension(fileExtension(for: artifact.mimeType, fallback: "mp4")) try data.write(to: url, options: .atomic) - artifactContent[artifact.id] = .video(url) + setArtifactContent(.video(url), for: artifact.id) } else if let image = UIImage(data: data) { ADEImageCache.shared.store(data, for: cacheKey) - artifactContent[artifact.id] = .image(image) + setArtifactContent(.image(image), for: artifact.id) } else { - artifactContent[artifact.id] = .text(blob.content) + setArtifactContent(.text(blob.content), for: artifact.id) } } catch { - artifactContent[artifact.id] = .error(error.localizedDescription) + setArtifactContent(.error(error.localizedDescription), for: artifact.id) } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 2ed821430..eb606e29f 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -7,9 +7,12 @@ enum WorkSessionNavigationChrome { case embedded } +extension WorkSessionNavigationChrome: Equatable {} + let workSessionEdgeSwipeActivationWidth: CGFloat = 36 let workSessionEdgeSwipeMinimumTranslation: CGFloat = 88 let workSessionEdgeSwipePredictedTranslation: CGFloat = 140 +let workChatIdleLiveEventTailLimit = 48 func workSessionShouldDismissForEdgeSwipe( startX: CGFloat, @@ -46,8 +49,8 @@ func workChatSendWillQueueMessage( isLive && !hostReachable && chatSendQueueable } -func workChatLiveObservationKey(sessionId: String, chatEventNotificationRevision: Int) -> String { - "\(sessionId)-\(chatEventNotificationRevision)" +func workChatLiveObservationKey(sessionId: String, chatEventRevision: Int) -> String { + "\(sessionId)-\(chatEventRevision)" } func workChatShouldSteerActiveTurn( @@ -103,9 +106,35 @@ func workChatShouldPreferFallbackTranscript( sessionStatus: String, liveTranscript: [WorkChatEnvelope] ) -> Bool { - !fallbackTranscript.isEmpty - && sessionStatus != "active" - && !workTranscriptIndicatesActiveTurn(liveTranscript) + guard !fallbackTranscript.isEmpty, + sessionStatus != "active", + !workTranscriptIndicatesActiveTurn(liveTranscript) + else { return false } + guard let liveTail = latestWorkTextEnvelope(in: liveTranscript) else { return true } + guard let fallbackTail = latestWorkTextEnvelope(in: fallbackTranscript) else { return false } + return fallbackTail.timestamp >= liveTail.timestamp +} + +private func latestWorkTextEnvelope(in transcript: [WorkChatEnvelope]) -> WorkChatEnvelope? { + for envelope in sortedWorkChatEnvelopes(transcript).reversed() { + switch envelope.event { + case .userMessage, .assistantText: + return envelope + default: + continue + } + } + return nil +} + +func workChatTranscriptPreferenceStatus( + sessionStatus: String, + liveTurnActiveHint: Bool? +) -> String { + if liveTurnActiveHint == false && sessionStatus == "active" { + return "idle" + } + return sessionStatus } func workChatErrorIndicatesActiveTurn(_ error: Error) -> Bool { @@ -139,6 +168,92 @@ func mergeWorkTranscriptEntries( return result } +struct WorkLiveTranscriptCache { + private var sessionId: String? + private var eventCount = 0 + private var headEvent: AgentChatEventEnvelope? + private var tailEvent: AgentChatEventEnvelope? + private var transcript: [WorkChatEnvelope] = [] + private(set) var recentDeltaTranscript: [WorkChatEnvelope] = [] + private(set) var recentTranscriptWasRebuilt = false + + mutating func reset(sessionId: String? = nil) { + self.sessionId = sessionId + eventCount = 0 + headEvent = nil + tailEvent = nil + transcript = [] + recentDeltaTranscript = [] + recentTranscriptWasRebuilt = false + } + + mutating func compact(sessionId: String, events: [AgentChatEventEnvelope]) { + guard !events.isEmpty else { + reset(sessionId: sessionId) + return + } + self.sessionId = sessionId + eventCount = events.count + headEvent = events.first + tailEvent = events.last + transcript = makeWorkChatTranscript(from: events) + recentDeltaTranscript = [] + recentTranscriptWasRebuilt = false + } + + mutating func transcript( + for sessionId: String, + events: [AgentChatEventEnvelope] + ) -> [WorkChatEnvelope] { + guard !events.isEmpty else { + reset(sessionId: sessionId) + return [] + } + + if canAppend(sessionId: sessionId, events: events) { + let appendedEvents = Array(events.dropFirst(eventCount)) + if !appendedEvents.isEmpty { + let appendedTranscript = makeWorkChatTranscript(from: appendedEvents) + recentDeltaTranscript = appendedTranscript + recentTranscriptWasRebuilt = false + transcript = appendWorkChatTranscripts(base: transcript, live: appendedTranscript) + } else { + recentDeltaTranscript = [] + recentTranscriptWasRebuilt = false + } + } else { + transcript = makeWorkChatTranscript(from: events) + // A rebase/replay rebuild is not a delta. Treating the rebuilt transcript + // as "recent" re-appends old assistant chunks into the visible message and + // creates repeated phrases during live streaming. + recentDeltaTranscript = [] + recentTranscriptWasRebuilt = true + } + + self.sessionId = sessionId + eventCount = events.count + headEvent = events.first + tailEvent = events.last + return transcript + } + + private func canAppend( + sessionId: String, + events: [AgentChatEventEnvelope] + ) -> Bool { + guard self.sessionId == sessionId, + eventCount <= events.count + else { return false } + + if eventCount == 0 { + return true + } + + return headEvent == events.first + && tailEvent == events[eventCount - 1] + } +} + private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !raw.isEmpty else { return nil } @@ -170,19 +285,40 @@ struct WorkSessionDestinationView: View { @State var session: TerminalSessionSummary? @State var chatSummary: AgentChatSessionSummary? @State var transcript: [WorkChatEnvelope] = [] + @State var transcriptRenderSignature = 0 + @State var mainChatRenderEpoch = 0 + @State var liveTranscriptCache = WorkLiveTranscriptCache() @State var fallbackEntries: [AgentChatTranscriptEntry] = [] + @State var fallbackEntriesRenderSignature = 0 // Canonical transcript entries keyed by their host-side index. Tail // refreshes overwrite the newest indices while "load earlier" pages fill // older ones, so a poll can never clobber scroll-back history. The cursor // is the oldest fetched index (0 = transcript head reached). @State var transcriptEntriesByIndex: [Int: AgentChatTranscriptEntry] = [:] @State var olderTranscriptCursor: Int? + // Newer hosts page canonical chat JSONL by byte offset. Prefer this path for + // scrollback because it keeps the websocket stream tail-sized while still + // walking arbitrarily old transcript history. + @State var olderChatEventHistoryCursor: Int? @State var olderTranscriptLoading = false @State var artifacts: [ComputerUseArtifactSummary] = [] + @State var artifactsRenderSignature = 0 @State var localEchoMessages: [WorkLocalEchoMessage] = [] @State var optimisticPendingSteers: [WorkPendingSteerModel] = [] + @State var subagentSnapshots: [WorkSubagentSnapshot] = [] + @State var remoteSubagentSnapshots: [WorkSubagentSnapshot] = [] + @State var subagentView: WorkSubagentSelection? + @State var subagentTranscript: [WorkChatEnvelope] = [] + @State var subagentTranscriptRenderSignature = 0 + @State var parentTranscriptBeforeSubagent: [WorkChatEnvelope] = [] + @State var parentFallbackEntriesBeforeSubagent: [AgentChatTranscriptEntry] = [] + @State var subagentDrawerPresented = false + @State var expandedSubagentDetailIds: Set = [] + @State var probingSubagentTaskId: String? + @State var remoteSubagentRefreshInFlight = false @State var expandedToolCardIds = Set() @State var artifactContent: [String: WorkLoadedArtifactContent] = [:] + @State var artifactContentRenderSignature = 0 @State var artifactContentLoadsInFlight = Set() @State var artifactRefreshInFlight = false @State var artifactRefreshError: String? @@ -204,19 +340,97 @@ struct WorkSessionDestinationView: View { @State var sessionDeepLinkCopied = false @State var lastSessionRowRefreshAt = Date.distantPast @State var lastTranscriptRemoteRefreshAt = Date.distantPast + @State var lastEmptyTranscriptHydrationAt = Date.distantPast @State var lastCanonicalTranscriptRefreshAt = Date.distantPast @State var lastArtifactRefreshAt = Date.distantPast + @State var initialTranscriptTailHydrated = false + @State var emptyTranscriptHydrationInFlight = false @State var canonicalTranscriptRefreshInFlight = false @State var handledOpeningPromptKey: String? @State var stagedOpeningPromptKey: String? + @MainActor + func setTranscript(_ next: [WorkChatEnvelope]) { + if transcript.isEmpty, !next.isEmpty { + mainChatRenderEpoch &+= 1 + } + transcript = next + transcriptRenderSignature = workChatEnvelopeListRenderSignature(next) + } + + @MainActor + func setFallbackEntries(_ next: [AgentChatTranscriptEntry]) { + if transcript.isEmpty, fallbackEntries.isEmpty, !next.isEmpty { + mainChatRenderEpoch &+= 1 + } + fallbackEntries = next + fallbackEntriesRenderSignature = workFallbackEntriesRenderSignature(next) + } + + @MainActor + func resetTranscriptHistoryState() { + transcriptEntriesByIndex = [:] + olderTranscriptCursor = nil + olderChatEventHistoryCursor = nil + initialTranscriptTailHydrated = false + } + + @MainActor + func setArtifacts(_ next: [ComputerUseArtifactSummary]) { + artifacts = next + artifactsRenderSignature = workArtifactSummariesRenderSignature(next) + } + + @MainActor + func setSubagentTranscript(_ next: [WorkChatEnvelope]) { + subagentTranscript = next + subagentTranscriptRenderSignature = workChatEnvelopeListRenderSignature(next) + } + + @MainActor + func refreshArtifactContentRenderSignature() { + artifactContentRenderSignature = workLoadedArtifactContentRenderSignature(artifactContent) + } + + @MainActor + func setArtifactContent(_ content: WorkLoadedArtifactContent, for artifactId: String) { + artifactContent[artifactId] = content + refreshArtifactContentRenderSignature() + } + + @MainActor + func removeArtifactContent(for artifactId: String) { + artifactContent.removeValue(forKey: artifactId) + refreshArtifactContentRenderSignature() + } + var sessionDestinationNavigationTitle: String { + if let subagentView { + return subagentView.name + } if let navigationTitleOverride { return navigationTitleOverride } return chatSummary?.title ?? session?.title ?? "Session" } + var subagentProvider: String? { + chatSummary?.provider ?? workChatProviderFamilyFromToolType((session ?? initialSession)?.toolType) + } + + var subagentCapability: WorkSubagentCapability { + workResolveSubagentCapability(provider: subagentProvider) + } + + var selectedSubagentSnapshot: WorkSubagentSnapshot? { + guard let subagentView else { return nil } + return subagentSnapshots.first { snapshot in + snapshot.taskId == subagentView.taskId + || snapshot.agentId == subagentView.taskId + || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) + } + } + var hostReachable: Bool { syncService.connectionState == .connected || syncService.connectionState == .syncing } @@ -260,8 +474,8 @@ struct WorkSessionDestinationView: View { /// Live host-side "turn is running" hint (chat_subscribe ack + status/done /// events). Fresher than the synced session row, which arrives via the /// slower changeset pump. - var liveTurnActiveHint: Bool { - syncService.chatTurnActiveHint(sessionId: sessionId) ?? false + var liveTurnActiveHint: Bool? { + syncService.chatTurnActiveHint(sessionId: sessionId) } var supportsManualSteerDispatch: Bool { @@ -281,36 +495,73 @@ struct WorkSessionDestinationView: View { @ViewBuilder var sessionHeaderTrailingControls: some View { if let session, isChatSession(session) { - Menu { - Button { - artifactDrawerPresented = true - } label: { - if artifacts.isEmpty { - Label("Proof", systemImage: "cube.transparent") - } else { - Label("Proof (\(artifacts.count))", systemImage: "cube.transparent") + HStack(spacing: 8) { + if subagentView != nil { + Button { + Task { await dismissSubagentView() } + } label: { + Image(systemName: "chevron.left") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 30, height: 30) + .background(ADEColor.surfaceBackground.opacity(0.9), in: Circle()) + .overlay( + Circle() + .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.5) + ) } + .buttonStyle(.plain) + .accessibilityLabel("Back to main chat") } - .accessibilityHint("Opens the proof drawer") - if showsLaneActions { + Menu { + Button { + Task { await prepareSubagentDrawerPresentation() } + } label: { + if subagentSnapshots.isEmpty { + Label("Subagents", systemImage: "person.2") + } else { + Label("Subagents (\(subagentSnapshots.count))", systemImage: "person.2") + } + } + Divider() - chatPullRequestMenuItems - } + Button { + artifactDrawerPresented = true + } label: { + if artifacts.isEmpty { + Label("Proof", systemImage: "cube.transparent") + } else { + Label("Proof (\(artifacts.count))", systemImage: "cube.transparent") + } + } + .accessibilityHint("Opens the proof drawer") - Divider() + if showsLaneActions { + Divider() - chatSessionDesktopMenuItems(session) - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 34, height: 34) - .contentShape(Rectangle()) + chatPullRequestMenuItems + } + + Divider() + + chatSessionDesktopMenuItems(session) + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.9), in: Circle()) + .overlay( + Circle() + .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Chat actions") } - .buttonStyle(.glass) - .accessibilityLabel("Chat actions") } else { EmptyView() } @@ -474,6 +725,21 @@ struct WorkSessionDestinationView: View { .sheet(item: $fullscreenImage) { image in WorkFullscreenImageView(image: image) } + .sheet(isPresented: $subagentDrawerPresented) { + WorkSubagentDrawerSheet( + snapshots: subagentSnapshots, + provider: subagentProvider, + selectedTaskId: subagentView?.taskId, + probingTaskId: probingSubagentTaskId, + expandedTaskIds: $expandedSubagentDetailIds, + onSelect: handleSubagentSelection + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .task { + await refreshRemoteSubagentSnapshots() + } + } .sheet(isPresented: $createPrPresented) { chatCreatePrWizardSheet } @@ -490,21 +756,29 @@ struct WorkSessionDestinationView: View { Text("Give this session a clearer title for search, pinning, and activity tracking.") } .task { + mainChatRenderEpoch = 0 + liveTranscriptCache.reset(sessionId: sessionId) + resetTranscriptHistoryState() session = initialSession chatSummary = initialChatSummary - transcript = initialTranscript ?? [] + setTranscript(initialTranscript ?? []) stageInitialOpeningPromptEchoIfNeeded() await load() await sendInitialOpeningPromptIfNeeded() + refreshSubagentSnapshots() + await refreshRemoteSubagentSnapshots() } .task(id: liveChatObservationKey) { syncTranscriptFromLiveEvents() await reconcileIdleCanonicalTranscriptIfNeeded() } - .task(id: artifactObservationKey) { - // Proof rows and the session row both arrive through CRDT-backed - // local DB updates, not chat event streams, so observe the synced DB - // revision directly. + .task(id: emptyTranscriptHydrationKey) { + await hydrateEmptyTranscriptFromHostIfNeeded() + } + .task(id: sessionRowObservationKey) { + // 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. try? await Task.sleep(nanoseconds: 320_000_000) guard !Task.isCancelled else { return } // The session row is the status source for the stop button and the @@ -513,10 +787,15 @@ struct WorkSessionDestinationView: View { // @State row — the chat streams output but renders as frozen // (pollIfNeeded bails on non-active status and nothing else // observes the DB). + guard shouldRefreshSessionRowFromLocalStore() else { return } await refreshSessionRowFromLocalStore() - // Local sync can tick rapidly while a turn is streaming. Coalesce - // refreshes here so we do not refetch artifact lists for every - // unrelated revision burst while the user is reading the chat. + } + .task(id: artifactObservationKey) { + // Proof rows arrive through their own projection. Keep this separate + // from work row refreshes so live chat deltas don't churn artifact + // loading state. + try? await Task.sleep(nanoseconds: 320_000_000) + guard !Task.isCancelled else { return } await refreshArtifacts(force: false) } .task(id: session?.laneId ?? initialSession?.laneId ?? "") { @@ -529,6 +808,16 @@ struct WorkSessionDestinationView: View { .task(id: pollingKey) { await pollIfNeeded() } + .task(id: selectedSubagentPollingKey) { + await pollSelectedSubagentTranscriptIfNeeded() + } + .onChange(of: transcript) { _, _ in + refreshSubagentSnapshots() + } + .onChange(of: subagentDrawerPresented) { _, presented in + guard presented else { return } + Task { await refreshRemoteSubagentSnapshots() } + } .onDisappear { if let announcedLaneId { syncService.releaseLaneOpen(laneId: announcedLaneId) @@ -545,14 +834,30 @@ struct WorkSessionDestinationView: View { var sessionDestinationRoot: some View { if let session { if isChatSession(session) { + let viewingSubagent = subagentView != nil + let transcriptForView = viewingSubagent ? subagentTranscript : transcript + let fallbackEntriesForView: [AgentChatTranscriptEntry] = viewingSubagent ? [] : fallbackEntries + let sessionStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) + let shouldSteer = hostReachable && sessionStatus == "active" WorkChatSessionView( - session: session, - chatSummary: chatSummary, - transcript: transcript, - fallbackEntries: fallbackEntries, - artifacts: artifacts, - optimisticPendingSteers: optimisticPendingSteers, - localEchoMessages: localEchoMessages, + session: WorkChatSessionRenderContext(session), + chatSummaryContext: WorkChatSummaryRenderContext(chatSummary), + transcript: transcriptForView, + transcriptRenderSignature: viewingSubagent ? subagentTranscriptRenderSignature : transcriptRenderSignature, + fallbackEntries: fallbackEntriesForView, + fallbackEntriesRenderSignature: viewingSubagent ? 0 : fallbackEntriesRenderSignature, + artifacts: viewingSubagent ? [] : artifacts, + artifactsRenderSignature: viewingSubagent ? 0 : artifactsRenderSignature, + optimisticPendingSteers: viewingSubagent ? [] : optimisticPendingSteers, + optimisticPendingSteersRenderSignature: viewingSubagent ? 0 : workPendingSteersRenderSignature(optimisticPendingSteers), + localEchoMessages: viewingSubagent ? [] : localEchoMessages, + localEchoMessagesRenderSignature: viewingSubagent ? 0 : workLocalEchoMessagesRenderSignature(localEchoMessages), + expandedToolCardIdsSnapshot: expandedToolCardIds, + expandedToolCardIdsRenderSignature: workExpandedToolCardIdsRenderSignature(expandedToolCardIds), + artifactContentRenderSignature: artifactContentRenderSignature, + artifactDrawerPresentedSnapshot: artifactDrawerPresented, + sendingSnapshot: sending, + errorMessageSnapshot: errorMessage, expandedToolCardIds: $expandedToolCardIds, artifactContent: $artifactContent, fullscreenImage: $fullscreenImage, @@ -562,9 +867,12 @@ struct WorkSessionDestinationView: View { sending: $sending, errorMessage: $errorMessage, isLive: isLiveAndReachable, - canComposeMessages: canComposeChatMessages, - canSendMessages: canSendChatMessages, - sendWillQueue: sendWillQueueChatMessage || shouldSteerActiveTurn, + hostUnreachable: syncService.connectionState.isHostUnreachable, + canComposeMessages: canComposeChatMessages && !viewingSubagent, + canSendMessages: canSendChatMessages && !viewingSubagent, + sendWillQueue: sendWillQueueChatMessage || shouldSteer, + sendWillQueueIsReconnect: sendWillQueueChatMessage, + inputLockMessage: viewingSubagent ? "Viewing subagent transcript. Return to main chat to send." : nil, transitionNamespace: transitionNamespace, onOpenLane: showsLaneActions ? openSessionLane : nil, onSend: sendMessage, @@ -589,11 +897,22 @@ struct WorkSessionDestinationView: View { onSelectRuntimeMode: selectRuntimeMode, onSelectEffort: selectReasoningEffort, onSelectCodexFastMode: selectCodexFastMode, + resolvedSessionStatus: viewingSubagent ? "ended" : sessionStatus, lanes: lanes, - hasOlderTranscriptHistory: hasOlderTranscriptHistory, - onLoadOlderTranscript: loadOlderTranscriptEntries, + lanesRenderSignature: workLaneListRenderSignature(lanes), + hasOlderTranscriptHistory: viewingSubagent ? false : hasOlderTranscriptHistory, + onLoadOlderTranscript: viewingSubagent ? nil : loadOlderTranscriptEntries, + subagentSnapshots: subagentSnapshots, + subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature(subagentSnapshots), + selectedSubagentTaskId: subagentView?.taskId, + onOpenSubagents: { Task { await prepareSubagentDrawerPresentation() } }, liveTurnActiveHint: liveTurnActiveHint ) + .id( + viewingSubagent + ? "subagent-\(subagentView?.taskId ?? "unknown")-\(subagentTranscriptRenderSignature)" + : "main-\(session.id)-\(mainChatRenderEpoch)" + ) } else { TerminalSessionScreen(session: session) .environmentObject(syncService) @@ -612,18 +931,33 @@ struct WorkSessionDestinationView: View { let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) // liveTurnActiveHint participates so a desktop-started turn (session row // still idle locally) restarts the poll task the moment the hint flips on. - return "\(session?.id ?? sessionId)-\(status)-\(isLiveAndReachable)-\(liveTurnActiveHint)" + return "\(session?.id ?? sessionId)-\(status)-\(isLiveAndReachable)-\(liveTurnActiveHint.map(String.init) ?? "nil")" } var liveChatObservationKey: String { workChatLiveObservationKey( sessionId: sessionId, - chatEventNotificationRevision: syncService.chatEventNotificationRevision + chatEventRevision: syncService.chatEventRevision(for: sessionId) ) } + var emptyTranscriptHydrationKey: String { + "\(session?.id ?? sessionId)-empty:\(transcript.isEmpty)-fallback:\(fallbackEntries.isEmpty)-host:\(hostReachable)-local:\(syncService.localStateRevision)" + } + + var selectedSubagentPollingKey: String { + guard let selectedSubagentSnapshot, + selectedSubagentSnapshot.status == .running + else { return "paused" } + return "\(sessionId)-\(selectedSubagentSnapshot.taskId)-running-\(isLiveAndReachable)" + } + var artifactObservationKey: String { - "\(sessionId)-work:\(syncService.workProjectionRevision)-proof:\(syncService.proofArtifactsProjectionRevision)" + "\(sessionId)-proof:\(syncService.proofArtifactsProjectionRevision)" + } + + var sessionRowObservationKey: String { + "\(sessionId)-work:\(syncService.workProjectionRevision)" } var trimmedInitialOpeningPrompt: String { @@ -645,26 +979,84 @@ struct WorkSessionDestinationView: View { @MainActor func load() async { do { - if let fetchedSession = try await syncService.fetchSessions().first(where: { $0.id == sessionId }) { + if let fetchedSession = try await syncService.fetchSession(id: sessionId) { session = fetchedSession } lastSessionRowRefreshAt = Date() - if let fetchedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { - chatSummary = fetchedSummary - } + await refreshChatSummaryFromHost() if !syncService.prefersReducedSyncLoad { await refreshArtifacts(force: true) } - await loadTranscript(forceRemote: isLiveAndReachable, preferLightweight: syncService.prefersReducedSyncLoad) + await loadTranscript(forceRemote: shouldHydrateTranscriptFromHost, preferLightweight: syncService.prefersReducedSyncLoad) + await hydrateEmptyTranscriptFromHostIfNeeded(force: true) errorMessage = nil } catch { errorMessage = error.localizedDescription } } + var shouldHydrateTranscriptFromHost: Bool { + guard hostReachable, + let currentSession = session ?? initialSession, + isChatSession(currentSession) + else { return false } + return true + } + + @MainActor + func refreshChatSummaryFromHost() async { + if chatSummary == nil, let cached = syncService.chatSummaryCache[sessionId] { + chatSummary = cached + } + + if syncService.supportsRemoteAction("chat.getSummary"), + let fetchedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { + if chatSummary != fetchedSummary { + chatSummary = fetchedSummary + } + syncService.cacheChatSummary(fetchedSummary) + return + } + + guard let laneId = (session ?? initialSession)?.laneId, + !laneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + syncService.supportsRemoteAction("chat.listSessions"), + let summaries = try? await syncService.listChatSessions(laneId: laneId), + let fallbackSummary = summaries.first(where: { $0.sessionId == sessionId }) + else { return } + + if chatSummary != fallbackSummary { + chatSummary = fallbackSummary + } + syncService.cacheChatSummary(fallbackSummary) + } + + @MainActor + func hydrateEmptyTranscriptFromHostIfNeeded(force: Bool = false) async { + guard transcript.isEmpty, + fallbackEntries.isEmpty, + shouldHydrateTranscriptFromHost, + !emptyTranscriptHydrationInFlight + else { return } + + let now = Date() + guard force || now.timeIntervalSince(lastEmptyTranscriptHydrationAt) >= 2 else { return } + + emptyTranscriptHydrationInFlight = true + lastEmptyTranscriptHydrationAt = now + defer { emptyTranscriptHydrationInFlight = false } + + await refreshChatSummaryFromHost() + await loadTranscript(forceRemote: true, preferLightweight: false) + } + @MainActor func loadTranscript(forceRemote: Bool, preferLightweight: Bool = false) async { let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + let transcriptStatus = workChatTranscriptPreferenceStatus( + sessionStatus: status, + liveTurnActiveHint: syncService.chatTurnActiveHint(sessionId: sessionId) + ) if forceRemote, let currentSession = session ?? initialSession, isChatSession(currentSession) { let alreadySubscribed = syncService.subscribedChatSessionIds.contains(sessionId) @@ -682,9 +1074,43 @@ struct WorkSessionDestinationView: View { // final transcript until the canonical transcript fetch lands. try? await syncService.requestFullChatEventSnapshot(sessionId: sessionId) } + + let shouldHydrateCanonicalEventTail = !preferLightweight + || transcript.isEmpty + || transcriptStatus != "active" + || !initialTranscriptTailHydrated + if shouldHydrateCanonicalEventTail { + do { + if syncService.supportsRemoteAction("chat.getChatEventHistory") { + let snapshot = try await syncService.hydrateChatEventHistorySnapshot(sessionId: sessionId) + seedOlderChatEventHistoryCursor(from: snapshot) + if (snapshot.tailStartOffset ?? 0) <= 0, + (snapshot.windowTruncated == true || snapshot.truncated), + syncService.supportsRemoteAction("chat.getChatEventHistoryPage") { + if let page = try? await syncService.hydrateChatEventHistoryTailPage(sessionId: sessionId) { + seedOlderChatEventHistoryCursor(from: page) + } + } + } else if syncService.supportsRemoteAction("chat.getChatEventHistoryPage") { + let page = try await syncService.hydrateChatEventHistoryTailPage(sessionId: sessionId) + seedOlderChatEventHistoryCursor(from: page) + } + } catch { + if syncService.supportsRemoteAction("chat.getChatEventHistoryPage") { + if let page = try? await syncService.hydrateChatEventHistoryTailPage(sessionId: sessionId) { + seedOlderChatEventHistoryCursor(from: page) + } + } + } + } } - let liveTranscript = makeWorkChatTranscript(from: syncService.chatEventHistory(sessionId: sessionId)) + let liveTranscript = liveTranscriptCache.transcript( + for: sessionId, + events: syncService.chatEventHistory(sessionId: sessionId) + ) + let liveDeltaTranscript = liveTranscriptCache.recentDeltaTranscript + let liveTranscriptWasRebuilt = liveTranscriptCache.recentTranscriptWasRebuilt var fallbackTranscript: [WorkChatEnvelope] = [] var eventTranscript: [WorkChatEnvelope] = [] var fetchedFallbackEntries: [AgentChatTranscriptEntry] = [] @@ -699,12 +1125,15 @@ struct WorkSessionDestinationView: View { // host transcript. Live event snapshots can be byte-capped tails of a long // answer, which are useful while streaming but not enough for final copy // or history. - let shouldFetchFallback = !preferLightweight + let needsInitialTailHydration = forceRemote && !initialTranscriptTailHydrated + let shouldFetchFallback = needsInitialTailHydration + || !preferLightweight || (liveTranscript.isEmpty && transcript.isEmpty) - || (!liveTranscript.isEmpty && status != "active") - let fallbackMaxChars = status == "active" ? 32_000 : 120_000 + || (!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) + initialTranscriptTailHydrated = true fetchedFallbackEntries = combinedTranscriptEntries() fetchedFallbackEntriesAvailable = true fallbackTranscript = makeWorkChatTranscript(from: fetchedFallbackEntries, sessionId: sessionId) @@ -727,8 +1156,13 @@ struct WorkSessionDestinationView: View { eventTranscript = mergeWorkChatTranscripts(base: eventTranscript, live: liveTranscript) } + let shouldPreferFallbackTranscript = workChatShouldPreferFallbackTranscript( + fallbackTranscript: fallbackTranscript, + sessionStatus: transcriptStatus, + liveTranscript: eventTranscript + ) let canonicalEventTranscript: [WorkChatEnvelope] - if !fallbackTranscript.isEmpty, status != "active" { + if shouldPreferFallbackTranscript { canonicalEventTranscript = eventTranscript.filter { envelope in workChatEventIncludedInIdleCanonicalEventTranscript(envelope.event) } @@ -736,25 +1170,65 @@ struct WorkSessionDestinationView: View { canonicalEventTranscript = eventTranscript } - let mergeBaseTranscript = !fallbackTranscript.isEmpty && status != "active" ? [] : transcript - let mergedTranscript = preferredWorkTranscript( - current: mergeBaseTranscript, - fallback: fallbackTranscript, - eventTranscript: canonicalEventTranscript - ) + // Live event history is already the source of truth for the current tail. + // Re-merging it into `transcript` replays delta chunks into the same + // assistant item on every refresh, which duplicates text only on iOS. + let mergedTranscript: [WorkChatEnvelope] + let liveTranscriptStillMoving = transcriptStatus == "active" || workTranscriptIndicatesActiveTurn(eventTranscript) + if !shouldPreferFallbackTranscript, + !liveDeltaTranscript.isEmpty, + !transcript.isEmpty { + mergedTranscript = appendWorkChatTranscripts(base: transcript, live: liveDeltaTranscript) + } else if !shouldPreferFallbackTranscript, + liveTranscriptWasRebuilt, + !transcript.isEmpty { + mergedTranscript = preferredWorkTranscript( + current: transcript, + fallback: fallbackTranscript, + eventTranscript: canonicalEventTranscript + ) + } else if !shouldPreferFallbackTranscript, + !needsInitialTailHydration, + liveTranscriptStillMoving, + !transcript.isEmpty { + mergedTranscript = transcript + } else { + mergedTranscript = preferredWorkTranscript( + current: [], + fallback: fallbackTranscript, + eventTranscript: canonicalEventTranscript + ) + } if !mergedTranscript.isEmpty, mergedTranscript != transcript { - transcript = mergedTranscript + setTranscript(mergedTranscript) } if fetchedFallbackEntriesAvailable, fallbackEntries != fetchedFallbackEntries { - fallbackEntries = fetchedFallbackEntries + setFallbackEntries(fetchedFallbackEntries) } reconcileOptimisticPendingSteers(with: mergedTranscript) reconcileLocalEchoMessages() + pruneIdleLiveChatEventHistoryIfNeeded(transcriptStatus: transcriptStatus, eventTranscript: eventTranscript) if forceRemote { lastTranscriptRemoteRefreshAt = Date() } } + @MainActor + func pruneIdleLiveChatEventHistoryIfNeeded( + transcriptStatus: String, + eventTranscript: [WorkChatEnvelope] + ) { + guard transcriptStatus != "active", + liveTurnActiveHint != true, + !workTranscriptIndicatesActiveTurn(eventTranscript) + else { return } + let compactedEvents = syncService.pruneChatEventHistory( + sessionId: sessionId, + keepingTail: workChatIdleLiveEventTailLimit + ) + liveTranscriptCache.compact(sessionId: sessionId, events: compactedEvents) + } + /// Fold one host transcript page into the index-keyed store. `cursor` is /// the `before` index the page was requested with (nil for a tail fetch). /// Host indices are stable because the transcript is append-only. @@ -785,22 +1259,53 @@ struct WorkSessionDestinationView: View { } } + @MainActor + func seedOlderChatEventHistoryCursor(from snapshot: AgentChatEventHistorySnapshot) { + updateOlderChatEventHistoryCursor(snapshot.tailStartOffset) + } + + @MainActor + func seedOlderChatEventHistoryCursor(from page: AgentChatEventHistoryPage) { + guard page.sessionFound else { + olderChatEventHistoryCursor = nil + return + } + updateOlderChatEventHistoryCursor(page.hasMore ? page.startOffset : nil) + } + + @MainActor + func updateOlderChatEventHistoryCursor(_ cursor: Int?) { + guard let cursor, cursor > 0 else { + olderChatEventHistoryCursor = nil + return + } + if let existing = olderChatEventHistoryCursor, existing > 0 { + olderChatEventHistoryCursor = min(existing, cursor) + } else { + olderChatEventHistoryCursor = cursor + } + } + @MainActor func combinedTranscriptEntries() -> [AgentChatTranscriptEntry] { transcriptEntriesByIndex.keys.sorted().compactMap { transcriptEntriesByIndex[$0] } } var hasOlderTranscriptHistory: Bool { - (olderTranscriptCursor ?? 0) > 0 + (olderChatEventHistoryCursor ?? 0) > 0 || (olderTranscriptCursor ?? 0) > 0 } /// Fetch the next strictly-older transcript page from the host and prepend /// it to the fallback entries that feed the chat timeline. @MainActor func loadOlderTranscriptEntries() async { - guard !olderTranscriptLoading, let cursor = olderTranscriptCursor, cursor > 0 else { return } + guard !olderTranscriptLoading else { return } olderTranscriptLoading = true defer { olderTranscriptLoading = false } + if await loadOlderChatEventHistoryPageIfPossible() { + return + } + guard let cursor = olderTranscriptCursor, cursor > 0 else { return } guard let page = try? await syncService.fetchChatTranscriptPage( sessionId: sessionId, cursor: cursor @@ -808,7 +1313,7 @@ struct WorkSessionDestinationView: View { recordTranscriptPage(page, before: cursor) let combined = combinedTranscriptEntries() if !combined.isEmpty, combined != fallbackEntries { - fallbackEntries = combined + setFallbackEntries(combined) } // fallbackEntries only feed the timeline while `transcript` is empty // (buildWorkTimeline), so splice the older entries into the rendered @@ -819,16 +1324,72 @@ struct WorkSessionDestinationView: View { let olderTranscript = makeWorkChatTranscript(from: combined, sessionId: sessionId) let merged = preferredWorkTranscript(current: [], fallback: olderTranscript, eventTranscript: transcript) if !merged.isEmpty, merged != transcript { - transcript = merged + setTranscript(merged) } } + @MainActor + func loadOlderChatEventHistoryPageIfPossible() async -> Bool { + guard syncService.supportsRemoteAction("chat.getChatEventHistoryPage"), + var cursor = olderChatEventHistoryCursor, + cursor > 0 + else { return false } + + for _ in 0..<6 { + guard cursor > 0 else { break } + guard let page = try? await syncService.fetchChatEventHistoryPage( + sessionId: sessionId, + beforeOffset: cursor + ) else { + return false + } + guard page.sessionFound else { + olderChatEventHistoryCursor = nil + return true + } + guard page.startOffset < cursor else { + olderChatEventHistoryCursor = nil + return true + } + olderChatEventHistoryCursor = page.hasMore && page.startOffset > 0 ? page.startOffset : nil + cursor = page.startOffset + + let olderTranscript = makeWorkChatTranscript(from: page.events) + guard !olderTranscript.isEmpty else { continue } + + let merged = pruneResolvedQueuedSteerEnvelopes( + mergeWorkChatTranscripts(base: olderTranscript, live: transcript) + ) + if !merged.isEmpty, merged != transcript { + setTranscript(merged) + } + return true + } + + return true + } + /// Re-read this session's row from the phone's local replicated DB. Cheap /// (no network) — keeps the @State row current with changeset-synced status /// transitions (idle → running → exited) while the view is open. + @MainActor + func sessionRowRefreshMinimumInterval() -> TimeInterval { + let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + if liveTurnActiveHint == true || status == "active" || status == "awaiting-input" { + return 0.75 + } + return syncService.prefersReducedSyncLoad ? 8.0 : 4.0 + } + + @MainActor + func shouldRefreshSessionRowFromLocalStore(now: Date = Date()) -> Bool { + now.timeIntervalSince(lastSessionRowRefreshAt) >= sessionRowRefreshMinimumInterval() + } + @MainActor func refreshSessionRowFromLocalStore() async { - guard let refreshed = try? await syncService.fetchSessions().first(where: { $0.id == sessionId }) else { return } + lastSessionRowRefreshAt = Date() + guard let refreshed = try? await syncService.fetchSession(id: sessionId) else { return } if refreshed != session { session = refreshed } @@ -841,10 +1402,8 @@ struct WorkSessionDestinationView: View { if !preferLightweight { await refreshArtifacts(force: true) } - if let refreshedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { - chatSummary = refreshedSummary - } - if let refreshedSession = try? await syncService.fetchSessions().first(where: { $0.id == sessionId }) { + await refreshChatSummaryFromHost() + if let refreshedSession = try? await syncService.fetchSession(id: sessionId) { session = refreshedSession } } @@ -853,6 +1412,7 @@ struct WorkSessionDestinationView: View { func cleanupLoadedArtifactContent() { artifactContent.values.forEach { workRemoveLoadedArtifactTempFile($0) } artifactContent.removeAll() + refreshArtifactContentRenderSignature() artifactContentLoadsInFlight.removeAll() } @@ -880,15 +1440,16 @@ struct WorkSessionDestinationView: View { workRemoveLoadedArtifactTempFile(content) } artifactContent = artifactContent.filter { validArtifactIds.contains($0.key) } + refreshArtifactContentRenderSignature() artifactContentLoadsInFlight = Set(artifactContentLoadsInFlight.filter { validArtifactIds.contains($0) }) for artifact in refreshed where previousURIs[artifact.id] != nil && previousURIs[artifact.id] != artifact.uri { workRemoveLoadedArtifactTempFile(artifactContent[artifact.id]) - artifactContent.removeValue(forKey: artifact.id) + removeArtifactContent(for: artifact.id) } if artifacts != refreshed { - artifacts = refreshed + setArtifacts(refreshed) } artifactRefreshError = nil } catch { @@ -979,13 +1540,26 @@ struct WorkSessionDestinationView: View { @MainActor func syncTranscriptFromLiveEvents() { - let liveTranscript = makeWorkChatTranscript(from: syncService.chatEventHistory(sessionId: sessionId)) + let liveTranscript = liveTranscriptCache.transcript( + for: sessionId, + events: syncService.chatEventHistory(sessionId: sessionId) + ) + let liveDeltaTranscript = liveTranscriptCache.recentDeltaTranscript + let liveTranscriptWasRebuilt = liveTranscriptCache.recentTranscriptWasRebuilt guard !liveTranscript.isEmpty else { return } - let fallbackTranscript = makeWorkChatTranscript(from: fallbackEntries, sessionId: sessionId) let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) + let transcriptStatus = workChatTranscriptPreferenceStatus( + sessionStatus: status, + liveTurnActiveHint: syncService.chatTurnActiveHint(sessionId: sessionId) + ) + guard !liveDeltaTranscript.isEmpty || liveTranscriptWasRebuilt || transcript.isEmpty else { + pruneIdleLiveChatEventHistoryIfNeeded(transcriptStatus: transcriptStatus, eventTranscript: liveTranscript) + return + } + let fallbackTranscript = makeWorkChatTranscript(from: fallbackEntries, sessionId: sessionId) let shouldPreferFallbackTranscript = workChatShouldPreferFallbackTranscript( fallbackTranscript: fallbackTranscript, - sessionStatus: status, + sessionStatus: transcriptStatus, liveTranscript: liveTranscript ) let canonicalLiveTranscript: [WorkChatEnvelope] @@ -996,17 +1570,35 @@ struct WorkSessionDestinationView: View { } else { canonicalLiveTranscript = liveTranscript } - let mergeBaseTranscript = shouldPreferFallbackTranscript ? [] : transcript - let mergedTranscript = preferredWorkTranscript( - current: mergeBaseTranscript, - fallback: fallbackTranscript, - eventTranscript: canonicalLiveTranscript - ) + // Active live ticks should only fold the new event delta. Full fallback + // backfill remains for idle/terminal reconciliation where completeness beats + // per-frame streaming cost. + let mergedTranscript: [WorkChatEnvelope] + if !shouldPreferFallbackTranscript, + !liveDeltaTranscript.isEmpty, + !transcript.isEmpty { + mergedTranscript = appendWorkChatTranscripts(base: transcript, live: liveDeltaTranscript) + } else if !shouldPreferFallbackTranscript, + liveTranscriptWasRebuilt, + !transcript.isEmpty { + mergedTranscript = preferredWorkTranscript( + current: transcript, + fallback: fallbackTranscript, + eventTranscript: canonicalLiveTranscript + ) + } else { + mergedTranscript = preferredWorkTranscript( + current: [], + fallback: fallbackTranscript, + eventTranscript: canonicalLiveTranscript + ) + } if !mergedTranscript.isEmpty, mergedTranscript != transcript { - transcript = mergedTranscript + setTranscript(mergedTranscript) } reconcileOptimisticPendingSteers(with: mergedTranscript) reconcileLocalEchoMessages() + pruneIdleLiveChatEventHistoryIfNeeded(transcriptStatus: transcriptStatus, eventTranscript: liveTranscript) } @MainActor @@ -1080,6 +1672,211 @@ struct WorkSessionDestinationView: View { } } + @MainActor + func refreshSubagentSnapshots() { + let local = buildWorkSubagentSnapshots(from: transcript) + let next = mergeWorkSubagentSnapshots(local: local, remote: remoteSubagentSnapshots) + if next != subagentSnapshots { + subagentSnapshots = next + } + if let subagentView, + let updated = next.first(where: { snapshot in + snapshot.taskId == subagentView.taskId + || snapshot.agentId == subagentView.taskId + || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) + }) { + let selection = workSubagentSelection(from: updated) + if selection != subagentView { + self.subagentView = selection + } + } + } + + @MainActor + func refreshRemoteSubagentSnapshots() async { + guard subagentCapability.canList, + !remoteSubagentRefreshInFlight + else { return } + remoteSubagentRefreshInFlight = true + defer { remoteSubagentRefreshInFlight = false } + do { + let remote = try await syncService.fetchSubagents(sessionId: sessionId) + let snapshots = remote.map(workSubagentSnapshot(from:)) + if snapshots != remoteSubagentSnapshots { + remoteSubagentSnapshots = snapshots + refreshSubagentSnapshots() + } + } catch { + // Drawer hydration is opportunistic; the local transcript-derived roster + // stays usable when an older desktop build lacks chat.listSubagents. + } + } + + @MainActor + func clearSubagentView() { + subagentView = nil + setSubagentTranscript([]) + probingSubagentTaskId = nil + } + + @MainActor + func rememberParentTranscriptBeforeSubagent() { + guard !transcript.isEmpty || !fallbackEntries.isEmpty else { return } + parentTranscriptBeforeSubagent = transcript + parentFallbackEntriesBeforeSubagent = fallbackEntries + } + + @MainActor + func restoreParentTranscriptAfterSubagentIfNeeded() { + if transcript.isEmpty, !parentTranscriptBeforeSubagent.isEmpty { + setTranscript(parentTranscriptBeforeSubagent) + } + if fallbackEntries.isEmpty, !parentFallbackEntriesBeforeSubagent.isEmpty { + setFallbackEntries(parentFallbackEntriesBeforeSubagent) + } + parentTranscriptBeforeSubagent = [] + parentFallbackEntriesBeforeSubagent = [] + } + + @MainActor + func materializeParentTranscriptFromLiveEventsIfNeeded() { + guard transcript.isEmpty, fallbackEntries.isEmpty else { return } + let liveTranscript = liveTranscriptCache.transcript( + for: sessionId, + events: syncService.chatEventHistory(sessionId: sessionId) + ) + guard !liveTranscript.isEmpty else { return } + let merged = preferredWorkTranscript(current: [], fallback: [], eventTranscript: liveTranscript) + if !merged.isEmpty { + setTranscript(merged) + } + } + + @MainActor + func prepareSubagentDrawerPresentation() async { + materializeParentTranscriptFromLiveEventsIfNeeded() + if transcript.isEmpty && fallbackEntries.isEmpty && shouldHydrateTranscriptFromHost { + await refreshChatSummaryFromHost() + await loadTranscript(forceRemote: true, preferLightweight: false) + } + materializeParentTranscriptFromLiveEventsIfNeeded() + rememberParentTranscriptBeforeSubagent() + subagentDrawerPresented = true + await refreshRemoteSubagentSnapshots() + } + + @MainActor + func dismissSubagentView() async { + guard subagentView != nil else { return } + + if transcript.isEmpty && fallbackEntries.isEmpty && shouldHydrateTranscriptFromHost { + await refreshChatSummaryFromHost() + await loadTranscript(forceRemote: true, preferLightweight: false) + } + + clearSubagentView() + restoreParentTranscriptAfterSubagentIfNeeded() + materializeParentTranscriptFromLiveEventsIfNeeded() + + if transcript.isEmpty && fallbackEntries.isEmpty && shouldHydrateTranscriptFromHost { + await hydrateEmptyTranscriptFromHostIfNeeded(force: true) + } + } + + @MainActor + func handleSubagentSelection(_ snapshot: WorkSubagentSnapshot) async { + if let subagentView, + snapshot.taskId == subagentView.taskId + || snapshot.agentId == subagentView.taskId + || (subagentView.agentId != nil && (snapshot.agentId == subagentView.agentId || snapshot.taskId == subagentView.agentId)) { + await dismissSubagentView() + subagentDrawerPresented = false + return + } + + guard subagentCapability.canViewFullTranscript else { + toggleExpandedSubagentDetail(snapshot.taskId) + return + } + + guard snapshot.status == .running else { + toggleExpandedSubagentDetail(snapshot.taskId) + return + } + + rememberParentTranscriptBeforeSubagent() + + probingSubagentTaskId = snapshot.taskId + defer { probingSubagentTaskId = nil } + + do { + let messages = try await syncService.fetchSubagentTranscript( + sessionId: sessionId, + agentId: snapshot.agentId ?? snapshot.taskId, + taskId: snapshot.taskId, + laneId: (session ?? initialSession)?.laneId, + limit: 200 + ) + guard let messages, !messages.isEmpty else { + toggleExpandedSubagentDetail(snapshot.taskId) + return + } + let subagentEnvelopes = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: sessionId) + guard workSubagentTranscriptHasVisibleTimeline(subagentEnvelopes) else { + toggleExpandedSubagentDetail(snapshot.taskId) + return + } + setSubagentTranscript(subagentEnvelopes) + await Task.yield() + subagentView = workSubagentSelection(from: snapshot) + expandedSubagentDetailIds.remove(snapshot.taskId) + subagentDrawerPresented = false + } catch { + toggleExpandedSubagentDetail(snapshot.taskId) + } + } + + @MainActor + func toggleExpandedSubagentDetail(_ taskId: String) { + if expandedSubagentDetailIds.contains(taskId) { + expandedSubagentDetailIds.remove(taskId) + } else { + expandedSubagentDetailIds.insert(taskId) + } + } + + @MainActor + func refreshSelectedSubagentTranscript() async { + guard let selectedSubagentSnapshot, + subagentCapability.canViewFullTranscript + else { return } + guard let messages = try? await syncService.fetchSubagentTranscript( + sessionId: sessionId, + agentId: selectedSubagentSnapshot.agentId ?? selectedSubagentSnapshot.taskId, + taskId: selectedSubagentSnapshot.taskId, + laneId: (session ?? initialSession)?.laneId, + limit: 200 + ), !messages.isEmpty else { return } + let next = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: sessionId) + guard workSubagentTranscriptHasVisibleTimeline(next) else { return } + if next != subagentTranscript { + setSubagentTranscript(next) + } + } + + @MainActor + func pollSelectedSubagentTranscriptIfNeeded() async { + guard isLiveAndReachable, + selectedSubagentSnapshot?.status == .running + else { return } + while !Task.isCancelled, + isLiveAndReachable, + selectedSubagentSnapshot?.status == .running { + await refreshSelectedSubagentTranscript() + try? await Task.sleep(nanoseconds: 1_500_000_000) + } + } + @MainActor func updateLocalEchoDeliveryState(echoId: String, deliveryState: String?) { guard let index = localEchoMessages.firstIndex(where: { $0.id == echoId }) else { return } @@ -1097,11 +1894,14 @@ struct WorkSessionDestinationView: View { // the row catches up via refreshSessionRowFromLocalStore / the loop's own // summary refresh below. let initialStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) - guard initialStatus == "active" || initialStatus == "awaiting-input" || liveTurnActiveHint else { return } + guard liveTurnActiveHint != false, + initialStatus == "active" || initialStatus == "awaiting-input" || liveTurnActiveHint == true + else { return } while !Task.isCancelled, isLiveAndReachable, { + guard self.liveTurnActiveHint != false else { return false } let status = normalizedWorkChatSessionStatus(session: self.session, summary: self.chatSummary) - return status == "active" || status == "awaiting-input" || self.liveTurnActiveHint + return status == "active" || status == "awaiting-input" || self.liveTurnActiveHint == true }() { syncTranscriptFromLiveEvents() let now = Date() @@ -1111,10 +1911,8 @@ struct WorkSessionDestinationView: View { let sessionRefreshInterval = syncService.prefersReducedSyncLoad ? 10.0 : 5.0 if now.timeIntervalSince(lastSessionRowRefreshAt) >= sessionRefreshInterval { lastSessionRowRefreshAt = now - if let refreshedSummary = try? await syncService.fetchChatSummary(sessionId: sessionId) { - chatSummary = refreshedSummary - } - if let refreshedSession = try? await syncService.fetchSessions().first(where: { $0.id == sessionId }) { + await refreshChatSummaryFromHost() + if let refreshedSession = try? await syncService.fetchSession(id: sessionId) { self.session = refreshedSession } } @@ -1127,6 +1925,175 @@ struct WorkSessionDestinationView: View { } } +private func workInitialTranscriptSeedRenderSignature(_ transcript: [WorkChatEnvelope]?) -> Int { + guard let transcript else { return 0 } + var hasher = Hasher() + hasher.combine(transcript.count) + if let first = transcript.first { + hasher.combine(workChatEnvelopeMergeKey(first)) + hasher.combine(first.sequence) + hasher.combine(first.timestamp) + } + if let last = transcript.last { + hasher.combine(workChatEnvelopeMergeKey(last)) + hasher.combine(last.sequence) + hasher.combine(last.timestamp) + if case .assistantText(let text, _, _) = last.event { + hasher.combine(text.utf8.count) + hasher.combine(text.hashValue) + } + } + return hasher.finalize() +} + +extension WorkSessionDestinationView: Equatable { + static func == (lhs: WorkSessionDestinationView, rhs: WorkSessionDestinationView) -> Bool { + lhs.sessionId == rhs.sessionId + && lhs.initialOpeningPrompt == rhs.initialOpeningPrompt + && lhs.initialSession == rhs.initialSession + && lhs.initialChatSummary == rhs.initialChatSummary + && workInitialTranscriptSeedRenderSignature(lhs.initialTranscript) == workInitialTranscriptSeedRenderSignature(rhs.initialTranscript) + && (lhs.transitionNamespace == nil) == (rhs.transitionNamespace == nil) + && lhs.isLive == rhs.isLive + && lhs.navigationChrome == rhs.navigationChrome + && lhs.showsLaneActions == rhs.showsLaneActions + && lhs.navigationTitleOverride == rhs.navigationTitleOverride + && workLaneListRenderSignature(lhs.lanes) == workLaneListRenderSignature(rhs.lanes) + } +} + +func workSubagentTranscriptToEnvelopes( + messages: [SyncService.AgentChatSubagentTranscriptMessage], + sessionId parentSessionId: String +) -> [WorkChatEnvelope] { + let baseDate = Date(timeIntervalSince1970: 0) + let coalesced = workCoalescedSubagentTranscriptMessages(messages) + return coalesced.enumerated().compactMap { index, message in + let text = workSubagentTranscriptText(message) + let timestamp = workSubagentTranscriptIsoFormatter.string(from: baseDate.addingTimeInterval(Double(index))) + let itemId = workSubagentTranscriptItemId(message) + let event: WorkChatEvent + switch message.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "user": + event = .userMessage( + text: text, + attachments: nil, + turnId: nil, + steerId: nil, + deliveryState: nil, + processed: true + ) + case "assistant": + event = .assistantText( + text: text, + turnId: nil, + itemId: itemId?.isEmpty == false ? itemId : nil + ) + default: + event = .systemNotice( + kind: "subagent", + message: text.isEmpty ? "Subagent event" : text, + detail: nil, + turnId: nil, + steerId: nil + ) + } + return WorkChatEnvelope( + sessionId: "\(parentSessionId):subagent:\(message.sessionId)", + timestamp: timestamp, + sequence: index, + event: event + ) + } +} + +func workSubagentTranscriptHasVisibleTimeline(_ envelopes: [WorkChatEnvelope]) -> Bool { + guard !envelopes.isEmpty else { return false } + return !buildWorkChatTimelineSnapshot( + transcript: envelopes, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ).timeline.isEmpty +} + +private func workCoalescedSubagentTranscriptMessages( + _ messages: [SyncService.AgentChatSubagentTranscriptMessage] +) -> [SyncService.AgentChatSubagentTranscriptMessage] { + var result: [SyncService.AgentChatSubagentTranscriptMessage] = [] + result.reserveCapacity(messages.count) + + for message in messages { + let key = workSubagentTranscriptMergeKey(message) + if key != nil, + let last = result.last, + workSubagentTranscriptMergeKey(last) == key { + var merged = last + merged.text = workSubagentTranscriptRawText(last) + workSubagentTranscriptRawText(message) + result[result.count - 1] = merged + continue + } + result.append(message) + } + + return result +} + +private func workSubagentTranscriptMergeKey(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String? { + let type = message.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard type == "assistant" || type == "user" else { return nil } + guard let itemId = workSubagentTranscriptItemId(message), !itemId.isEmpty else { return nil } + return "\(type)|\(message.sessionId)|\(itemId)" +} + +private func workSubagentTranscriptItemId(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String? { + let candidates = [ + workRemoteJSONString(message.message, key: "messageId"), + workRemoteJSONString(message.message, key: "itemId"), + message.uuid, + ] + return candidates + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } +} + +private func workRemoteJSONString(_ value: RemoteJSONValue?, key: String) -> String? { + guard case .object(let object)? = value, + case .string(let string)? = object[key] + else { + return nil + } + return string +} + +private func workSubagentTranscriptRawText(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String { + if let text = message.text { + return text + } + if let payload = message.message { + return prettyPrintedRemoteJSONValue(payload) + } + return "" +} + +private func workSubagentTranscriptText(_ message: SyncService.AgentChatSubagentTranscriptMessage) -> String { + if let text = message.text?.trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty { + return text + } + if let payload = message.message { + let text = prettyPrintedRemoteJSONValue(payload).trimmingCharacters(in: .whitespacesAndNewlines) + if !text.isEmpty { return text } + } + return "" +} + +private let workSubagentTranscriptIsoFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + private struct WorkSessionNavigationChromeModifier: ViewModifier { @Environment(\.dismiss) private var dismiss @Environment(\.layoutDirection) private var layoutDirection @@ -1185,9 +2152,8 @@ private struct WorkSessionNavigationChromeModifier: View .padding(.bottom, 8) .background { ADEColor.pageBackground - .opacity(0.98) - .ignoresSafeArea(edges: .top) - .allowsHitTesting(false) + .ignoresSafeArea(edges: .top) + .allowsHitTesting(false) } } .navigationTitle("") diff --git a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift index b8fba2044..1e74eab12 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionGrouping.swift @@ -66,28 +66,88 @@ struct WorkSessionGroup: Identifiable, Equatable { } } +struct WorkSessionChildGroup: Equatable { + let parentId: String + let children: [TerminalSessionSummary] + let collapsedSectionId: String + + var label: String { + children.count == 1 ? "1 shell" : "\(children.count) shells" + } +} + struct WorkRootSessionPresentation: Equatable { let mergedSessions: [TerminalSessionSummary] let displaySessions: [TerminalSessionSummary] let displaySessionIds: Set + let topLevelDisplaySessionIds: Set + let childGroupsByParentId: [String: WorkSessionChildGroup] let liveChatSessions: [TerminalSessionSummary] let sessionGroups: [WorkSessionGroup] + let workOrderedLanes: [LaneSummary] + let laneById: [String: LaneSummary] + let lanePrTagsByLaneId: [String: LanePrTag] let globalNeedsInputCount: Int let globalLiveSessionCount: Int let firstGlobalAttentionSessionId: String? let firstGlobalLiveSessionId: String? + private let renderSignature: Int + + init( + mergedSessions: [TerminalSessionSummary], + displaySessions: [TerminalSessionSummary], + displaySessionIds: Set, + topLevelDisplaySessionIds: Set, + childGroupsByParentId: [String: WorkSessionChildGroup], + liveChatSessions: [TerminalSessionSummary], + sessionGroups: [WorkSessionGroup], + workOrderedLanes: [LaneSummary], + laneById: [String: LaneSummary], + lanePrTagsByLaneId: [String: LanePrTag], + globalNeedsInputCount: Int, + globalLiveSessionCount: Int, + firstGlobalAttentionSessionId: String?, + firstGlobalLiveSessionId: String?, + renderSignature: Int + ) { + self.mergedSessions = mergedSessions + self.displaySessions = displaySessions + self.displaySessionIds = displaySessionIds + self.topLevelDisplaySessionIds = topLevelDisplaySessionIds + self.childGroupsByParentId = childGroupsByParentId + self.liveChatSessions = liveChatSessions + self.sessionGroups = sessionGroups + self.workOrderedLanes = workOrderedLanes + self.laneById = laneById + self.lanePrTagsByLaneId = lanePrTagsByLaneId + self.globalNeedsInputCount = globalNeedsInputCount + self.globalLiveSessionCount = globalLiveSessionCount + self.firstGlobalAttentionSessionId = firstGlobalAttentionSessionId + self.firstGlobalLiveSessionId = firstGlobalLiveSessionId + self.renderSignature = renderSignature + } static let empty = WorkRootSessionPresentation( mergedSessions: [], displaySessions: [], displaySessionIds: [], + topLevelDisplaySessionIds: [], + childGroupsByParentId: [:], liveChatSessions: [], sessionGroups: [], + workOrderedLanes: [], + laneById: [:], + lanePrTagsByLaneId: [:], globalNeedsInputCount: 0, globalLiveSessionCount: 0, firstGlobalAttentionSessionId: nil, - firstGlobalLiveSessionId: nil + firstGlobalLiveSessionId: nil, + renderSignature: 0 ) + + static func == (lhs: WorkRootSessionPresentation, rhs: WorkRootSessionPresentation) -> Bool { + lhs.renderSignature == rhs.renderSignature + } } func buildWorkRootSessionPresentation( @@ -100,12 +160,26 @@ func buildWorkRootSessionPresentation( searchText: String, outputSearchBySessionId: [String: String] = [:], organization: WorkSessionOrganization, - orderedLanes: [LaneSummary] + orderedLanes: [LaneSummary], + pullRequests: [PullRequestListItem] = [], + githubPrs: [GitHubPrListItem] = [] ) -> WorkRootSessionPresentation { let committedIds = Set(sessions.map(\.id)) let draftValues = optimisticSessions.values.filter { !committedIds.contains($0.id) } + let workOrderedLanes = sortWorkLanesForTabs(orderedLanes) + let laneById = Dictionary(orderedLanes.map { ($0.id, $0) }, uniquingKeysWith: { _, new in new }) + let lanePrTagsByLaneId = lanePrTagByLaneId( + lanes: orderedLanes, + pullRequests: pullRequests, + githubPrs: githubPrs + ) let mergedSessions = (sessions + draftValues) .sorted { compareWorkSessionSortOrder($0, $1, chatSummaries: chatSummaries) } + let statusBySessionId = Dictionary( + uniqueKeysWithValues: mergedSessions.map { session in + (session.id, normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id])) + } + ) let displaySessions = workFilteredSessions( mergedSessions, @@ -116,6 +190,10 @@ func buildWorkRootSessionPresentation( searchText: searchText, outputSearchBySessionId: outputSearchBySessionId ) + let displaySessionIds = Set(displaySessions.map(\.id)) + let childGroupsByParentId = workSessionChildGroupsByParentId(sessions: displaySessions) + let childSessionIds = Set(childGroupsByParentId.values.flatMap { $0.children.map(\.id) }) + let topLevelDisplaySessionIds = displaySessionIds.subtracting(childSessionIds) var liveChatSessions: [TerminalSessionSummary] = [] liveChatSessions.reserveCapacity(mergedSessions.count) @@ -126,7 +204,7 @@ func buildWorkRootSessionPresentation( for session in mergedSessions { let isArchived = archivedSessionIds.contains(session.id) - let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) + let status = statusBySessionId[session.id] ?? "ended" if isChatSession(session), status != "ended", !isArchived { liveChatSessions.append(session) @@ -154,28 +232,211 @@ func buildWorkRootSessionPresentation( organization: organization, sessions: displaySessions, chatSummaries: chatSummaries, + statusBySessionId: statusBySessionId, archivedSessionIds: archivedSessionIds, - orderedLanes: orderedLanes + orderedLanes: workOrderedLanes ) return WorkRootSessionPresentation( mergedSessions: mergedSessions, displaySessions: displaySessions, - displaySessionIds: Set(displaySessions.map(\.id)), + displaySessionIds: displaySessionIds, + topLevelDisplaySessionIds: topLevelDisplaySessionIds, + childGroupsByParentId: childGroupsByParentId, liveChatSessions: liveChatSessions, sessionGroups: sessionGroups, + workOrderedLanes: workOrderedLanes, + laneById: laneById, + lanePrTagsByLaneId: lanePrTagsByLaneId, globalNeedsInputCount: globalNeedsInputCount, globalLiveSessionCount: globalLiveSessionCount, firstGlobalAttentionSessionId: firstGlobalAttentionSessionId, - firstGlobalLiveSessionId: firstGlobalLiveSessionId + firstGlobalLiveSessionId: firstGlobalLiveSessionId, + renderSignature: workRootSessionPresentationRenderSignature( + mergedSessions: mergedSessions, + displaySessions: displaySessions, + topLevelDisplaySessionIds: topLevelDisplaySessionIds, + childGroupsByParentId: childGroupsByParentId, + liveChatSessions: liveChatSessions, + sessionGroups: sessionGroups, + workOrderedLanes: workOrderedLanes, + lanePrTagsByLaneId: lanePrTagsByLaneId, + chatSummaries: chatSummaries, + statusBySessionId: statusBySessionId, + globalNeedsInputCount: globalNeedsInputCount, + globalLiveSessionCount: globalLiveSessionCount, + firstGlobalAttentionSessionId: firstGlobalAttentionSessionId, + firstGlobalLiveSessionId: firstGlobalLiveSessionId + ) ) } +private func workRootSessionPresentationRenderSignature( + mergedSessions: [TerminalSessionSummary], + displaySessions: [TerminalSessionSummary], + topLevelDisplaySessionIds: Set, + childGroupsByParentId: [String: WorkSessionChildGroup], + liveChatSessions: [TerminalSessionSummary], + sessionGroups: [WorkSessionGroup], + workOrderedLanes: [LaneSummary], + lanePrTagsByLaneId: [String: LanePrTag], + chatSummaries: [String: AgentChatSessionSummary], + statusBySessionId: [String: String], + globalNeedsInputCount: Int, + globalLiveSessionCount: Int, + firstGlobalAttentionSessionId: String?, + firstGlobalLiveSessionId: String? +) -> Int { + var hasher = Hasher() + hasher.combine(mergedSessions.count) + for session in mergedSessions { + hasher.combine(session.id) + hasher.combine(session.title) + hasher.combine(session.laneId) + hasher.combine(session.laneName) + hasher.combine(session.toolType) + hasher.combine(session.summary) + hasher.combine(session.lastOutputPreview) + hasher.combine(session.status) + hasher.combine(session.runtimeState) + hasher.combine(session.chatIdleSinceAt) + hasher.combine(session.pendingInputItemId) + hasher.combine(session.chatSessionId) + hasher.combine(session.archivedAt) + hasher.combine(session.endedAt) + hasher.combine(session.pinned) + hasher.combine(statusBySessionId[session.id]) + if let summary = chatSummaries[session.id] { + hasher.combine(summary.title) + hasher.combine(summary.provider) + hasher.combine(summary.model) + hasher.combine(summary.summary) + hasher.combine(summary.lastOutputPreview) + hasher.combine(summary.status) + hasher.combine(summary.idleSinceAt) + hasher.combine(summary.endedAt) + } + } + hasher.combine(displaySessions.map(\.id)) + hasher.combine(topLevelDisplaySessionIds.sorted()) + hasher.combine(liveChatSessions.map(\.id)) + for group in sessionGroups { + hasher.combine(group.id) + hasher.combine(group.label) + hasher.combine(group.sessions.map(\.id)) + } + for key in childGroupsByParentId.keys.sorted() { + guard let group = childGroupsByParentId[key] else { continue } + hasher.combine(key) + hasher.combine(group.parentId) + hasher.combine(group.collapsedSectionId) + hasher.combine(group.children.map(\.id)) + } + for lane in workOrderedLanes { + hasher.combine(lane.id) + hasher.combine(lane.name) + hasher.combine(lane.color) + hasher.combine(lane.status.dirty) + hasher.combine(lane.status.ahead) + hasher.combine(lane.status.behind) + } + for key in lanePrTagsByLaneId.keys.sorted() { + guard let tag = lanePrTagsByLaneId[key] else { continue } + hasher.combine(key) + hasher.combine(tag.githubPrNumber) + hasher.combine(lanePrStateLabel(tag.state)) + } + hasher.combine(globalNeedsInputCount) + hasher.combine(globalLiveSessionCount) + hasher.combine(firstGlobalAttentionSessionId) + hasher.combine(firstGlobalLiveSessionId) + return hasher.finalize() +} + +func sortWorkLanesForTabs(_ lanes: [LaneSummary]) -> [LaneSummary] { + lanes.enumerated().sorted { lhsPair, rhsPair in + let lhs = lhsPair.element + let rhs = rhsPair.element + let lhsPrimary = lhs.laneType == "primary" + let rhsPrimary = rhs.laneType == "primary" + if lhsPrimary != rhsPrimary { return lhsPrimary } + + let lhsDate = parseWorkSessionTimestamp(lhs.createdAt) + let rhsDate = parseWorkSessionTimestamp(rhs.createdAt) + if let lhsDate, let rhsDate, lhsDate != rhsDate { + return lhsDate > rhsDate + } + return lhsPair.offset < rhsPair.offset + }.map(\.element) +} + +func workSessionChildGroupsByParentId(sessions: [TerminalSessionSummary]) -> [String: WorkSessionChildGroup] { + let visibleIds = Set(sessions.map(\.id)) + var childrenByParentId: [String: [TerminalSessionSummary]] = [:] + for session in sessions { + guard let parentId = normalizedWorkParentChatSessionId(session.chatSessionId), + parentId != session.id, + visibleIds.contains(parentId) + else { + continue + } + childrenByParentId[parentId, default: []].append(session) + } + + return Dictionary(uniqueKeysWithValues: childrenByParentId.map { parentId, children in + let ordered = children.sorted { lhs, rhs in + let lhsDate = parseWorkSessionTimestamp(lhs.startedAt) + let rhsDate = parseWorkSessionTimestamp(rhs.startedAt) + if let lhsDate, let rhsDate, lhsDate != rhsDate { + return lhsDate < rhsDate + } + if lhs.startedAt != rhs.startedAt { + return lhs.startedAt < rhs.startedAt + } + return lhs.id < rhs.id + } + return ( + parentId, + WorkSessionChildGroup( + parentId: parentId, + children: ordered, + collapsedSectionId: workSessionChildSectionId(parentId: parentId) + ) + ) + }) +} + +func workSessionChildSectionId(parentId: String) -> String { + "chat:\(parentId)" +} + +private func normalizedWorkParentChatSessionId(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +} + +private func parseWorkSessionTimestamp(_ rawValue: String) -> Date? { + workSessionISO8601Formatter.date(from: rawValue) ?? workSessionISO8601FormatterNoFractional.date(from: rawValue) +} + +private let workSessionISO8601Formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter +}() + +private let workSessionISO8601FormatterNoFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter +}() + /// Group session list by the user's chosen organization. Empty groups are filtered out. func workSessionGroups( organization: WorkSessionOrganization, sessions: [TerminalSessionSummary], chatSummaries: [String: AgentChatSessionSummary], + statusBySessionId: [String: String] = [:], archivedSessionIds: Set, orderedLanes: [LaneSummary] ) -> [WorkSessionGroup] { @@ -184,6 +445,7 @@ func workSessionGroups( return workSessionGroupsByStatus( sessions: sessions, chatSummaries: chatSummaries, + statusBySessionId: statusBySessionId, archivedSessionIds: archivedSessionIds ) case .byLane: @@ -199,6 +461,7 @@ func workSessionGroups( func workSessionGroupsByStatus( sessions: [TerminalSessionSummary], chatSummaries: [String: AgentChatSessionSummary], + statusBySessionId: [String: String] = [:], archivedSessionIds: Set ) -> [WorkSessionGroup] { var needsInput: [TerminalSessionSummary] = [] @@ -212,7 +475,8 @@ func workSessionGroupsByStatus( archived.append(session) continue } - let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) + let status = statusBySessionId[session.id] + ?? normalizedWorkChatSessionStatus(session: session, summary: chatSummaries[session.id]) if status == "awaiting-input" { needsInput.append(session) } else if session.pinned { @@ -268,13 +532,9 @@ func workSessionGroupsByLane( } // Surface any sessions whose lane isn't in the ordered list (e.g., soft-deleted lanes) // as their own per-lane groups so users still recognize which branch each belongs to. - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let isoFallback = ISO8601DateFormatter() - isoFallback.formatOptions = [.withInternetDateTime] func latestStartedAt(_ list: [TerminalSessionSummary]) -> Date { list.reduce(.distantPast) { acc, session in - let parsed = iso.date(from: session.startedAt) ?? isoFallback.date(from: session.startedAt) ?? .distantPast + let parsed = parseWorkSessionTimestamp(session.startedAt) ?? .distantPast return parsed > acc ? parsed : acc } } @@ -309,13 +569,8 @@ func workSessionGroupsByTime(sessions: [TerminalSessionSummary]) -> [WorkSession var yesterday: [TerminalSessionSummary] = [] var older: [TerminalSessionSummary] = [] - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let fallbackFormatter = ISO8601DateFormatter() - fallbackFormatter.formatOptions = [.withInternetDateTime] - for session in sessions { - let parsed = formatter.date(from: session.startedAt) ?? fallbackFormatter.date(from: session.startedAt) + let parsed = parseWorkSessionTimestamp(session.startedAt) guard let started = parsed else { older.append(session) continue diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 854bda780..7d3aa939b 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -23,6 +23,11 @@ func isRunOwnedSession(_ session: TerminalSessionSummary) -> Bool { func isStoppableRuntimeSession(_ session: TerminalSessionSummary, summary: AgentChatSessionSummary? = nil) -> Bool { guard !isChatSession(session) else { return false } let status = normalizedWorkChatSessionStatus(session: session, summary: summary) + return isStoppableRuntimeStatus(session, status: status) +} + +func isStoppableRuntimeStatus(_ session: TerminalSessionSummary, status: String) -> Bool { + guard !isChatSession(session) else { return false } return status == "active" || status == "awaiting-input" || status == "idle" } @@ -56,6 +61,131 @@ func workChatComposerPlaceholder(pendingInputCount: Int, sessionStatus: String) return "Type to vibecode..." } +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..." + } + if workChatComposerBlocksFreeformInput(pendingInputCount: pendingInputs.count, sessionStatus: sessionStatus) { + return "Answer the prompt above..." + } + return "Type to vibecode..." +} + +func workMobileShowsToolCardInTimeline(_ card: WorkToolCardModel) -> Bool { + isQuestionInputToolName(card.toolName) +} + +struct WorkToolActivityPresentation: Equatable { + let label: String + let detail: String? +} + +struct WorkSubagentCapability: Equatable { + let canList: Bool + let canViewFullTranscript: Bool + + static let none = WorkSubagentCapability(canList: false, canViewFullTranscript: false) +} + +func workResolveSubagentCapability(provider: String?) -> WorkSubagentCapability { + switch providerFamilyKey(provider ?? "") { + case "codex", "claude", "opencode": + return WorkSubagentCapability(canList: true, canViewFullTranscript: true) + case "cursor", "droid", "factory": + return WorkSubagentCapability(canList: true, canViewFullTranscript: false) + default: + return .none + } +} + +func workSubagentRunningCount(_ snapshots: [WorkSubagentSnapshot]) -> Int { + snapshots.filter { $0.status == .running }.count +} + +func workSubagentMeaningfulName(_ snapshot: WorkSubagentSnapshot) -> String { + let genericAgentTypes: Set = ["opencode-subagent", "subagent"] + let agentType = snapshot.agentType?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !agentType.isEmpty, !genericAgentTypes.contains(agentType.lowercased()) { + return agentType + } + let description = snapshot.description.trimmingCharacters(in: .whitespacesAndNewlines) + if !description.isEmpty, description.lowercased() != "subagent" { + return description + } + if let agentId = snapshot.agentId?.trimmingCharacters(in: .whitespacesAndNewlines), + !agentId.isEmpty { + return agentId + } + return snapshot.taskId +} + +func workSubagentSelection(from snapshot: WorkSubagentSnapshot) -> WorkSubagentSelection { + WorkSubagentSelection( + taskId: snapshot.taskId, + agentId: snapshot.agentId, + name: workSubagentMeaningfulName(snapshot), + status: snapshot.status, + background: snapshot.background + ) +} + +func workToolActivityPresentation(tool: String, argsText: String?) -> WorkToolActivityPresentation { + let normalized = tool.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let label: String + if normalized.contains("grep") || normalized.contains("search") { + label = "Grepping" + } else if normalized.contains("read") { + label = "Reading" + } else if normalized.contains("write") { + label = "Writing" + } else if normalized.contains("edit") { + label = "Editing" + } else if normalized.contains("bash") || normalized.contains("shell") || normalized.contains("command") { + label = "Running command" + } else if normalized.contains("web") || normalized.contains("browser") { + label = "Browsing" + } else if normalized.contains("list") || normalized.contains("ls") { + label = "Listing" + } else { + let display = toolDisplayName(tool).trimmingCharacters(in: .whitespacesAndNewlines) + label = display.isEmpty || display == "Tool" ? "Working" : display + } + return WorkToolActivityPresentation( + label: label, + detail: workToolArgPreview(tool: tool, argsText: argsText) + ) +} + +func workToolArgPreview(tool: String, argsText: String?) -> String? { + guard let argsText else { return nil } + let trimmed = argsText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let object = workJSONObject(from: trimmed) { + let keys = ["file_path", "path", "pattern", "query", "command", "cmd", "url"] + for key in keys { + if let value = object[key] as? String, + let text = nonEmptyWorkToolArgPreview(value) { + return text + } + } + } + + let firstLine = trimmed.split(separator: "\n", omittingEmptySubsequences: false).first.map(String.init) ?? trimmed + return nonEmptyWorkToolArgPreview(firstLine) +} + +private func nonEmptyWorkToolArgPreview(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + if trimmed.count <= 72 { return trimmed } + return "...\(trimmed.suffix(69))" +} + func terminalSessionHasResumeTarget(_ session: TerminalSessionSummary) -> Bool { if session.resumeMetadata != nil { return true @@ -342,11 +472,7 @@ func normalizedWorkChatSessionStatus(session: TerminalSessionSummary?, summary: private let workChatStaleAfterSeconds: TimeInterval = 7 * 24 * 60 * 60 private func workChatLastActivityDate(_ raw: String) -> Date? { - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - if let d = iso.date(from: raw) { return d } - iso.formatOptions = [.withInternetDateTime] - return iso.date(from: raw) + workParsedDate(raw) } private func rawWorkChatSessionStatus(session: TerminalSessionSummary?, summary: AgentChatSessionSummary?) -> String { @@ -831,12 +957,34 @@ private let workDateFormatterFractional: ISO8601DateFormatter = { return formatter }() +private final class WorkParsedDateCacheBox: NSObject { + let date: Date + + init(_ date: Date) { + self.date = date + } +} + +private let workParsedDateCache: NSCache = { + let cache = NSCache() + cache.countLimit = 2_048 + return cache +}() + func workParsedDate(_ value: String?) -> Date? { guard let value, !value.isEmpty else { return nil } + let cacheKey = value as NSString + if let cached = workParsedDateCache.object(forKey: cacheKey) { + return cached.date + } // Sync hosts emit timestamps with fractional seconds (`...095Z`); the default // ISO8601DateFormatter rejects those, leaving `relativeTimestamp` to fall back // to the raw ISO string and leaking it into the UI. - return workDateFormatterFractional.date(from: value) ?? workDateFormatter.date(from: value) + guard let date = workDateFormatterFractional.date(from: value) ?? workDateFormatter.date(from: value) else { + return nil + } + workParsedDateCache.setObject(WorkParsedDateCacheBox(date), forKey: cacheKey) + return date } func formattedSessionDuration(startedAt: String, endedAt: String?) -> String { diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 27f2cc509..76a042e85 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -8,16 +8,27 @@ func buildWorkChatTimelineSnapshot( artifacts: [ComputerUseArtifactSummary], localEchoMessages: [WorkLocalEchoMessage] ) -> WorkChatTimelineSnapshot { + let signature = workChatTimelineSnapshotSignature( + transcript: transcript, + fallbackEntries: fallbackEntries, + artifacts: artifacts, + localEchoMessages: localEchoMessages + ) let pendingInputs = derivePendingWorkInputs(from: transcript) let pendingSteers = derivePendingWorkSteers(from: transcript) let suppressedItemIds = Set(pendingInputs.map(\.itemId)) let suppressedToolItemIds = Set(pendingInputs.map(\.itemId)) - let toolCards = buildWorkToolCards(from: transcript, suppressedPendingItemIds: suppressedToolItemIds) + let toolCards = buildWorkMobileTimelineToolCards(from: transcript, suppressedPendingItemIds: suppressedToolItemIds) + .filter(workMobileShowsToolCardInTimeline) let eventCards = buildWorkEventCards(from: transcript, suppressedItemIds: suppressedItemIds) - let commandCards = buildWorkCommandCards(from: transcript) - let fileChangeCards = buildWorkFileChangeCards(from: transcript) + .filter { $0.kind != "toolUseSummary" } + let commandCards: [WorkCommandCardModel] = [] + let fileChangeCards: [WorkFileChangeCardModel] = [] let subagentSnapshots = buildWorkSubagentSnapshots(from: transcript) let transcriptIndicatesActiveTurn = workTranscriptIndicatesActiveTurn(transcript) + let transcriptLatestTurnEnded = workTranscriptLatestTurnEnded(transcript) + let transcriptHasInterruptibleActivity = WorkActivityIndicator.derivePresentation(from: transcript) != nil + let latestTranscriptTimestamp = latestWorkTranscriptTimestamp(transcript) let timeline = buildWorkTimeline( transcript: transcript, fallbackEntries: fallbackEntries, @@ -31,6 +42,7 @@ func buildWorkChatTimelineSnapshot( ) return WorkChatTimelineSnapshot( + signature: signature, pendingInputs: pendingInputs, pendingSteers: pendingSteers, toolCards: toolCards, @@ -39,10 +51,333 @@ func buildWorkChatTimelineSnapshot( fileChangeCards: fileChangeCards, subagentSnapshots: subagentSnapshots, transcriptIndicatesActiveTurn: transcriptIndicatesActiveTurn, + transcriptLatestTurnEnded: transcriptLatestTurnEnded, + transcriptHasInterruptibleActivity: transcriptHasInterruptibleActivity, + latestTranscriptTimestamp: latestTranscriptTimestamp, + latestMessageAssistantId: latestWorkTimelineMessageAssistantId(timeline), timeline: timeline ) } +private func workChatTimelineSnapshotSignature( + transcript: [WorkChatEnvelope], + fallbackEntries: [AgentChatTranscriptEntry], + artifacts: [ComputerUseArtifactSummary], + localEchoMessages: [WorkLocalEchoMessage] +) -> Int { + var hasher = Hasher() + hasher.combine(transcript.count) + for envelope in transcript { + hasher.combine(envelope.sessionId) + hasher.combine(envelope.timestamp) + hasher.combine(envelope.sequence ?? Int.min) + combineWorkChatEventSignature(envelope.event, into: &hasher) + } + + if transcript.isEmpty { + hasher.combine(fallbackEntries.count) + for entry in fallbackEntries { + hasher.combine(entry.role) + combineLongTextSignature(entry.text, into: &hasher) + hasher.combine(entry.timestamp) + combineOptional(entry.turnId, into: &hasher) + combineOptional(entry.messageId, into: &hasher) + combineOptional(entry.itemId, into: &hasher) + } + } else { + hasher.combine(0) + } + + hasher.combine(artifacts.count) + for artifact in artifacts { + hasher.combine(artifact.id) + hasher.combine(artifact.artifactKind) + hasher.combine(artifact.backendStyle) + hasher.combine(artifact.backendName) + combineOptional(artifact.sourceToolName, into: &hasher) + combineOptional(artifact.originalType, into: &hasher) + hasher.combine(artifact.title) + combineOptional(artifact.description, into: &hasher) + hasher.combine(artifact.uri) + hasher.combine(artifact.storageKind) + combineOptional(artifact.mimeType, into: &hasher) + combineOptional(artifact.metadataJson, into: &hasher) + hasher.combine(artifact.createdAt) + hasher.combine(artifact.ownerKind) + hasher.combine(artifact.ownerId) + hasher.combine(artifact.relation) + combineOptional(artifact.reviewState, into: &hasher) + combineOptional(artifact.workflowState, into: &hasher) + combineOptional(artifact.reviewNote, into: &hasher) + } + + hasher.combine(localEchoMessages.count) + for echo in localEchoMessages { + hasher.combine(echo.id) + combineLongTextSignature(echo.text, into: &hasher) + hasher.combine(echo.timestamp) + combineOptional(echo.deliveryState, into: &hasher) + } + + return hasher.finalize() +} + +private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: inout Hasher) { + hasher.combine(event.typeKey) + switch event { + case .userMessage(let text, let attachments, let turnId, let steerId, let deliveryState, let processed): + combineLongTextSignature(text, into: &hasher) + combineAgentChatFileRefs(attachments, into: &hasher) + combineOptional(turnId, into: &hasher) + combineOptional(steerId, into: &hasher) + combineOptional(deliveryState, into: &hasher) + combineOptional(processed, into: &hasher) + case .assistantText(let text, let turnId, let itemId): + combineLongTextSignature(text, into: &hasher) + combineOptional(turnId, into: &hasher) + combineOptional(itemId, into: &hasher) + case .toolCall(let tool, let argsText, let itemId, let parentItemId, let turnId): + hasher.combine(tool) + combineLongTextSignature(argsText, into: &hasher) + hasher.combine(itemId) + combineOptional(parentItemId, into: &hasher) + combineOptional(turnId, into: &hasher) + case .toolResult(let tool, let resultText, let itemId, let parentItemId, let turnId, let status): + hasher.combine(tool) + combineLongTextSignature(resultText, into: &hasher) + hasher.combine(itemId) + combineOptional(parentItemId, into: &hasher) + combineOptional(turnId, into: &hasher) + hasher.combine(status.rawValue) + case .activity(let kind, let detail, let turnId): + hasher.combine(kind) + combineOptionalText(detail, into: &hasher) + combineOptional(turnId, into: &hasher) + case .plan(let steps, let explanation, let turnId): + combinePlanSteps(steps, into: &hasher) + combineOptionalText(explanation, into: &hasher) + combineOptional(turnId, into: &hasher) + case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let turnId): + hasher.combine(taskId) + combineOptional(agentId, into: &hasher) + combineOptional(agentType, into: &hasher) + combineOptional(parentToolUseId, into: &hasher) + hasher.combine(description) + hasher.combine(background) + combineOptional(turnId, into: &hasher) + case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, let toolName, let turnId): + hasher.combine(taskId) + combineOptional(agentId, into: &hasher) + combineOptional(agentType, into: &hasher) + combineOptional(parentToolUseId, into: &hasher) + combineOptionalText(description, into: &hasher) + combineLongTextSignature(summary, into: &hasher) + combineOptional(toolName, into: &hasher) + combineOptional(turnId, into: &hasher) + case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let turnId): + hasher.combine(taskId) + combineOptional(agentId, into: &hasher) + combineOptional(agentType, into: &hasher) + combineOptional(parentToolUseId, into: &hasher) + hasher.combine(status) + combineLongTextSignature(summary, into: &hasher) + combineOptional(turnId, into: &hasher) + case .structuredQuestion(let question, let options, let itemId, let turnId): + combineLongTextSignature(question, into: &hasher) + combineQuestionOptions(options, into: &hasher) + hasher.combine(itemId) + combineOptional(turnId, into: &hasher) + case .approvalRequest(let description, let detail, let itemId, let turnId): + combineLongTextSignature(description, into: &hasher) + combineOptionalText(detail, into: &hasher) + hasher.combine(itemId) + combineOptional(turnId, into: &hasher) + case .pendingInputResolved(let itemId, let resolution, let turnId): + hasher.combine(itemId) + combineLongTextSignature(resolution, into: &hasher) + combineOptional(turnId, into: &hasher) + case .todoUpdate(let items, let turnId): + hasher.combine(items.count) + for item in items { + combineLongTextSignature(item, into: &hasher) + } + combineOptional(turnId, into: &hasher) + case .systemNotice(let kind, let message, let detail, let turnId, let steerId): + hasher.combine(kind) + combineLongTextSignature(message, into: &hasher) + combineOptionalText(detail, into: &hasher) + combineOptional(turnId, into: &hasher) + combineOptional(steerId, into: &hasher) + case .error(let message, let detail, let category, let turnId): + combineLongTextSignature(message, into: &hasher) + combineOptionalText(detail, into: &hasher) + hasher.combine(category) + combineOptional(turnId, into: &hasher) + case .done(let status, let summary, let usage, let turnId, let model, let modelId): + hasher.combine(status) + combineLongTextSignature(summary, into: &hasher) + combineUsageSummary(usage, into: &hasher) + hasher.combine(turnId) + combineOptional(model, into: &hasher) + combineOptional(modelId, into: &hasher) + case .tokens(let usage, let turnId, let itemId): + combineUsageSummary(usage, into: &hasher) + hasher.combine(turnId) + combineOptional(itemId, into: &hasher) + case .promptSuggestion(let text, let turnId): + combineLongTextSignature(text, into: &hasher) + combineOptional(turnId, into: &hasher) + case .contextCompact(let summary, let isInProgress, let turnId): + combineLongTextSignature(summary, into: &hasher) + hasher.combine(isInProgress) + combineOptional(turnId, into: &hasher) + case .autoApprovalReview(let summary, let turnId): + combineLongTextSignature(summary, into: &hasher) + combineOptional(turnId, into: &hasher) + case .webSearch(let query, let action, let status, let itemId, let turnId): + combineLongTextSignature(query, into: &hasher) + combineOptionalText(action, into: &hasher) + hasher.combine(status.rawValue) + hasher.combine(itemId) + combineOptional(turnId, into: &hasher) + case .planText(let text, let turnId): + combineLongTextSignature(text, into: &hasher) + combineOptional(turnId, into: &hasher) + case .toolUseSummary(let text, let turnId): + combineLongTextSignature(text, into: &hasher) + combineOptional(turnId, into: &hasher) + case .status(let turnStatus, let message, let turnId): + hasher.combine(turnStatus) + combineOptionalText(message, into: &hasher) + combineOptional(turnId, into: &hasher) + case .reasoning(let text, let turnId, let itemId, let summaryIndex): + combineLongTextSignature(text, into: &hasher) + combineOptional(turnId, into: &hasher) + combineOptional(itemId, into: &hasher) + combineOptional(summaryIndex, into: &hasher) + case .completionReport(let summary, let status, let artifacts, let blockerDescription, let turnId): + combineLongTextSignature(summary, into: &hasher) + hasher.combine(status) + combineCompletionArtifacts(artifacts, into: &hasher) + combineOptionalText(blockerDescription, into: &hasher) + combineOptional(turnId, into: &hasher) + case .command(let command, let cwd, let output, let status, let itemId, let exitCode, let durationMs, let turnId): + combineLongTextSignature(command, into: &hasher) + hasher.combine(cwd) + combineLongTextSignature(output, into: &hasher) + hasher.combine(status.rawValue) + hasher.combine(itemId) + combineOptional(exitCode, into: &hasher) + combineOptional(durationMs, into: &hasher) + combineOptional(turnId, into: &hasher) + case .fileChange(let path, let diff, let kind, let status, let itemId, let turnId): + hasher.combine(path) + combineLongTextSignature(diff, into: &hasher) + hasher.combine(kind) + hasher.combine(status.rawValue) + hasher.combine(itemId) + combineOptional(turnId, into: &hasher) + case .unknown(let type): + hasher.combine(type) + } +} + +private func combineOptional(_ value: Value?, into hasher: inout Hasher) { + hasher.combine(value != nil) + if let value { + hasher.combine(value) + } +} + +private func combineOptionalText(_ value: String?, into hasher: inout Hasher) { + hasher.combine(value != nil) + if let value { + combineLongTextSignature(value, into: &hasher) + } +} + +private func combineLongTextSignature(_ text: String, into hasher: inout Hasher) { + let utf8Count = text.utf8.count + hasher.combine(utf8Count) + guard utf8Count > 1_024 else { + hasher.combine(text) + return + } + hasher.combine(text.prefix(512)) + hasher.combine(text.suffix(512)) +} + +private func combineAgentChatFileRefs(_ refs: [AgentChatFileRef]?, into hasher: inout Hasher) { + hasher.combine(refs?.count ?? -1) + for ref in refs ?? [] { + hasher.combine(ref.path) + hasher.combine(ref.type) + combineOptional(ref.url, into: &hasher) + } +} + +private func combinePlanSteps(_ steps: [WorkPlanStep], into hasher: inout Hasher) { + hasher.combine(steps.count) + for step in steps { + combineLongTextSignature(step.text, into: &hasher) + hasher.combine(step.status) + } +} + +private func combineQuestionOptions(_ options: [WorkPendingQuestionOption], into hasher: inout Hasher) { + hasher.combine(options.count) + for option in options { + combineLongTextSignature(option.label, into: &hasher) + combineLongTextSignature(option.value, into: &hasher) + combineOptionalText(option.description, into: &hasher) + hasher.combine(option.recommended) + combineOptionalText(option.preview, into: &hasher) + combineOptionalText(option.previewFormat, into: &hasher) + } +} + +private func combineUsageSummary(_ usage: WorkUsageSummary?, into hasher: inout Hasher) { + hasher.combine(usage != nil) + guard let usage else { return } + hasher.combine(usage.turnCount) + hasher.combine(usage.inputTokens) + hasher.combine(usage.outputTokens) + hasher.combine(usage.cacheReadTokens) + hasher.combine(usage.cacheCreationTokens) + hasher.combine(usage.reasoningTokens) + hasher.combine(usage.totalTokens) + combineOptional(usage.contextWindow, into: &hasher) + hasher.combine(usage.costUsd) +} + +private func combineCompletionArtifacts(_ artifacts: [WorkCompletionArtifactModel], into hasher: inout Hasher) { + hasher.combine(artifacts.count) + for artifact in artifacts { + hasher.combine(artifact.type) + combineLongTextSignature(artifact.description, into: &hasher) + combineOptional(artifact.reference, into: &hasher) + } +} + +private func latestWorkTranscriptTimestamp(_ transcript: [WorkChatEnvelope]) -> String? { + var latest: String? + for envelope in transcript { + guard !envelope.timestamp.isEmpty else { continue } + if latest.map({ envelope.timestamp > $0 }) ?? true { + latest = envelope.timestamp + } + } + return latest +} + +private func latestWorkTimelineMessageAssistantId(_ timeline: [WorkTimelineEntry]) -> String? { + for entry in timeline.reversed() { + guard case .message(let message) = entry.payload else { continue } + return message.role == "assistant" ? message.id : nil + } + return nil +} + func workTranscriptIndicatesActiveTurn(_ transcript: [WorkChatEnvelope]) -> Bool { var activeTurnIds = Set() var bootstrapStartOpen = false @@ -103,22 +438,24 @@ func workChatIsStreaming( sessionStatus: String, isLive: Bool, transcriptIndicatesActiveTurn: Bool, - liveTurnActiveHint: Bool = false, + liveTurnActiveHint: Bool? = nil, transcriptLatestTurnEnded: Bool = false, rowEndedAfterLatestTranscript: Bool = false ) -> Bool { guard isLive else { return false } if sessionStatus == "ended" { return false } if rowEndedAfterLatestTranscript { return false } + if liveTurnActiveHint == false { return false } if transcriptIndicatesActiveTurn { return true } - if liveTurnActiveHint { return true } + if liveTurnActiveHint == true { return true } if transcriptLatestTurnEnded { return false } return sessionStatus == "active" } -/// Collapse `subagent_*` events into one snapshot per taskId. Preserves host -/// order via a first-seen index so completed subagents don't jump around when -/// a later progress event lands. +/// Collapse `subagent_*` events into one snapshot per runtime subagent. Codex +/// can first emit a parent-tool placeholder keyed by `parentToolUseId`, then a +/// real agent row keyed by `agentId`; mirror desktop by adopting that placeholder +/// into the real row instead of rendering both. func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSubagentSnapshot] { struct Entry { var snapshot: WorkSubagentSnapshot @@ -127,55 +464,130 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub var entries: [String: Entry] = [:] var next = 0 - func place(_ taskId: String, _ snapshot: WorkSubagentSnapshot) { - if let existing = entries[taskId] { - entries[taskId] = Entry(snapshot: snapshot, order: existing.order) + func identityKey(taskId: String, agentId: String?) -> String { + normalizedWorkSubagentAgentId(agentId) ?? taskId + } + + let resolvedKeysByParent = buildResolvedWorkSubagentKeysByParent(from: transcript) + + func isParentPlaceholder(_ snapshot: WorkSubagentSnapshot, parentToolUseId: String) -> Bool { + snapshot.agentId == nil + && snapshot.taskId == parentToolUseId + && snapshot.parentToolUseId == parentToolUseId + } + + func resolve( + taskId: String, + agentId: String?, + parentToolUseId rawParentToolUseId: String? + ) -> (key: String, existing: WorkSubagentSnapshot?, order: Int?, adoptedPlaceholder: Bool) { + let key = identityKey(taskId: taskId, agentId: agentId) + let direct = entries[key] + let parentToolUseId = normalizedWorkSubagentAgentId(rawParentToolUseId) + let parentEntry = parentToolUseId.flatMap { entries[$0] } + let parentResolvedKeys = parentToolUseId.flatMap { resolvedKeysByParent[$0] } + let canAdoptParentPlaceholder = parentToolUseId.flatMap { parent in + guard let parentEntry, + isParentPlaceholder(parentEntry.snapshot, parentToolUseId: parent), + parentResolvedKeys?.count == 1, + parentResolvedKeys?.contains(key) == true + else { return false } + return true + } ?? false + + let taskAliasEntry = key != taskId ? entries[taskId] : nil + let taskAliasIsParentPlaceholder = taskAliasEntry.flatMap { entry in + parentToolUseId.flatMap { parent in + taskId == parent && isParentPlaceholder(entry.snapshot, parentToolUseId: parent) + } + } ?? false + let taskAlias = (taskAliasEntry != nil && (!taskAliasIsParentPlaceholder || canAdoptParentPlaceholder)) + ? taskAliasEntry + : nil + let adoptParentEntry = taskAlias == nil && canAdoptParentPlaceholder ? parentEntry : nil + let adoptedPlaceholder = adoptParentEntry != nil || (taskAlias != nil && taskAliasIsParentPlaceholder) + + if taskAlias != nil, key != taskId { + entries.removeValue(forKey: taskId) + } + if adoptParentEntry != nil, let parentToolUseId { + entries.removeValue(forKey: parentToolUseId) + } else if let parentToolUseId, + let parentEntry, + isParentPlaceholder(parentEntry.snapshot, parentToolUseId: parentToolUseId), + let parentResolvedKeys, + parentResolvedKeys.count > 1 { + entries.removeValue(forKey: parentToolUseId) + } + + let adopted = direct ?? taskAlias ?? adoptParentEntry + return (key, adopted?.snapshot, adopted?.order, adoptedPlaceholder) + } + + func place(_ key: String, _ snapshot: WorkSubagentSnapshot, order preferredOrder: Int? = nil) { + if let existing = entries[key] { + entries[key] = Entry(snapshot: snapshot, order: existing.order) } else { - entries[taskId] = Entry(snapshot: snapshot, order: next) - next += 1 + entries[key] = Entry(snapshot: snapshot, order: preferredOrder ?? next) + if preferredOrder == nil { + next += 1 + } } } for envelope in transcript { switch envelope.event { - case .subagentStarted(let taskId, let description, let background, let turnId): - place(taskId, WorkSubagentSnapshot( + case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let turnId): + let resolved = resolve(taskId: taskId, agentId: agentId, parentToolUseId: parentToolUseId) + place(resolved.key, WorkSubagentSnapshot( taskId: taskId, + agentId: normalizedWorkSubagentAgentId(agentId) ?? resolved.existing?.agentId, + agentType: normalizedWorkSubagentAgentId(agentType) ?? resolved.existing?.agentType, + parentToolUseId: normalizedWorkSubagentAgentId(parentToolUseId) ?? resolved.existing?.parentToolUseId, description: description, background: background, status: .running, - lastToolName: entries[taskId]?.snapshot.lastToolName, - latestSummary: entries[taskId]?.snapshot.latestSummary, - turnId: turnId - )) - case .subagentProgress(let taskId, let description, let summary, let toolName, let turnId): - let existing = entries[taskId]?.snapshot - place(taskId, WorkSubagentSnapshot( - taskId: taskId, + lastToolName: resolved.existing?.lastToolName, + latestSummary: resolved.existing?.latestSummary, + turnId: turnId, + startedAt: resolved.existing?.startedAt ?? envelope.timestamp, + updatedAt: envelope.timestamp + ), order: resolved.order) + case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, let toolName, let turnId): + let resolved = resolve(taskId: taskId, agentId: agentId, parentToolUseId: parentToolUseId) + let existing = resolved.existing + place(resolved.key, WorkSubagentSnapshot( + taskId: resolved.adoptedPlaceholder ? taskId : existing?.taskId ?? taskId, + agentId: normalizedWorkSubagentAgentId(agentId) ?? existing?.agentId, + agentType: normalizedWorkSubagentAgentId(agentType) ?? existing?.agentType, + parentToolUseId: normalizedWorkSubagentAgentId(parentToolUseId) ?? existing?.parentToolUseId, description: description ?? existing?.description ?? "Subagent", background: existing?.background ?? false, status: .running, lastToolName: toolName ?? existing?.lastToolName, latestSummary: summary.isEmpty ? existing?.latestSummary : summary, - turnId: turnId ?? existing?.turnId - )) - case .subagentResult(let taskId, let status, let summary, let turnId): - let normalized: WorkSubagentSnapshot.Status = { - switch status.lowercased() { - case "failed", "error", "cancelled", "canceled": return .failed - default: return .succeeded - } - }() - let existing = entries[taskId]?.snapshot - place(taskId, WorkSubagentSnapshot( - taskId: taskId, + turnId: turnId ?? existing?.turnId, + startedAt: existing?.startedAt ?? envelope.timestamp, + updatedAt: envelope.timestamp + ), order: resolved.order) + case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let turnId): + let normalized = workSubagentStatus(from: status) + let resolved = resolve(taskId: taskId, agentId: agentId, parentToolUseId: parentToolUseId) + let existing = resolved.existing + place(resolved.key, WorkSubagentSnapshot( + taskId: resolved.adoptedPlaceholder ? taskId : existing?.taskId ?? taskId, + agentId: normalizedWorkSubagentAgentId(agentId) ?? existing?.agentId, + agentType: normalizedWorkSubagentAgentId(agentType) ?? existing?.agentType, + parentToolUseId: normalizedWorkSubagentAgentId(parentToolUseId) ?? existing?.parentToolUseId, description: existing?.description ?? "Subagent", background: existing?.background ?? false, status: normalized, lastToolName: existing?.lastToolName, latestSummary: summary.isEmpty ? existing?.latestSummary : summary, - turnId: turnId ?? existing?.turnId - )) + turnId: turnId ?? existing?.turnId, + startedAt: existing?.startedAt ?? envelope.timestamp, + updatedAt: envelope.timestamp + ), order: resolved.order) default: break } @@ -186,6 +598,165 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub .map { $0.snapshot } } +private func buildResolvedWorkSubagentKeysByParent(from transcript: [WorkChatEnvelope]) -> [String: Set] { + var keysByParent: [String: Set] = [:] + for envelope in transcript { + let taskId: String + let agentId: String? + let parentToolUseId: String? + switch envelope.event { + case .subagentStarted(let value, let agent, _, let parent, _, _, _): + taskId = value + agentId = agent + parentToolUseId = parent + case .subagentProgress(let value, let agent, _, let parent, _, _, _, _): + taskId = value + agentId = agent + parentToolUseId = parent + case .subagentResult(let value, let agent, _, let parent, _, _, _): + taskId = value + agentId = agent + parentToolUseId = parent + default: + continue + } + guard let parent = normalizedWorkSubagentAgentId(parentToolUseId) else { continue } + let key = normalizedWorkSubagentAgentId(agentId) ?? taskId + guard key != parent else { continue } + keysByParent[parent, default: []].insert(key) + } + return keysByParent +} + +private func normalizedWorkSubagentAgentId(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +} + +func workSubagentStatus(from raw: String) -> WorkSubagentSnapshot.Status { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "running", "started", "in_progress": return .running + case "failed", "error", "cancelled", "canceled": return .failed + case "stopped", "halted", "interrupted": return .stopped + default: return .succeeded + } +} + +func workSubagentSnapshot(from remote: SyncService.AgentChatSubagentSnapshot) -> WorkSubagentSnapshot { + let startedAt = remote.startTimestamp ?? remote.endTimestamp + let updatedAt = remote.endTimestamp ?? remote.startTimestamp + return WorkSubagentSnapshot( + taskId: remote.taskId, + agentId: normalizedWorkSubagentAgentId(remote.agentId), + agentType: normalizedWorkSubagentAgentId(remote.agentType), + parentToolUseId: normalizedWorkSubagentAgentId(remote.parentToolUseId), + description: remote.description, + background: remote.background ?? false, + status: workSubagentStatus(from: remote.status), + lastToolName: remote.lastToolName, + latestSummary: remote.finalSummary ?? remote.summary, + turnId: remote.turnId, + startedAt: startedAt, + updatedAt: updatedAt + ) +} + +func mergeWorkSubagentSnapshots( + local: [WorkSubagentSnapshot], + remote: [WorkSubagentSnapshot] +) -> [WorkSubagentSnapshot] { + guard !remote.isEmpty else { return local } + guard !local.isEmpty else { return remote } + + var merged = remote + var indexByKey: [String: Int] = [:] + + func register(_ snapshot: WorkSubagentSnapshot, at index: Int) { + for key in workSubagentSnapshotLookupKeys(snapshot) { + indexByKey[key] = index + } + } + + for (index, snapshot) in merged.enumerated() { + register(snapshot, at: index) + } + + for snapshot in local { + let index = workSubagentSnapshotLookupKeys(snapshot) + .compactMap { indexByKey[$0] } + .first + if let index { + merged[index] = mergedWorkSubagentSnapshot(remote: merged[index], local: snapshot) + register(merged[index], at: index) + } else { + let index = merged.count + merged.append(snapshot) + register(snapshot, at: index) + } + } + + return merged +} + +private func workSubagentSnapshotLookupKeys(_ snapshot: WorkSubagentSnapshot) -> [String] { + var keys: [String] = [] + for value in [snapshot.agentId, Optional(snapshot.taskId), snapshot.parentToolUseId] { + guard let key = normalizedWorkSubagentAgentId(value), + !keys.contains(key) + else { continue } + keys.append(key) + } + return keys +} + +private func mergedWorkSubagentSnapshot( + remote: WorkSubagentSnapshot, + local: WorkSubagentSnapshot +) -> WorkSubagentSnapshot { + WorkSubagentSnapshot( + taskId: remote.taskId, + agentId: remote.agentId ?? local.agentId, + agentType: local.agentType ?? remote.agentType, + parentToolUseId: remote.parentToolUseId ?? local.parentToolUseId, + description: preferredWorkSubagentText(remote.description, fallback: local.description) ?? "Subagent", + background: remote.background || local.background, + status: mergedWorkSubagentStatus(remote: remote.status, local: local.status), + lastToolName: local.lastToolName ?? remote.lastToolName, + latestSummary: preferredWorkSubagentText(remote.latestSummary, fallback: local.latestSummary), + turnId: remote.turnId ?? local.turnId, + startedAt: remote.startedAt ?? local.startedAt, + updatedAt: latestWorkSubagentTimestamp(remote.updatedAt, local.updatedAt) + ) +} + +private func mergedWorkSubagentStatus( + remote: WorkSubagentSnapshot.Status, + local: WorkSubagentSnapshot.Status +) -> WorkSubagentSnapshot.Status { + if remote != .running { return remote } + if local != .running { return local } + return .running +} + +private func preferredWorkSubagentText(_ primary: String?, fallback: String?) -> String? { + let primaryTrimmed = primary?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if !primaryTrimmed.isEmpty { return primary } + let fallbackTrimmed = fallback?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return fallbackTrimmed.isEmpty ? nil : fallback +} + +private func preferredWorkSubagentText(_ primary: String, fallback: String) -> String? { + preferredWorkSubagentText(Optional(primary), fallback: Optional(fallback)) +} + +private func latestWorkSubagentTimestamp(_ lhs: String?, _ rhs: String?) -> String? { + guard let lhs else { return rhs } + guard let rhs else { return lhs } + let lhsDate = workParsedDate(lhs) ?? .distantPast + let rhsDate = workParsedDate(rhs) ?? .distantPast + return rhsDate >= lhsDate ? rhs : lhs +} + func buildWorkTimeline( transcript: [WorkChatEnvelope], fallbackEntries: [AgentChatTranscriptEntry], @@ -268,14 +839,8 @@ func buildWorkTimeline( rank: 1_600 + index, payload: .pendingPermission(model) ) - case .planApproval(let model): - let ts = pendingTimestamps[model.id] ?? fallbackPendingTimestamp - return WorkTimelineEntry( - id: "pending-plan-approval-\(model.id)", - timestamp: ts, - rank: 1_600 + index, - payload: .pendingPlanApproval(model) - ) + case .planApproval: + return nil case .modelSelection(let model): let ts = pendingTimestamps[model.id] ?? fallbackPendingTimestamp return WorkTimelineEntry( @@ -642,6 +1207,7 @@ func buildWorkEventCards( ) -> [WorkEventCardModel] { var byId: [String: WorkEventCardModel] = [:] var order: [String] = [] + let terminalDoneTurnIds = workTerminalDoneTurnIds(from: transcript) for envelope in transcript { if !suppressedItemIds.isEmpty { switch envelope.event { @@ -653,6 +1219,9 @@ func buildWorkEventCards( break } } + if redundantWorkTerminalStatus(envelope.event, terminalDoneTurnIds: terminalDoneTurnIds) { + continue + } guard let card = eventCard(for: envelope) else { continue } if let existing = byId[card.id], let merged = mergedWorkEventCard(existing, with: card) { byId[card.id] = merged @@ -664,6 +1233,29 @@ func buildWorkEventCards( return order.compactMap { byId[$0] } } +private func workTerminalDoneTurnIds(from transcript: [WorkChatEnvelope]) -> Set { + Set(transcript.compactMap { envelope in + guard case .done(_, _, _, let turnId, _, _) = envelope.event else { return nil } + return normalizedWorkTurnId(turnId) + }) +} + +private func redundantWorkTerminalStatus( + _ event: WorkChatEvent, + terminalDoneTurnIds: Set +) -> Bool { + guard case .status(let turnStatus, let message, let turnId) = event, + let key = normalizedWorkTurnId(turnId), + terminalDoneTurnIds.contains(key) + else { + return false + } + let normalizedStatus = turnStatus.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedMessage = message?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + return (normalizedMessage.isEmpty || normalizedMessage == normalizedStatus) + && (normalizedStatus == "interrupted" || normalizedStatus == "failed") +} + private func workReasoningCardId( sessionId: String, turnId: String?, @@ -700,6 +1292,28 @@ private func workContextCompactCardId( return fallback } +private func workPlanCardId( + sessionId: String, + turnId: String?, + fallback: String +) -> String { + if let turnId, !turnId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return ["plan", sessionId, "turn", turnId].joined(separator: ":") + } + return fallback +} + +private func workPlanTextCardId( + sessionId: String, + turnId: String?, + fallback: String +) -> String { + if let turnId, !turnId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return ["plan-text", sessionId, "turn", turnId].joined(separator: ":") + } + return fallback +} + private func mergeWorkInlineText(_ existing: String, _ incoming: String) -> String { if existing.isEmpty { return incoming } if incoming.isEmpty { return existing } @@ -713,13 +1327,8 @@ private func mergeWorkInlineText(_ existing: String, _ incoming: String) -> Stri } private func laterWorkTimestamp(_ lhs: String, _ rhs: String) -> String { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let fallbackFormatter = ISO8601DateFormatter() - fallbackFormatter.formatOptions = [.withInternetDateTime] - - let lhsDate = formatter.date(from: lhs) ?? fallbackFormatter.date(from: lhs) - let rhsDate = formatter.date(from: rhs) ?? fallbackFormatter.date(from: rhs) + let lhsDate = workParsedDate(lhs) + let rhsDate = workParsedDate(rhs) if let lhsDate, let rhsDate { return rhsDate >= lhsDate ? rhs : lhs @@ -879,6 +1488,34 @@ private func mergedWorkEventCard(_ existing: WorkEventCardModel, with incoming: planSteps: incoming.planSteps.isEmpty ? existing.planSteps : incoming.planSteps ) } + if existing.kind == "plan" { + return WorkEventCardModel( + id: incoming.id, + kind: incoming.kind, + title: incoming.title, + icon: incoming.icon, + tint: incoming.tint, + timestamp: laterWorkTimestamp(existing.timestamp, incoming.timestamp), + body: nonEmptyWorkTimelineText(incoming.body) ?? existing.body, + bullets: incoming.bullets.isEmpty ? existing.bullets : incoming.bullets, + metadata: incoming.metadata.isEmpty ? existing.metadata : incoming.metadata, + planSteps: incoming.planSteps.isEmpty ? existing.planSteps : incoming.planSteps + ) + } + if existing.kind == "planText" { + return WorkEventCardModel( + id: incoming.id, + kind: incoming.kind, + title: incoming.title, + icon: incoming.icon, + tint: incoming.tint, + timestamp: laterWorkTimestamp(existing.timestamp, incoming.timestamp), + body: mergeWorkInlineText(existing.body ?? "", incoming.body ?? ""), + bullets: incoming.bullets.isEmpty ? existing.bullets : incoming.bullets, + metadata: incoming.metadata.isEmpty ? existing.metadata : incoming.metadata, + planSteps: incoming.planSteps.isEmpty ? existing.planSteps : incoming.planSteps + ) + } return incoming } @@ -912,15 +1549,18 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { // redundant rows under each tool group. Live streaming hints come from // WorkActivityIndicator, not the persisted timeline. return nil - case .plan(let steps, let explanation, _): + case .plan(let steps, let explanation, let turnId): + guard !steps.isEmpty || nonEmptyWorkTimelineText(explanation) != nil else { + return nil + } return WorkEventCardModel( - id: envelope.id, + id: workPlanCardId(sessionId: envelope.sessionId, turnId: turnId, fallback: envelope.id), kind: "plan", title: "Plan", icon: "list.bullet.clipboard", tint: .accent, timestamp: envelope.timestamp, - body: explanation, + body: nonEmptyWorkTimelineText(explanation), bullets: steps.map { $0.text }, metadata: [], planSteps: steps @@ -1060,9 +1700,9 @@ private func eventCard(for envelope: WorkChatEnvelope) -> WorkEventCardModel? { // alongside Read/Bash/etc. instead of leaking out as standalone event // cards that break the surrounding tool group. return nil - case .planText(let text, _): + case .planText(let text, let turnId): return WorkEventCardModel( - id: envelope.id, + id: workPlanTextCardId(sessionId: envelope.sessionId, turnId: turnId, fallback: envelope.id), kind: "planText", title: "Plan detail", icon: "text.alignleft", @@ -1143,7 +1783,7 @@ func isLowSignalWorkStatus(turnStatus: String, message: String?) -> Bool { return normalizedMessage.isEmpty && (normalizedStatus == "started" || normalizedStatus == "completed") } -let workTimelinePageSize = 80 +let workTimelinePageSize = 36 func visibleWorkTimelineEntries(from entries: [WorkTimelineEntry], visibleCount: Int) -> [WorkTimelineEntry] { let clampedCount = max(visibleCount, 0) @@ -1177,19 +1817,45 @@ func injectWorkTurnSeparators( into entries: [WorkTimelineEntry], chatSummary: AgentChatSessionSummary?, transcript: [WorkChatEnvelope] = [] +) -> [WorkTimelineEntry] { + injectWorkTurnSeparators( + into: entries, + provider: chatSummary?.provider ?? "", + model: chatSummary?.model ?? "", + modelId: chatSummary?.modelId, + transcript: transcript + ) +} + +func injectWorkTurnSeparators( + into entries: [WorkTimelineEntry], + provider: String, + model: String, + modelId: String?, + transcript: [WorkChatEnvelope] = [] ) -> [WorkTimelineEntry] { guard !entries.isEmpty else { return entries } var seenTurnIds = Set() var output: [WorkTimelineEntry] = [] output.reserveCapacity(entries.count + 4) - let fallbackModelId = chatSummary?.modelId ?? chatSummary?.model + let fallbackModelId = modelId ?? model let fallbackMetadata = WorkTurnModelMetadata( - provider: chatSummary?.provider ?? "", - modelLabel: prettyWorkChatModelName(chatSummary?.model ?? ""), + provider: provider, + modelLabel: prettyWorkChatModelName(model), modelId: fallbackModelId ) - let metadataByTurn = workTurnModelMetadataByTurn(from: transcript, fallback: fallbackMetadata) + let visibleTurnIds = Set(entries.compactMap { entry -> String? in + guard case .message(let message) = entry.payload, + message.role.lowercased() == "user" + else { return nil } + return normalizedWorkTurnId(message.turnId) + }) + let metadataByTurn = workTurnModelMetadataByTurn( + from: transcript, + fallback: fallbackMetadata, + matchingTurnIds: visibleTurnIds + ) for entry in entries { if case .message(let message) = entry.payload, message.role.lowercased() == "user" { @@ -1244,14 +1910,19 @@ func workTurnEndMarkers(from transcript: [WorkChatEnvelope]) -> [WorkTurnEndMark default: continue } - case .done(_, _, _, let turnId, _, _): + case .done(let status, _, _, let turnId, let model, let modelId): guard let key = normalizedWorkTurnId(turnId) else { continue } guard !seenEndedTurns.contains(key), let start = startByTurn[key] else { continue } seenEndedTurns.insert(key) + let metadata = workTurnModelMetadata(model: model, modelId: modelId, fallbackProvider: "") markers.append(WorkTurnEndMarker( turnId: key, time: envelope.timestamp, - workedDurationLabel: formattedSessionDuration(startedAt: start, endedAt: envelope.timestamp) + workedDurationLabel: formattedSessionDuration(startedAt: start, endedAt: envelope.timestamp), + status: status, + provider: metadata.provider, + modelLabel: metadata.modelLabel, + modelId: metadata.modelId )) default: guard let key = normalizedWorkTurnId(workTurnId(for: envelope.event)), startByTurn[key] == nil else { continue } @@ -1275,9 +1946,9 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .toolResult(_, _, _, _, let turnId, _), .activity(_, _, let turnId), .plan(_, _, let turnId), - .subagentStarted(_, _, _, let turnId), - .subagentProgress(_, _, _, _, let turnId), - .subagentResult(_, _, _, let turnId), + .subagentStarted(_, _, _, _, _, _, let turnId), + .subagentProgress(_, _, _, _, _, _, _, let turnId), + .subagentResult(_, _, _, _, _, _, let turnId), .structuredQuestion(_, _, _, let turnId), .approvalRequest(_, _, _, let turnId), .pendingInputResolved(_, _, let turnId), @@ -1313,29 +1984,65 @@ struct WorkTurnModelMetadata { func workTurnModelMetadataByTurn( from transcript: [WorkChatEnvelope], - fallback: WorkTurnModelMetadata? = nil + fallback: WorkTurnModelMetadata? = nil, + matchingTurnIds: Set? = nil ) -> [String: WorkTurnModelMetadata] { var metadataByTurn: [String: WorkTurnModelMetadata] = [:] + if let matchingTurnIds { + guard !matchingTurnIds.isEmpty else { return metadataByTurn } + for envelope in transcript.reversed() { + guard case .done(_, _, _, let turnId, let model, let modelId) = envelope.event else { continue } + let normalizedTurnId = turnId.trimmingCharacters(in: .whitespacesAndNewlines) + guard matchingTurnIds.contains(normalizedTurnId), + metadataByTurn[normalizedTurnId] == nil else { continue } + metadataByTurn[normalizedTurnId] = workTurnModelMetadata( + model: model, + modelId: modelId, + fallbackProvider: fallback?.provider ?? "", + fallbackModelLabel: fallback?.modelLabel ?? "Model", + fallbackModelId: fallback?.modelId + ) + if metadataByTurn.count == matchingTurnIds.count { break } + } + return metadataByTurn + } + for envelope in transcript { guard case .done(_, _, _, let turnId, let model, let modelId) = envelope.event else { continue } let normalizedTurnId = turnId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedTurnId.isEmpty else { continue } - let rawModel = [model, modelId] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } - ?? "" - let rawModelId = [modelId, model] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty } - metadataByTurn[normalizedTurnId] = WorkTurnModelMetadata( - provider: workModelCatalogGroupKey(for: rawModelId ?? rawModel, currentProvider: fallback?.provider ?? ""), - modelLabel: rawModel.isEmpty ? fallback?.modelLabel ?? "Model" : prettyWorkChatModelName(rawModel), - modelId: rawModelId ?? fallback?.modelId + metadataByTurn[normalizedTurnId] = workTurnModelMetadata( + model: model, + modelId: modelId, + fallbackProvider: fallback?.provider ?? "", + fallbackModelLabel: fallback?.modelLabel ?? "Model", + fallbackModelId: fallback?.modelId ) } return metadataByTurn } +private func workTurnModelMetadata( + model: String?, + modelId: String?, + fallbackProvider: String, + fallbackModelLabel: String = "Model", + fallbackModelId: String? = nil +) -> WorkTurnModelMetadata { + let rawModel = [model, modelId] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + ?? "" + let rawModelId = [modelId, model] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty } + return WorkTurnModelMetadata( + provider: workModelCatalogGroupKey(for: rawModelId ?? rawModel, currentProvider: fallbackProvider), + modelLabel: rawModel.isEmpty ? fallbackModelLabel : prettyWorkChatModelName(rawModel), + modelId: rawModelId ?? fallbackModelId + ) +} + /// Beautify a host-supplied model id into the label used on chips and turn /// separators. Mirrors the desktop composer's display: "Claude Sonnet 4.6", /// "GPT-5.4", etc., so iOS and desktop read the same. @@ -1440,6 +2147,18 @@ func workContextUsageViewModel( ) -> WorkContextUsageViewModel? { let provider = summary?.provider ?? "" let fallbackWindow = workContextWindowFallback(summary: summary) + return workContextUsageViewModel( + transcript: transcript, + provider: provider, + fallbackContextWindow: fallbackWindow + ) +} + +func workContextUsageViewModel( + transcript: [WorkChatEnvelope], + provider: String, + fallbackContextWindow: Int? +) -> WorkContextUsageViewModel? { for envelope in sortedWorkChatEnvelopes(transcript).reversed() { switch envelope.event { @@ -1447,14 +2166,14 @@ func workContextUsageViewModel( return makeWorkContextUsageViewModel( usage: usage, provider: provider, - fallbackContextWindow: fallbackWindow + fallbackContextWindow: fallbackContextWindow ) case .done(_, _, let usage, _, _, _): if let usage { return makeWorkContextUsageViewModel( usage: usage, provider: provider, - fallbackContextWindow: fallbackWindow + fallbackContextWindow: fallbackContextWindow ) } default: @@ -1518,20 +2237,24 @@ private func workProviderUsesCodexTokenOccupancy(_ provider: String) -> Bool { return normalized == "codex" || normalized == "openai" } -private func workContextWindowFallback(summary: AgentChatSessionSummary?) -> Int? { +func workContextWindowFallback(summary: AgentChatSessionSummary?) -> Int? { guard let summary else { return nil } - let model = [summary.modelId, summary.model] + return workContextWindowFallback(modelId: summary.modelId, model: summary.model) +} + +func workContextWindowFallback(modelId: String?, model: String) -> Int? { + let modelKey = [modelId, Optional(model)] .compactMap { $0?.lowercased() } .joined(separator: " ") - if model.contains("1m") || model.contains("1-million") { return 1_000_000 } - if model.contains("gpt-5") { + if modelKey.contains("1m") || modelKey.contains("1-million") { return 1_000_000 } + if modelKey.contains("gpt-5") { return 258_400 } - if model.contains("claude") || model.contains("sonnet") || model.contains("opus") || model.contains("haiku") || model.contains("fable") { + if modelKey.contains("claude") || modelKey.contains("sonnet") || modelKey.contains("opus") || modelKey.contains("haiku") || modelKey.contains("fable") { return 200_000 } - if model.contains("gpt-4.1") || model.contains("gpt-4o") { + if modelKey.contains("gpt-4.1") || modelKey.contains("gpt-4o") { return 128_000 } return nil diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index f33b02b89..37d3bbf98 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -112,6 +112,9 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { case "subagent_started": event = .subagentStarted( taskId: stringValue(eventDict["taskId"]), + agentId: optionalString(eventDict["agentId"]), + agentType: optionalString(eventDict["agentType"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]), description: stringValue(eventDict["description"]), background: (eventDict["background"] as? Bool) ?? false, turnId: turnId @@ -119,6 +122,9 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { case "subagent_progress": event = .subagentProgress( taskId: stringValue(eventDict["taskId"]), + agentId: optionalString(eventDict["agentId"]), + agentType: optionalString(eventDict["agentType"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]), description: optionalString(eventDict["description"]), summary: stringValue(eventDict["summary"]), toolName: optionalString(eventDict["lastToolName"]), @@ -127,6 +133,9 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { case "subagent_result": event = .subagentResult( taskId: stringValue(eventDict["taskId"]), + agentId: optionalString(eventDict["agentId"]), + agentType: optionalString(eventDict["agentType"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]), status: stringValue(eventDict["status"]), summary: stringValue(eventDict["summary"]), turnId: turnId diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 7ee23a590..01148588c 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -1526,6 +1526,10 @@ final class ADETests: XCTestCase { syncConnectPortCandidates(primaryPort: 8787, addresses: ["Aruls-Mac-Studio.local."]), [8787] ) + XCTAssertEqual( + syncConnectPortCandidates(primaryPort: 8787, addresses: ["192.168.1.8"], allowFallbackSweep: false), + [8787] + ) XCTAssertEqual( syncConnectPortCandidates(primaryPort: 8787, addresses: ["192.168.1.8"]).prefix(3), [8787, 8788, 8789] @@ -2226,30 +2230,6 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.subscribedTerminalSessionIds, Set(["terminal-1"])) } - @MainActor - func testTerminalFallbackFingerprintUsesPerSessionRevision() { - let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) - - service.seedTerminalBufferForTesting(sessionId: "terminal-1", transcript: "first history") - let firstFingerprint = workRootLiveTranscriptFingerprint( - chatEventRevision: 0, - streamedEventCount: 0, - terminalBufferRevision: service.terminalBufferRevisionsBySessionId["terminal-1"], - terminalTail: service.terminalBuffers["terminal-1"] - ) - - service.seedTerminalBufferForTesting(sessionId: "terminal-2", transcript: "second history") - let unchangedFingerprint = workRootLiveTranscriptFingerprint( - chatEventRevision: 0, - streamedEventCount: 0, - terminalBufferRevision: service.terminalBufferRevisionsBySessionId["terminal-1"], - terminalTail: service.terminalBuffers["terminal-1"] - ) - - XCTAssertEqual(unchangedFingerprint, firstFingerprint) - XCTAssertEqual(service.terminalBufferRevisionsBySessionId["terminal-2"], 1) - } - @MainActor func testChatEventHistoryStoresDecodedEnvelopes() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -4005,6 +3985,66 @@ final class ADETests: XCTestCase { database.close() } + func testReplaceTerminalSessionsDetachesClaudeSessionsBeforeDeletingStaleSessionsWithOrphanedLanes() throws { + let baseURL = makeTemporaryDirectory() + let database = makeTerminalSessionSyncDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values ( + 'project-1', '/tmp/project', 'ADE', 'main', '2026-04-20T00:00:00.000Z', '2026-04-20T00:00:00.000Z' + ); + insert into lanes ( + id, project_id, name, lane_type, base_ref, branch_ref, worktree_path, status, created_at + ) values ( + 'lane-1', 'project-1', 'Primary', 'primary', 'main', 'main', '/tmp/project', 'active', '2026-04-20T00:00:00.000Z' + ); + create table if not exists claude_sessions ( + session_id text primary key, + lane_id text not null, + chat_session_id text unique, + title text, + tags_json text, + created_at text not null, + updated_at text not null, + foreign key(lane_id) references lanes(id), + foreign key(chat_session_id) references terminal_sessions(id) on delete set null + ); + """) + + let staleSession = makeTerminalSessionSummary( + id: "stale-session", + laneId: "lane-1", + laneName: "Primary", + toolType: "codex-chat", + title: "Stale" + ) + let keptSession = makeTerminalSessionSummary( + id: "kept-session", + laneId: "lane-1", + laneName: "Primary", + toolType: "codex-chat", + title: "Kept" + ) + try database.replaceTerminalSessions([staleSession, keptSession]) + try database.executeSqlForTesting(""" + pragma foreign_keys = off; + insert into claude_sessions ( + session_id, lane_id, chat_session_id, title, tags_json, created_at, updated_at + ) values ( + 'legacy-claude-session', 'missing-lane', 'stale-session', 'Legacy', null, '2026-04-20T00:01:00.000Z', '2026-04-20T00:01:00.000Z' + ); + pragma foreign_keys = on; + """) + + XCTAssertNoThrow(try database.replaceTerminalSessions([keptSession])) + XCTAssertEqual(try countRows(in: baseURL, table: "terminal_sessions"), 1) + XCTAssertEqual(try countRows(in: baseURL, table: "claude_sessions where chat_session_id is null"), 1) + database.close() + } + func testReplaceTerminalSessionsSkipsSessionsForMissingLanes() throws { let baseURL = makeTemporaryDirectory() let database = makeTerminalSessionSyncDatabase(baseURL: baseURL) @@ -6945,6 +6985,13 @@ final class ADETests: XCTestCase { chatSendQueueable: false ) ) + XCTAssertFalse( + workChatSendWillQueueMessage( + isLive: true, + hostReachable: true, + chatSendQueueable: true + ) + ) } func testWorkChatActiveTurnUsesSteerAndClaudeOnlyManualDispatch() { @@ -6970,9 +7017,9 @@ final class ADETests: XCTestCase { XCTAssertEqual(syncChatMessageDelivery(from: NSNull()), .sent) } - func testWorkChatLiveObservationKeyUsesCoalescedNotificationRevision() { + func testWorkChatLiveObservationKeyUsesSessionScopedRevision() { XCTAssertEqual( - workChatLiveObservationKey(sessionId: "chat-1", chatEventNotificationRevision: 7), + workChatLiveObservationKey(sessionId: "chat-1", chatEventRevision: 7), "chat-1-7" ) } @@ -7037,41 +7084,6 @@ final class ADETests: XCTestCase { ) } - func testWorkRootLiveTranscriptFingerprintIgnoresTerminalTailWhenEventsExist() { - let firstTail = String(repeating: "A", count: 200_000) - let secondTail = String(repeating: "B", count: 200_000) - - XCTAssertEqual( - workRootLiveTranscriptFingerprint( - chatEventRevision: 12, - streamedEventCount: 42, - terminalBufferRevision: 1, - terminalTail: firstTail - ), - workRootLiveTranscriptFingerprint( - chatEventRevision: 12, - streamedEventCount: 42, - terminalBufferRevision: 2, - terminalTail: secondTail - ) - ) - - XCTAssertNotEqual( - workRootLiveTranscriptFingerprint( - chatEventRevision: 12, - streamedEventCount: 0, - terminalBufferRevision: 1, - terminalTail: firstTail - ), - workRootLiveTranscriptFingerprint( - chatEventRevision: 12, - streamedEventCount: 0, - terminalBufferRevision: 2, - terminalTail: firstTail - ) - ) - } - func testBuildPullRequestTimelineOrdersStateReviewsAndComments() { let pr = PullRequestListItem( id: "pr-9", @@ -7657,6 +7669,147 @@ final class ADETests: XCTestCase { XCTAssertTrue(toolCards.first?.resultText?.contains("ADE") == true) } + func testWorkSubagentSnapshotsPreserveAgentIdAndRunningCount() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","description":"Docs helper","background":true,"turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"subagent_progress","taskId":"task-1","agentId":"agent-1","summary":"Reading README.md","lastToolName":"functions.Read","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"subagent_started","taskId":"task-2","agentId":"agent-2","description":"Done helper","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"subagent_result","taskId":"task-2","agentId":"agent-2","status":"completed","summary":"Done","turnId":"turn-1"}} + """ + + let snapshots = buildWorkSubagentSnapshots(from: parseWorkChatTranscript(raw)) + + XCTAssertEqual(workSubagentRunningCount(snapshots), 1) + XCTAssertEqual(snapshots.first?.taskId, "task-1") + XCTAssertEqual(snapshots.first?.agentId, "agent-1") + XCTAssertEqual(snapshots.first?.background, true) + XCTAssertEqual(snapshots.first?.lastToolName, "functions.Read") + XCTAssertEqual(snapshots.first?.startedAt, "2026-03-25T00:00:01.000Z") + XCTAssertEqual(snapshots.first?.updatedAt, "2026-03-25T00:00:02.000Z") + } + + func testWorkSubagentSnapshotsAdoptCodexPlaceholderAndPreserveStoppedAgentName() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-06-30T03:47:24.583Z","sequence":1,"event":{"type":"subagent_started","taskId":"call_abc","parentToolUseId":"call_abc","description":"Throwaway ADE mobile subagent UI test","background":false,"turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-06-30T03:47:24.865Z","sequence":2,"event":{"type":"subagent_started","taskId":"agent-1","agentId":"agent-1","agentType":"Sagan","parentToolUseId":"call_abc","description":"Throwaway ADE mobile subagent UI test","background":false,"turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-06-30T03:47:36.283Z","sequence":3,"event":{"type":"subagent_result","taskId":"agent-1","parentToolUseId":"call_abc","status":"stopped","summary":"Parent turn completed before ADE received a final subagent status","turnId":"turn-1"}} + """ + + let snapshots = buildWorkSubagentSnapshots(from: parseWorkChatTranscript(raw)) + + XCTAssertEqual(snapshots.count, 1) + XCTAssertEqual(snapshots.first?.taskId, "agent-1") + XCTAssertEqual(snapshots.first?.agentId, "agent-1") + XCTAssertEqual(snapshots.first?.agentType, "Sagan") + XCTAssertEqual(snapshots.first?.parentToolUseId, "call_abc") + XCTAssertEqual(snapshots.first?.status, .stopped) + XCTAssertEqual(snapshots.first?.latestSummary, "Parent turn completed before ADE received a final subagent status") + XCTAssertEqual(workSubagentRunningCount(snapshots), 0) + XCTAssertEqual(snapshots.first.map(workSubagentMeaningfulName), "Sagan") + } + + func testWorkSubagentSnapshotsKeepHistoricalRemoteRosterAndMergeLocalDetails() { + let remote = [ + WorkSubagentSnapshot( + taskId: "old-agent", + agentId: "old-agent", + agentType: "Old", + parentToolUseId: "call-old", + description: "Old helper", + background: false, + status: .stopped, + lastToolName: nil, + latestSummary: "Finished earlier", + turnId: "old-turn", + startedAt: "2026-06-30T01:00:00.000Z", + updatedAt: "2026-06-30T01:02:00.000Z" + ), + WorkSubagentSnapshot( + taskId: "agent-1", + agentId: "agent-1", + agentType: "Remote", + parentToolUseId: "call-new", + description: "Throwaway ADE mobile subagent UI test", + background: false, + status: .running, + lastToolName: nil, + latestSummary: nil, + turnId: "new-turn", + startedAt: "2026-06-30T03:47:24.865Z", + updatedAt: "2026-06-30T03:47:24.865Z" + ), + ] + let local = [ + WorkSubagentSnapshot( + taskId: "agent-1", + agentId: "agent-1", + agentType: "Sagan", + parentToolUseId: "call-new", + description: "Local detail", + background: false, + status: .stopped, + lastToolName: "Read", + latestSummary: "Parent turn completed before ADE received a final subagent status", + turnId: "new-turn", + startedAt: "2026-06-30T03:47:24.865Z", + updatedAt: "2026-06-30T03:47:36.283Z" + ), + ] + + let snapshots = mergeWorkSubagentSnapshots(local: local, remote: remote) + + XCTAssertEqual(snapshots.map(\.taskId), ["old-agent", "agent-1"]) + XCTAssertEqual(snapshots[0].status, .stopped) + XCTAssertEqual(snapshots[1].agentType, "Sagan") + XCTAssertEqual(snapshots[1].status, .stopped) + XCTAssertEqual(snapshots[1].lastToolName, "Read") + XCTAssertEqual(workSubagentRunningCount(snapshots), 0) + } + + func testWorkSubagentCapabilityMatchesDesktopTakeoverRules() { + XCTAssertTrue(workResolveSubagentCapability(provider: "codex").canViewFullTranscript) + XCTAssertTrue(workResolveSubagentCapability(provider: "claude").canViewFullTranscript) + XCTAssertTrue(workResolveSubagentCapability(provider: "opencode").canViewFullTranscript) + XCTAssertFalse(workResolveSubagentCapability(provider: "cursor").canViewFullTranscript) + XCTAssertFalse(workResolveSubagentCapability(provider: "droid").canViewFullTranscript) + } + + func testWorkSubagentTranscriptMessagesConvertToChatEnvelopes() { + let messages = [ + SyncService.AgentChatSubagentTranscriptMessage( + type: "user", + uuid: "u-1", + sessionId: "child-1", + parentToolUseId: nil, + message: nil, + text: "Inspect this", + subagentMetadata: nil + ), + SyncService.AgentChatSubagentTranscriptMessage( + type: "assistant", + uuid: "a-1", + sessionId: "child-1", + parentToolUseId: nil, + message: nil, + text: "Done", + subagentMetadata: nil + ), + ] + + let envelopes = workSubagentTranscriptToEnvelopes(messages: messages, sessionId: "parent-1") + + XCTAssertEqual(envelopes.count, 2) + guard case .userMessage(let userText, _, _, _, _, _) = envelopes[0].event else { + return XCTFail("Expected first subagent transcript row to be a user message.") + } + XCTAssertEqual(userText, "Inspect this") + guard case .assistantText(let assistantText, _, let itemId) = envelopes[1].event else { + return XCTFail("Expected second subagent transcript row to be assistant text.") + } + XCTAssertEqual(assistantText, "Done") + XCTAssertEqual(itemId, "a-1") + } + func testWorkChatTranscriptUsesMessageIdToSplitAssistantMessages() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-04-22T22:10:00.000Z","sequence":1,"event":{"type":"user_message","text":"Rebase Windows Port","turnId":"turn-1"}} @@ -7706,7 +7859,6 @@ final class ADETests: XCTestCase { XCTAssertEqual(visibleKinds, [ "user", "assistant:msg-progress", - "tool:tool-1", "assistant:msg-final", ]) } @@ -7742,6 +7894,96 @@ final class ADETests: XCTestCase { ]) } + func testWorkChatMessagesDoNotMergeCanonicalAssistantTranscriptRows() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:01.000Z", + sequence: nil, + event: .assistantText(text: "First complete historical update.", turnId: "turn-1", itemId: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:02.000Z", + sequence: nil, + event: .assistantText(text: "Second complete historical update.", turnId: "turn-1", itemId: nil) + ), + ] + + let assistantMessages = buildWorkChatMessages(from: transcript) + .filter { $0.role == "assistant" } + + XCTAssertEqual(assistantMessages.map(\.markdown), [ + "First complete historical update.", + "Second complete historical update.", + ]) + } + + func testMakeWorkChatTranscriptPreservesTranscriptEntryMessageId() { + let transcript = makeWorkChatTranscript( + from: [ + AgentChatTranscriptEntry( + role: "assistant", + text: "Message-id backed history row.", + timestamp: "2026-04-22T22:10:01.000Z", + turnId: "turn-1", + messageId: "message-1" + ), + ], + sessionId: "chat-1" + ) + + guard case .assistantText(let text, let turnId, let itemId) = transcript.first?.event else { + return XCTFail("Expected assistant text.") + } + XCTAssertEqual(text, "Message-id backed history row.") + XCTAssertEqual(turnId, "turn-1") + XCTAssertEqual(itemId, "message-1") + } + + func testWorkChatMessagesMergeDuplicateUserMessageVariantsByTurn() { + let prompt = "as this turn is underway, the auto scroll is clearly broken" + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:01.000Z", + sequence: 1, + event: .userMessage( + text: prompt, + attachments: nil, + turnId: "turn-1", + steerId: nil, + deliveryState: "delivered", + processed: nil + ) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-22T22:10:02.000Z", + sequence: 2, + event: .userMessage( + text: prompt, + attachments: [ + AgentChatFileRef(path: "screenshot.png", type: "image", url: nil), + ], + turnId: "turn-1", + steerId: nil, + deliveryState: nil, + processed: true + ) + ), + ] + + let messages = buildWorkChatMessages(from: transcript) + .filter { $0.role == "user" } + + XCTAssertEqual(messages.count, 1) + XCTAssertEqual(messages.first?.markdown, prompt) + XCTAssertEqual(messages.first?.deliveryState, "delivered") + XCTAssertEqual(messages.first?.processed, true) + XCTAssertEqual(messages.first?.attachments?.map(\.path), ["screenshot.png"]) + } + func testWorkChatMessagesSuppressDuplicateAssistantSuffixAcrossTools() { let fullText = "Got it, I’ll include the top-left back button in this pass too and shrink its hit chrome without disturbing the title layout." let duplicateTail = "-left back button in this pass too and shrink its hit chrome without disturbing the title layout." @@ -7803,6 +8045,96 @@ final class ADETests: XCTestCase { ]) } + func testWorkLanesUseDesktopDefaultOrder() { + var primary = makeLaneSummary( + id: "lane-primary", + name: "Primary", + laneType: "primary", + branchRef: "main" + ) + primary.createdAt = "2026-03-01T00:00:00.000Z" + var older = makeLaneSummary( + id: "lane-older", + name: "Older feature", + laneType: "worktree", + branchRef: "ade/older" + ) + older.createdAt = "2026-03-20T00:00:00.000Z" + var newer = makeLaneSummary( + id: "lane-newer", + name: "Newer feature", + laneType: "worktree", + branchRef: "ade/newer" + ) + newer.createdAt = "2026-03-25T00:00:00.000Z" + + let ordered = sortWorkLanesForTabs([older, primary, newer]) + + XCTAssertEqual(ordered.map(\.id), ["lane-primary", "lane-newer", "lane-older"]) + } + + func testWorkRootPresentationNestsChatOwnedShellsUnderParentSession() { + let lane = makeLaneSummary( + id: "lane-primary", + name: "Primary", + laneType: "primary", + branchRef: "main" + ) + let parentChat = makeTerminalSessionSummary( + id: "chat-parent", + laneId: "lane-primary", + laneName: "Primary", + toolType: "codex-chat", + title: "Settings Secrets Tab", + startedAt: "2026-03-25T12:00:00.000Z" + ) + let olderShell = makeTerminalSessionSummary( + id: "shell-older", + laneId: "lane-primary", + laneName: "Primary", + toolType: "shell", + title: "App Control: setup", + startedAt: "2026-03-25T12:01:00.000Z", + chatSessionId: "chat-parent" + ) + let newerShell = makeTerminalSessionSummary( + id: "shell-newer", + laneId: "lane-primary", + laneName: "Primary", + toolType: "shell", + title: "App Control: verify", + startedAt: "2026-03-25T12:02:00.000Z", + chatSessionId: "chat-parent" + ) + let standaloneShell = makeTerminalSessionSummary( + id: "shell-standalone", + laneId: "lane-primary", + laneName: "Primary", + toolType: "shell", + title: "Standalone terminal", + startedAt: "2026-03-25T12:03:00.000Z" + ) + + let presentation = buildWorkRootSessionPresentation( + sessions: [standaloneShell, newerShell, parentChat, olderShell], + optimisticSessions: [:], + chatSummaries: [:], + archivedSessionIds: [], + selectedStatus: .all, + selectedLaneId: "all", + searchText: "", + organization: .byLane, + orderedLanes: [lane] + ) + + XCTAssertEqual(presentation.childGroupsByParentId["chat-parent"]?.children.map(\.id), ["shell-older", "shell-newer"]) + XCTAssertEqual(presentation.childGroupsByParentId["chat-parent"]?.collapsedSectionId, "chat:chat-parent") + XCTAssertFalse(presentation.topLevelDisplaySessionIds.contains("shell-older")) + XCTAssertFalse(presentation.topLevelDisplaySessionIds.contains("shell-newer")) + XCTAssertTrue(presentation.topLevelDisplaySessionIds.contains("chat-parent")) + XCTAssertTrue(presentation.topLevelDisplaySessionIds.contains("shell-standalone")) + } + func testWorkSessionGroupsByLaneSurfacesOrphanLanesPerLaneId() { let knownLane = LaneSummary( id: "lane-primary", @@ -8159,6 +8491,35 @@ final class ADETests: XCTestCase { ) } + func testWorkChatComposerPlaceholderUsesPlanReviewCopyForPlanApprovalOnly() { + let planInput = WorkPendingInputItem.planApproval(WorkPendingPlanApprovalModel( + id: "plan-1", + source: "codex", + planText: "## Plan\nShip the compact strip.", + title: "Plan Ready for Review" + )) + let questionInput = WorkPendingInputItem.question(WorkPendingQuestionModel( + id: "question-1", + questions: [ + WorkPendingQuestion( + questionId: "response", + question: "Which option?", + options: [], + allowsFreeform: true + ), + ] + )) + + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputs: [planInput], sessionStatus: "awaiting-input"), + "Review the plan above..." + ) + XCTAssertEqual( + workChatComposerPlaceholder(pendingInputs: [planInput, questionInput], sessionStatus: "awaiting-input"), + "Answer the prompt above..." + ) + } + func testWorkChatStatusNormalizationFallsBackToSessionRuntimeStateAndTerminalState() { let completedSummary = makeAgentChatSessionSummary(status: "completed", awaitingInput: false) XCTAssertEqual(normalizedWorkChatSessionStatus(session: nil, summary: completedSummary), "ended") @@ -8180,6 +8541,10 @@ final class ADETests: XCTestCase { XCTAssertTrue(isChatSession(makeTerminalSessionSummary(toolType: "codex-chat"))) XCTAssertTrue(isChatSession(makeTerminalSessionSummary(toolType: "cursor"))) XCTAssertTrue(isChatSession(makeTerminalSessionSummary(toolType: "custom-chat"))) + XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: "codex"))) + XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: "claude"))) + XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: "opencode"))) + XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: "droid"))) XCTAssertFalse(isChatSession(makeTerminalSessionSummary(toolType: "run-shell"))) XCTAssertTrue(isRunOwnedSession(makeTerminalSessionSummary(toolType: "run-shell"))) XCTAssertTrue(isRunOwnedSession(makeTerminalSessionSummary(toolType: " RUN-SHELL "))) @@ -8695,13 +9060,16 @@ final class ADETests: XCTestCase { XCTAssertEqual(ctoAvatarPaletteIndex(for: "anything", paletteSize: 0), 0) } - func testMergeWorkChatTranscriptsReplacesDuplicatesAndSortsByTime() { + func testMergeWorkChatTranscriptsReplacesDuplicatesAndKeepsAssistantItemsStable() { + let existingText = "I am adding Meta as a first-class health signal now. That means ADE can distinguish app installed from repo not installed." + let replayedTail = "Meta as a first-class health signal now. That means ADE can distinguish app installed from repo not installed. Next I will wire the relay status." + let expectedAssistantText = "I am adding \(replayedTail)" let base = [ WorkChatEnvelope( sessionId: "chat-1", timestamp: "2026-03-25T00:00:02.000Z", sequence: 2, - event: .assistantText(text: "Second", turnId: "turn-1", itemId: "msg-2") + event: .assistantText(text: existingText, turnId: "turn-1", itemId: "msg-2") ), WorkChatEnvelope( sessionId: "chat-1", @@ -8721,18 +9089,29 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:03.000Z", sequence: 3, + event: .assistantText(text: replayedTail, turnId: "turn-1", itemId: "msg-2") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:04.000Z", + sequence: 4, event: .assistantText(text: "Third", turnId: "turn-1", itemId: "msg-3") ), ] let merged = mergeWorkChatTranscripts(base: base, live: live) + let messages = buildWorkChatMessages(from: merged) XCTAssertEqual(merged.count, 3) XCTAssertEqual(merged.map(\.timestamp), [ "2026-03-25T00:00:01.000Z", - "2026-03-25T00:00:02.000Z", "2026-03-25T00:00:03.000Z", + "2026-03-25T00:00:04.000Z", ]) + XCTAssertEqual(merged[1].id, "chat-1:assistant-text:turn-1:msg-2") + XCTAssertEqual(messages.map(\.markdown), ["First", expectedAssistantText, "Third"]) + XCTAssertFalse(messages[1].markdown.contains("repo not installed.Meta")) + XCTAssertEqual(messages[1].markdown.components(separatedBy: "first-class health signal now").count - 1, 1) } /// Regression: hosts occasionally replay the same activity envelope during resume, so the cached @@ -8944,6 +9323,43 @@ final class ADETests: XCTestCase { XCTAssertEqual(messages.filter { $0.role == "assistant" }.map(\.markdown), [fullText]) } + func testPreferredWorkTranscriptKeepsFullFallbackStableWhenLiveTailReplays() { + let fullText = (1...200).map(String.init).joined(separator: "\n") + let tailText = (121...200).map(String.init).joined(separator: "\n") + let fallback = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: nil, + event: .assistantText(text: fullText, turnId: "turn-1", itemId: nil) + ), + ] + let liveTail = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 98, + event: .assistantText(text: tailText, turnId: "turn-1", itemId: "msg-1") + ), + ] + let first = preferredWorkTranscript( + current: liveTail, + fallback: fallback, + eventTranscript: liveTail + ) + + let second = preferredWorkTranscript( + current: first, + fallback: fallback, + eventTranscript: liveTail + ) + let messages = buildWorkChatMessages(from: second) + + XCTAssertEqual(first.count, 1) + XCTAssertEqual(second, first) + XCTAssertEqual(messages.filter { $0.role == "assistant" }.map(\.markdown), [fullText]) + } + func testPreferredWorkTranscriptDoesNotBackfillQueuedSteerAsPlainUserMessage() { let fallback = [ WorkChatEnvelope( @@ -9438,6 +9854,100 @@ final class ADETests: XCTestCase { XCTAssertEqual(visibleWorkTimelineEntries(from: entries, visibleCount: 10).map(\.id), entries.map(\.id)) } + func testWorkTimelineRenderEntriesSplitAssistantMarkdownIntoStableRows() { + let markdown = """ + # Status + + First paragraph. + + - one + - two + """ + var message = WorkChatMessage( + id: "assistant-1", + role: "assistant", + markdown: markdown, + timestamp: "2026-03-25T00:00:01.000Z", + turnId: "turn-1", + itemId: "item-1" + ) + message.assistantPreview = workInitialAssistantMessagePreview(markdown) + let entry = WorkTimelineEntry( + id: "message-assistant-1", + timestamp: message.timestamp, + rank: 0, + payload: .message(message) + ) + + let rendered = workTimelineRenderEntries(from: [entry], streamingAssistantMessageId: nil) + + XCTAssertEqual(rendered.count, 3) + XCTAssertTrue(rendered.allSatisfy { $0.sourceEntryId == entry.id }) + XCTAssertEqual(Set(rendered.map(\.id)).count, rendered.count) + XCTAssertTrue(rendered.allSatisfy { $0.id.hasPrefix("message-assistant-1-markdown-block-") }) + for row in rendered { + guard case .assistantMarkdownBlock(let block) = row.payload else { + return XCTFail("Expected assistant markdown block render rows.") + } + XCTAssertEqual(block.messageId, "assistant-1") + } + } + + func testWorkTimelineRenderEntriesKeepUserMessagesWhole() { + let message = WorkChatMessage( + id: "user-1", + role: "user", + markdown: "Please continue.", + timestamp: "2026-03-25T00:00:01.000Z", + turnId: "turn-1", + itemId: "item-1" + ) + let entry = WorkTimelineEntry( + id: "message-user-1", + timestamp: message.timestamp, + rank: 0, + payload: .message(message) + ) + + let rendered = workTimelineRenderEntries(from: [entry], streamingAssistantMessageId: nil) + + XCTAssertEqual(rendered.count, 1) + guard case .entry(let renderedEntry) = rendered.first?.payload else { + return XCTFail("Expected the user message to stay a normal timeline row.") + } + XCTAssertEqual(renderedEntry, entry) + } + + func testWorkTimelineRenderEntriesPreserveControlsForTruncatedAssistantMessages() { + let markdown = (1...5000).map { "\($0). Line \($0)" }.joined(separator: "\n") + var message = WorkChatMessage( + id: "assistant-long", + role: "assistant", + markdown: markdown, + timestamp: "2026-03-25T00:00:01.000Z", + turnId: "turn-1", + itemId: "item-1" + ) + message.assistantPreview = workInitialAssistantMessagePreview(markdown) + let entry = WorkTimelineEntry( + id: "message-assistant-long", + timestamp: message.timestamp, + rank: 0, + payload: .message(message) + ) + + let rendered = workTimelineRenderEntries(from: [entry], streamingAssistantMessageId: nil) + guard case .assistantControls(let controls) = rendered.last?.payload else { + return XCTFail("Expected a controls row after the truncated assistant preview.") + } + + XCTAssertEqual(controls.messageId, "assistant-long") + XCTAssertEqual(controls.visibleLineCount, workAssistantMessageInitialLineBudget) + XCTAssertEqual(controls.totalLineCount, 5000) + XCTAssertTrue(controls.canShowMore) + XCTAssertEqual(controls.nextLineBudget, workAssistantMessageInitialLineBudget + workAssistantMessageLineBudgetStep) + } + func testAssistantMessagePreviewBoundsHugeResponses() { let markdown = (1...5000).map { "\($0). Line \($0)" }.joined(separator: "\n") @@ -9465,6 +9975,26 @@ final class ADETests: XCTestCase { XCTAssertFalse(secondPage.text.contains("97. Line 97")) } + func testAssistantMessagePreviewCapsWireframesBeforeTheyCanOverloadLayout() { + let markdown = (1...120).map { index in + "│ \(String(repeating: "─", count: 72)) │ row \(index)" + }.joined(separator: "\n") + + let firstPage = workAssistantMessagePreview( + markdown, + lineBudget: workAssistantMessageInitialLineBudget, + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: workAssistantMessageInitialLineBudget) + ) + + XCTAssertTrue(workAssistantMessageUsesMonospacedPreview(firstPage.text)) + XCTAssertTrue(firstPage.isTruncated) + XCTAssertEqual(firstPage.visibleLineCount, workAssistantMessageWideInitialLineBudget) + XCTAssertEqual(firstPage.totalLineCount, 120) + XCTAssertTrue(firstPage.text.contains("row 24")) + XCTAssertFalse(firstPage.text.contains("row 25")) + XCTAssertEqual(workAssistantMessageMaxLineBudget(for: firstPage.text), workAssistantMessageWideMaxLineBudget) + } + func testAssistantPreviewCacheHydratesBuiltChatMessages() { let markdown = (1...5000).map { "\($0). Line \($0)" }.joined(separator: "\n") let transcript = [ @@ -10041,6 +10571,46 @@ final class ADETests: XCTestCase { XCTAssertEqual(filtered.map(\.id), ["chat-1"]) } + func testWorkFilteredSessionsHidesStaleStandaloneCliRowsButKeepsChatOwnedShells() { + let chatSession = makeTerminalSessionSummary( + id: "chat-parent", + laneId: "lane-1", + laneName: "feature/work", + toolType: "codex-chat", + title: "Real chat" + ) + let childShell = makeTerminalSessionSummary( + id: "shell-child", + laneId: "lane-1", + laneName: "feature/work", + toolType: "shell", + runtimeState: "stopped", + status: "disposed", + title: "Chat shell", + chatSessionId: "chat-parent" + ) + let staleCli = makeTerminalSessionSummary( + id: "legacy-cli", + laneId: "lane-1", + laneName: "feature/work", + toolType: "codex", + runtimeState: "stopped", + status: "disposed", + title: "Legacy CLI" + ) + + let filtered = workFilteredSessions( + [staleCli, childShell, chatSession], + chatSummaries: [:], + archivedSessionIds: [], + selectedStatus: .all, + selectedLaneId: "all", + searchText: "" + ) + + XCTAssertEqual(filtered.map(\.id), ["chat-parent", "shell-child"]) + } + func testWorkFilteredSessionsPrioritizesWaitingBeforeActiveAndEnded() { let waitingChat = makeTerminalSessionSummary( id: "chat-waiting", @@ -10157,6 +10727,54 @@ final class ADETests: XCTestCase { XCTAssertEqual(message?.deliveryState, "queued") } + func testBuildWorkTimelineOmitsPendingPlanApprovalFromTranscript() { + let detail = """ + { + "request": { + "kind": "plan_approval", + "source": "codex", + "title": "Plan Ready for Review", + "providerMetadata": { + "planContent": "## Plan\\n1. Move the plan gate to the composer." + } + } + } + """ + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:01.000Z", + sequence: 1, + event: .approvalRequest(description: "Plan ready", detail: detail, itemId: "plan-1", turnId: "turn-1") + ), + ] + + let pendingInputs = derivePendingWorkInputs(from: transcript) + XCTAssertEqual(pendingInputs.map(\.itemId), ["plan-1"]) + guard case .planApproval = pendingInputs.first else { + return XCTFail("Expected a pending plan approval.") + } + + let timeline = buildWorkTimeline( + transcript: transcript, + fallbackEntries: [], + toolCards: [], + commandCards: [], + fileChangeCards: [], + eventCards: [], + artifacts: [], + localEchoMessages: [] + ) + + XCTAssertFalse(timeline.contains { entry in + if case .pendingPlanApproval = entry.payload { + return true + } + return false + }) + XCTAssertFalse(timeline.contains { $0.id == "pending-plan-approval-plan-1" }) + } + func testWorkTurnSeparatorsUsePerTurnModelAfterModelSwitch() { let transcript = [ WorkChatEnvelope( @@ -10232,6 +10850,9 @@ final class ADETests: XCTestCase { } XCTAssertEqual(endMarkers.map(\.turnId), ["turn-1", "turn-2"]) XCTAssertEqual(endMarkers.map(\.workedDurationLabel), ["2s", "2s"]) + XCTAssertEqual(endMarkers.map(\.status), ["completed", "completed"]) + XCTAssertEqual(endMarkers.map(\.modelLabel), ["Claude Sonnet 4.6", "GPT 5.4 Mini"]) + XCTAssertEqual(endMarkers.map(\.provider), ["claude", "codex"]) let turnOrder = separated.compactMap { entry -> String? in switch entry.payload { @@ -10296,6 +10917,38 @@ final class ADETests: XCTestCase { XCTAssertEqual(cards.first?.body, "Tool call failed") } + func testWorkEventCardsCollapseInterruptedStatusIntoDoneDivider() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:00.000Z", + sequence: 0, + event: .userMessage(text: "stop this", attachments: nil, turnId: "turn-1", steerId: nil, deliveryState: nil, processed: nil) + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:04.000Z", + sequence: 1, + event: .status(turnStatus: "interrupted", message: "interrupted", turnId: "turn-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:05.000Z", + sequence: 2, + event: .done(status: "interrupted", summary: "Interrupted\ngpt-5.5", usage: nil, turnId: "turn-1", model: "gpt-5.5", modelId: "openai/gpt-5.5") + ), + ] + + XCTAssertTrue(buildWorkEventCards(from: transcript).isEmpty) + + let markers = workTurnEndMarkers(from: transcript) + XCTAssertEqual(markers.map(\.turnId), ["turn-1"]) + XCTAssertEqual(markers.map(\.status), ["interrupted"]) + XCTAssertEqual(markers.map(\.modelLabel), ["GPT-5.5"]) + XCTAssertEqual(markers.map(\.provider), ["codex"]) + XCTAssertEqual(markers.map(\.workedDurationLabel), ["5s"]) + } + func testWorkTimelineSnapshotCachesTranscriptActiveTurnState() { let started = WorkChatEnvelope( sessionId: "chat-1", @@ -10431,6 +11084,49 @@ final class ADETests: XCTestCase { XCTAssertNil(WorkActivityIndicator.derivePresentation(from: transcript)) } + func testWorkActivityIndicatorFormatsElapsedSecondsLikeDesktop() { + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(-4), "0s") + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(4), "4s") + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(59), "59s") + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(60), "1m 00s") + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(61), "1m 01s") + XCTAssertEqual(WorkActivityIndicator.formatElapsedSeconds(2610), "43m 30s") + } + + func testWorkActivityIndicatorUsesToolSpecificVerbAndArgPreview() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:00.000Z", + sequence: 0, + event: .toolCall( + tool: "functions.Grep", + argsText: "{\"pattern\":\"WorkRootScreen\",\"path\":\"apps/ios\"}", + itemId: "tool-1", + parentItemId: nil, + turnId: "turn-1" + ) + ), + ] + + let presentation = WorkActivityIndicator.derivePresentation(from: transcript) + XCTAssertEqual(presentation?.label, "Grepping") + XCTAssertEqual(presentation?.detail, "apps/ios") + } + + func testWorkActivityIndicatorFallsBackToWorkingForActiveStatus() { + let transcript = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-03-25T00:00:00.000Z", + sequence: 0, + event: .status(turnStatus: "started", message: nil, turnId: "turn-1") + ), + ] + + XCTAssertEqual(WorkActivityIndicator.derivePresentation(from: transcript)?.label, "Working") + } + func testWorkInterruptControlHidesAfterCompletedTranscriptTail() { let transcript = [ WorkChatEnvelope( @@ -11718,7 +12414,8 @@ final class ADETests: XCTestCase { startedAt: String = recentIso8601Fixture(), resumeCommand: String? = nil, resumeMetadata: TerminalResumeMetadata? = nil, - archivedAt: String? = nil + archivedAt: String? = nil, + chatSessionId: String? = nil ) -> TerminalSessionSummary { TerminalSessionSummary( id: id, @@ -11744,7 +12441,8 @@ final class ADETests: XCTestCase { runtimeState: runtimeState, resumeCommand: resumeCommand, resumeMetadata: resumeMetadata, - chatIdleSinceAt: nil + chatIdleSinceAt: nil, + chatSessionId: chatSessionId ) } @@ -11959,17 +12657,12 @@ final class ADETests: XCTestCase { artifacts: [], localEchoMessages: [] ) - let toolEntries = snapshot.timeline.filter { $0.id.hasPrefix("tool-") } - XCTAssertEqual(toolEntries.count, 1) - XCTAssertEqual(toolEntries.first?.id, "tool-group:tool-call-dup") - guard case .toolGroup(let group)? = toolEntries.first?.payload else { - return XCTFail("Expected duplicate tool calls to collapse into one tool group.") - } - XCTAssertEqual(group.members.count, 1) - guard case .tool(let groupedCard)? = group.members.first else { - return XCTFail("Expected the collapsed group to retain the deduped tool card.") - } - XCTAssertEqual(groupedCard.id, "call-dup") + XCTAssertTrue(snapshot.toolCards.isEmpty) + XCTAssertFalse(snapshot.timeline.contains { entry in + if case .toolGroup = entry.payload { return true } + if case .toolCard = entry.payload { return true } + return false + }) } func testWorkChatToolLifecycleUsesLogicalItemIdForStableCards() { @@ -12096,23 +12789,12 @@ final class ADETests: XCTestCase { localEchoMessages: [] ) - let toolGroups = snapshot.timeline.compactMap { entry -> WorkToolGroupModel? in - guard case .toolGroup(let group) = entry.payload else { return nil } - return group - } - let standaloneToolCards = snapshot.timeline.compactMap { entry -> WorkToolCardModel? in - guard case .toolCard(let card) = entry.payload else { return nil } - return card - } - - XCTAssertEqual(toolGroups.count, 1) - XCTAssertEqual(toolGroups.first?.members.count, 2) - XCTAssertTrue(standaloneToolCards.isEmpty) - guard case .tool(let latest)? = toolGroups.first?.latest else { - return XCTFail("Expected the latest visible group member to be the newest tool call.") - } - XCTAssertEqual(latest.id, "tool-2") - XCTAssertEqual(latest.status, .running) + XCTAssertTrue(snapshot.toolCards.isEmpty) + XCTAssertFalse(snapshot.timeline.contains { entry in + if case .toolGroup = entry.payload { return true } + if case .toolCard = entry.payload { return true } + return false + }) } func testBuildWorkTimelineWrapsSingleCommandInToolGroup() { @@ -12141,13 +12823,10 @@ final class ADETests: XCTestCase { localEchoMessages: [] ) - XCTAssertEqual(snapshot.timeline.count, 1) - guard case .toolGroup(let group) = snapshot.timeline.first?.payload else { - return XCTFail("Expected a single command to render through the compact tool group panel.") - } - XCTAssertEqual(group.members.count, 1) + XCTAssertTrue(snapshot.commandCards.isEmpty) XCTAssertFalse(snapshot.timeline.contains { entry in if case .commandCard = entry.payload { return true } + if case .toolGroup = entry.payload { return true } return false }) } @@ -12176,13 +12855,12 @@ final class ADETests: XCTestCase { localEchoMessages: [] ) - XCTAssertEqual(snapshot.timeline.count, 1) - guard case .changedFiles(let group) = snapshot.timeline.first?.payload else { - return XCTFail("Expected a single file change to render through the compact files changed panel.") - } - XCTAssertEqual(group.files.count, 1) - XCTAssertEqual(group.files.first?.additions, 1) - XCTAssertEqual(group.files.first?.deletions, 1) + XCTAssertTrue(snapshot.fileChangeCards.isEmpty) + XCTAssertFalse(snapshot.timeline.contains { entry in + if case .fileChangeCard = entry.payload { return true } + if case .changedFiles = entry.payload { return true } + return false + }) } func testBuildWorkCommandCardsDedupesDuplicateCommandEventsByItemId() { @@ -12476,6 +13154,71 @@ final class ADETests: XCTestCase { XCTAssertTrue(cards.isEmpty) } + func testBuildWorkTimelineHidesNormalToolCallsOnMobile() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .assistantText(text: "I will inspect it.", turnId: "turn-1", itemId: "msg-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:02.000Z", + sequence: 2, + event: .toolCall(tool: "functions.Read", argsText: "{\"file_path\":\"README.md\"}", itemId: "tool-1", parentItemId: nil, turnId: "turn-1") + ), + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:03.000Z", + sequence: 3, + event: .toolUseSummary(text: "Read README.md", turnId: "turn-1") + ), + ] + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + + XCTAssertTrue(snapshot.toolCards.isEmpty) + XCTAssertFalse(snapshot.eventCards.contains { $0.kind == "toolUseSummary" }) + XCTAssertEqual(snapshot.timeline.count, 1) + guard case .message(let message)? = snapshot.timeline.first?.payload else { + return XCTFail("Expected only the assistant message to remain visible.") + } + XCTAssertEqual(message.markdown, "I will inspect it.") + } + + func testBuildWorkTimelineKeepsMalformedAskUserFallbackOnMobile() { + let transcript: [WorkChatEnvelope] = [ + WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-04-20T00:00:01.000Z", + sequence: 1, + event: .toolCall(tool: "ask_user", argsText: "{not-json", itemId: "ask-1", parentItemId: nil, turnId: "turn-1") + ), + ] + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: transcript, + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + + XCTAssertEqual(snapshot.toolCards.map(\.id), ["ask-1"]) + XCTAssertTrue(snapshot.timeline.contains { entry in + if case .toolGroup(let group) = entry.payload, + case .tool(let card)? = group.members.first { + return card.id == "ask-1" + } + return false + }) + } + func testDerivePendingWorkInputsSurfacesRequestUserInputRawToolCallAsQuestion() { let argsText = """ {"questions":[{"id":"scope","question":"Pick scope","options":[{"label":"iOS","value":"ios"}]}]} @@ -13112,3 +13855,58 @@ private func XCTAssertThrowsErrorAsync( errorHandler(error) } } + +// MARK: - Roster delta apply (resync correctness) + +final class RosterDeltaTests: XCTestCase { + private func project(_ id: String, running: Int = 0) -> RemoteRosterProject { + RemoteRosterProject( + projectId: id, + rootPath: "/p/\(id)", + displayName: id.capitalized, + iconDataUrl: nil, + lastOpenedAt: nil, + booted: false, + runningCount: running, + attentionCount: 0, + lanes: [], + chats: [] + ) + } + + func testRosterDeltaNeedsSnapshotWithoutBaseline() { + let delta = RemoteRosterDeltaPayload(seq: 5, changed: [project("a")], removed: nil) + XCTAssertEqual(rosterApplyDelta(current: [], currentSeq: nil, delta: delta), .needsSnapshot) + } + + func testRosterDeltaDropsOldOrDuplicateSeq() { + let delta = RemoteRosterDeltaPayload(seq: 3, changed: [project("a")], removed: nil) + XCTAssertEqual(rosterApplyDelta(current: [project("a")], currentSeq: 3, delta: delta), .dropped) + XCTAssertEqual(rosterApplyDelta(current: [project("a")], currentSeq: 4, delta: delta), .dropped) + } + + func testRosterDeltaRequestsSnapshotOnSeqGap() { + let delta = RemoteRosterDeltaPayload(seq: 6, changed: [project("a")], removed: nil) + XCTAssertEqual(rosterApplyDelta(current: [project("a")], currentSeq: 4, delta: delta), .needsSnapshot) + } + + func testRosterDeltaUpsertsChangedAndAdvancesSeq() { + let current = [project("a", running: 0), project("b")] + let delta = RemoteRosterDeltaPayload(seq: 5, changed: [project("a", running: 2)], removed: nil) + guard case let .applied(projects, seq) = rosterApplyDelta(current: current, currentSeq: 4, delta: delta) else { + return XCTFail("expected applied") + } + XCTAssertEqual(seq, 5) + XCTAssertEqual(projects.first { $0.projectId == "a" }?.runningCount, 2) + XCTAssertEqual(projects.count, 2) + } + + func testRosterDeltaRemovesProjects() { + let current = [project("a"), project("b")] + let delta = RemoteRosterDeltaPayload(seq: 5, changed: nil, removed: ["b"]) + guard case let .applied(projects, _) = rosterApplyDelta(current: current, currentSeq: 4, delta: delta) else { + return XCTFail("expected applied") + } + XCTAssertEqual(projects.map(\.projectId).sorted(), ["a"]) + } +} diff --git a/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift b/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift index 7c0e6be79..77d4c6ff3 100644 --- a/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift +++ b/apps/ios/ADETests/WorkMarkdownStreamingParsingTests.swift @@ -64,6 +64,22 @@ final class WorkMarkdownStreamingParsingTests: XCTestCase { """) } + func testOrderedListsSplitByBlankLinesKeepSourceNumbers() { + let blocks = parseMarkdownBlocks(""" + 1. first + + 2. second + + 3. third + """) + + let starts = blocks.compactMap { block -> Int? in + guard case .orderedList(let start, _) = block.kind else { return nil } + return start + } + XCTAssertEqual(starts, [1, 2, 3]) + } + func testStreamingMatchesFullParseWhileCodeFenceOpensAndClosesAcrossDeltas() { // The fence opens in one snapshot and closes many deltas later; the blank // lines INSIDE the fence must never be picked as a split boundary. diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 23e7db419..fa6caace3 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -35,6 +35,7 @@ stream plus session metadata. | `ChatGitToolbar.tsx` | Git status and quick-action toolbar above the composer. The PR action opens or toggles a linked PR when one exists, otherwise opens the PR creation handoff for the current lane targeting the primary branch. Opening the chat PR pane or compact PR menu performs a targeted, cooldown-bound refresh for that single linked PR. | | `ChatPrPane.tsx` | Left floating PR pane for Work chat. Shows cached lane PR details immediately, then refreshes the linked PR row with the same targeted refresh path so pane toggles surface current merged/closed/check state without a broad PR sync. | | `ChatProposedPlanCard.tsx` | Composer-level plan approval card shown while input is locked. Renders the plan description or question text as rich markdown (`ChatMarkdown`) inside a scrollable container (capped at `min(34vh, 360px)`). Transcript plan events render through `AgentChatMessageList` / `CodexPlanCard`. | +| `apps/ios/ADE/Views/Work/WorkPlanComposerViews.swift` | iOS composer-level plan approval strip. The live `plan_approval` gate renders as a compact full-width strip above the prompt box, opens a large markdown sheet for review, and sends Approve/Reject decisions through `chat.approve` with optional rejection feedback as `responseText`. | | `ChatModelSelectionPendingCard.tsx` | Full agent-briefing model picker for orchestration pending inputs. Shows description, touched files, run-after dependencies, provider/model controls, and submitting/cancel states without a recommended default model. | | `codex/CodexPlanCard.tsx` | Codex plan card rendered inline in the transcript for `plan` events. Shows plan state (Planning / Plan ready), step progress with status glyphs, and streaming plan text as rich markdown via `ChatMarkdown`. Completed plans with no discrete steps render the full markdown body inline; plans with steps offer a toggle to expand the raw markdown details (labelled "details" when complete, "live" while streaming). Handles missing `steps` arrays gracefully. | | `codex/CodexGoalCard.tsx`, `codex/CodexGoalBanner.tsx` | Codex goal surfaces. The card is the active desktop surface and routes edits/clears through typed ADE APIs (`ade.agentChat.codex.*`) rather than prompt text. It shows objective, status, token count, and elapsed time, while hiding provider budgets because ADE keeps goals unlimited. The banner remains available for compact surfaces that need a horizontal goal strip. | @@ -581,13 +582,13 @@ meaningful content rather than a generic label. The card's data contract (`PendingInputRequest` / `PendingInputQuestion` / `PendingInputOption` in `shared/types/chat.ts`) is the single source of truth: the TUI (`apps/ade-cli/src/tuiClient/components/ApprovalPrompt.tsx`) -and iOS (`WorkStructuredQuestionCard` / `WorkPlanReviewCard`) render the +and iOS (`WorkStructuredQuestionCard` / `WorkPlanComposerStrip`) render the same header verb, dedup, monospace preview, and per-provider accent. The verb/name helpers live in `shared/pendingInputLabels.ts` so desktop and TUI share them; iOS mirrors them in Swift. A blocking pending input also surfaces an "Awaiting you" badge on the Lanes row and the Work grid tile (derived from exact pending-input counts, not idle CLI attention heuristics), -and iOS elevates the card with a light haptic on arrival. +and iOS fires a light haptic when a new blocking gate arrives. ### Per-runtime question richness (ceilings) From 6784409046f2741342ae520fa1a50688e978da27 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 04:27:11 -0400 Subject: [PATCH 2/7] Add mobile chat PR badge --- .../services/sync/syncRemoteCommandService.ts | 6 + .../sync/syncRemoteCommandService.test.ts | 14 + apps/desktop/src/shared/types/sync.ts | 1 + apps/ios/ADE.xcodeproj/project.pbxproj | 4 + apps/ios/ADE/Services/SyncService.swift | 47 ++- .../Views/Hub/HubScreen+ChatNavigation.swift | 2 +- apps/ios/ADE/Views/Hub/HubScreen.swift | 2 +- apps/ios/ADE/Views/PRs/PrHelpers.swift | 24 +- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 22 +- .../Work/WorkChatHeaderAndMessageViews.swift | 7 +- apps/ios/ADE/Views/Work/WorkChatPrViews.swift | 371 ++++++++++++++++++ .../ADE/Views/Work/WorkChatSessionView.swift | 37 +- .../WorkSessionDestinationView+Actions.swift | 203 ++++++++-- .../Work/WorkSessionDestinationView.swift | 340 ++++++++++------ apps/ios/ADETests/ADETests.swift | 50 +++ 15 files changed, 966 insertions(+), 164 deletions(-) create mode 100644 apps/ios/ADE/Views/Work/WorkChatPrViews.swift diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 1fac99574..e0d277335 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -1220,6 +1220,10 @@ function parseConflictLaneArgs(value: Record, action: string): }; } +function parseLaneIdArgs(value: Record, action: string): { laneId: string } { + return parseConflictLaneArgs(value, action); +} + function parseCursorModelSource(value: unknown): "sdk" | "cli" | "all" | null { const source = asTrimmedString(value); return source === "sdk" || source === "cli" || source === "all" ? source : null; @@ -2931,6 +2935,8 @@ function registerConflictRemoteCommands({ args, register }: RemoteCommandRegistr function registerPrAndDeeplinkRemoteCommands({ args, register }: RemoteCommandRegistrationDeps): void { register("prs.list", { viewerAllowed: true }, async () => args.prService.listAll()); + register("prs.getForLane", { viewerAllowed: true }, async (payload) => + args.prService.getForLane(parseLaneIdArgs(payload, "prs.getForLane").laneId)); register("prs.refresh", { viewerAllowed: true }, async (payload) => { const prId = asTrimmedString(payload.prId); const prIds = asStringArray(payload.prIds); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 5aba3dd50..fe3b710f8 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -110,6 +110,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "cto.getLinearIssueComments", "cto.runLinearSyncNow", "cto.saveAgent", + "prs.getForLane", "prs.createFromLane", "prs.createQueue", "prs.land", @@ -219,6 +220,7 @@ function createMockLaneService() { function createMockPrService() { return { listAll: vi.fn().mockResolvedValue([]), + getForLane: vi.fn().mockReturnValue(null), refresh: vi.fn().mockResolvedValue(undefined), listSnapshots: vi.fn().mockReturnValue([]), getDetail: vi.fn().mockResolvedValue({}), @@ -1048,6 +1050,18 @@ describe("createSyncRemoteCommandService", () => { expect(result).toEqual([]); }); + it("prs.getForLane routes to prService.getForLane", async () => { + prService.getForLane.mockReturnValue({ id: "pr-1" }); + const result = await service.execute(makePayload("prs.getForLane", { laneId: "lane-1" })); + expect(prService.getForLane).toHaveBeenCalledWith("lane-1"); + expect(result).toEqual({ id: "pr-1" }); + }); + + it("prs.getForLane requires laneId", async () => { + await expect(service.execute(makePayload("prs.getForLane", {}))) + .rejects.toThrow("prs.getForLane requires laneId."); + }); + it("prs.getDetail requires prId", async () => { await expect(service.execute(makePayload("prs.getDetail", {}))) .rejects.toThrow("prs.getDetail requires prId."); diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 63b24f000..103482e92 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -928,6 +928,7 @@ export type SyncRemoteCommandAction = | "conflicts.getBatchAssessment" | "prs.list" | "prs.refresh" + | "prs.getForLane" | "prs.getDetail" | "prs.getStatus" | "prs.getChecks" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 7074c0e82..37c8ccb94 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ E1000000000000000000002B /* WorkChatSessionView+Actions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002B /* WorkChatSessionView+Actions.swift */; }; E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */; }; E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1000000000000000000002D /* WorkChatRichCardViews.swift */; }; + E10000000000000000000055 /* WorkChatPrViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000055 /* WorkChatPrViews.swift */; }; E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000054 /* WorkPlanComposerViews.swift */; }; E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000050 /* WorkChatAttachmentTray.swift */; }; E10000000000000000000052 /* LaneDeeplinkHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000052 /* LaneDeeplinkHelpers.swift */; }; @@ -243,6 +244,7 @@ D1000000000000000000002B /* WorkChatSessionView+Actions.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = "WorkChatSessionView+Actions.swift"; path = "ADE/Views/Work/WorkChatSessionView+Actions.swift"; sourceTree = ""; }; D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatHeaderAndMessageViews.swift; path = ADE/Views/Work/WorkChatHeaderAndMessageViews.swift; sourceTree = ""; }; D1000000000000000000002D /* WorkChatRichCardViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatRichCardViews.swift; path = ADE/Views/Work/WorkChatRichCardViews.swift; sourceTree = ""; }; + D10000000000000000000055 /* WorkChatPrViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatPrViews.swift; path = ADE/Views/Work/WorkChatPrViews.swift; sourceTree = ""; }; D10000000000000000000054 /* WorkPlanComposerViews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkPlanComposerViews.swift; path = ADE/Views/Work/WorkPlanComposerViews.swift; sourceTree = ""; }; D10000000000000000000050 /* WorkChatAttachmentTray.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = WorkChatAttachmentTray.swift; path = ADE/Views/Work/WorkChatAttachmentTray.swift; sourceTree = ""; }; D10000000000000000000052 /* LaneDeeplinkHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDeeplinkHelpers.swift; path = ADE/Views/Lanes/LaneDeeplinkHelpers.swift; sourceTree = ""; }; @@ -636,6 +638,7 @@ D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + D10000000000000000000055 /* WorkChatPrViews.swift */, D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, @@ -1115,6 +1118,7 @@ E1000000000000000000003C /* WorkSessionGrouping.swift in Sources */, E1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift in Sources */, E1000000000000000000002D /* WorkChatRichCardViews.swift in Sources */, + E10000000000000000000055 /* WorkChatPrViews.swift in Sources */, E10000000000000000000054 /* WorkPlanComposerViews.swift in Sources */, E10000000000000000000050 /* WorkChatAttachmentTray.swift in Sources */, E10000000000000000000047 /* ADEInspectable.swift in Sources */, diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index b627ae422..2458f5895 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -1025,24 +1025,45 @@ struct WorkSessionNavigationRequest: Equatable, Identifiable { } } +enum PrNavigationRequestTarget: Equatable { + case detail(prId: String, prNumber: Int?, laneId: String?) + case githubNumber(Int) + case create(laneId: String) +} + struct PrNavigationRequest: Equatable, Identifiable { let id: String - let prId: String - let prNumber: Int? - let laneId: String? + let target: PrNavigationRequestTarget init(prId: String, prNumber: Int? = nil, laneId: String? = nil) { self.id = UUID().uuidString - self.prId = prId - self.prNumber = prNumber - self.laneId = laneId + self.target = .detail(prId: prId, prNumber: prNumber, laneId: laneId) } init(prNumber: Int) { self.id = UUID().uuidString - self.prId = "github-pr-number:\(prNumber)" - self.prNumber = prNumber - self.laneId = nil + self.target = .githubNumber(prNumber) + } + + init(createLaneId: String) { + self.id = UUID().uuidString + self.target = .create(laneId: createLaneId) + } + + var laneId: String? { + switch target { + case .detail(_, _, let laneId): + return laneId + case .githubNumber: + return nil + case .create(let laneId): + return laneId + } + } + + var createLaneId: String? { + guard case .create(let laneId) = target else { return nil } + return laneId } } @@ -4084,6 +4105,14 @@ final class SyncService: ObservableObject { database.fetchPullRequestListItems(forLane: laneId) } + func fetchPullRequestForLane(laneId: String) async throws -> PrSummary? { + try await sendDecodableCommand( + action: "prs.getForLane", + args: ["laneId": laneId], + as: PrSummary?.self + ) + } + func fetchPullRequestGroupMembers(groupId: String) async throws -> [PrGroupMemberSummary] { database.fetchPullRequestGroupMembers(groupId: groupId) } diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index 9856e435f..59f8a0837 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -47,7 +47,7 @@ private struct HubChatCover: View { navigationChrome: .pushedDetail, lanes: target.lane.map { [$0.asLaneSummary()] } ?? [] ) - .equatable() + .id(target.id) } else { HubChatActivatingView(projectName: target.project.displayName, onClose: onClose) } diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index f21dd8aa3..f1ad66d49 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -445,10 +445,10 @@ struct HubScreen: View { /// Identifies a chat opened from the hub, carrying enough context to activate /// the right project and render the chat over the hub. struct HubChatTarget: Identifiable, Equatable { + let id = UUID().uuidString let project: MobileProjectSummary let lane: RemoteRosterLane? let chat: RemoteRosterChat - var id: String { chat.id } } // MARK: - Persisted layout diff --git a/apps/ios/ADE/Views/PRs/PrHelpers.swift b/apps/ios/ADE/Views/PRs/PrHelpers.swift index 0c83cd974..0bad73d12 100644 --- a/apps/ios/ADE/Views/PRs/PrHelpers.swift +++ b/apps/ios/ADE/Views/PRs/PrHelpers.swift @@ -490,15 +490,29 @@ func prNavigationTarget( pullRequests: [PullRequestListItem], githubItems: [GitHubPrListItem] ) -> PrNavigationTarget { - let prId = request.prId.trimmingCharacters(in: .whitespacesAndNewlines) + let prId: String + let requestLaneId: String? + let explicitPrNumber: Int? + switch request.target { + case .detail(let rawPrId, let prNumber, let laneId): + prId = rawPrId.trimmingCharacters(in: .whitespacesAndNewlines) + requestLaneId = laneId + explicitPrNumber = prNumber + case .githubNumber(let prNumber): + prId = "github-pr-number:\(prNumber)" + requestLaneId = nil + explicitPrNumber = prNumber + case .create: + return .unresolved + } guard !prId.isEmpty else { return .unresolved } guard prId.hasPrefix("github-pr-number:") else { let match = pullRequests.first { $0.id == prId } - return .detail(prId: prId, laneId: request.laneId ?? match?.laneId) + return .detail(prId: prId, laneId: requestLaneId ?? match?.laneId) } - let requestedPrNumber = request.prNumber ?? syntheticPrNumber(from: prId) + let requestedPrNumber = explicitPrNumber ?? syntheticPrNumber(from: prId) guard let requestedPrNumber else { return .unresolved } let githubItem = githubItems.first { $0.githubPrNumber == requestedPrNumber } @@ -508,12 +522,12 @@ func prNavigationTarget( requestedPrNumber: requestedPrNumber, githubItem: githubItem ) { - return .detail(prId: match.id, laneId: request.laneId ?? match.laneId) + return .detail(prId: match.id, laneId: requestLaneId ?? match.laneId) } if let linkedPrId = githubItem?.linkedPrId?.trimmingCharacters(in: .whitespacesAndNewlines), !linkedPrId.isEmpty { - return .detail(prId: linkedPrId, laneId: request.laneId ?? githubItem?.linkedLaneId) + return .detail(prId: linkedPrId, laneId: requestLaneId ?? githubItem?.linkedLaneId) } if let githubItem { diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index ff0d12e75..ddc434c77 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -25,6 +25,7 @@ struct PRsTabView: View { @State private var errorMessage: String? @State private var actionMessage: String? @State private var createPresented = false + @State private var createInitialLaneId: String? @State private var stackPresentation: PrStackPresentation? @State private var refreshFeedbackToken = 0 @State private var lastPrsLocalProjectionReload = Date.distantPast @@ -479,7 +480,9 @@ struct PRsTabView: View { ) .environmentObject(syncService) } - .sheet(isPresented: $createPresented) { + .sheet(isPresented: $createPresented, onDismiss: { + createInitialLaneId = nil + }) { createPrWizardSheet } .sheet(item: $stackPresentation) { presentation in @@ -633,6 +636,7 @@ struct PRsTabView: View { .disabled(prsStatus.phase == .hydrating) Button { + createInitialLaneId = nil createPresented = true } label: { ZStack { @@ -1338,6 +1342,19 @@ struct PRsTabView: View { @MainActor private func handleRequestedPrNavigation() async { guard let request = syncService.requestedPrNavigation else { return } + if let createLaneId = request.createLaneId?.trimmingCharacters(in: .whitespacesAndNewlines), + !createLaneId.isEmpty { + await reload(refreshRemote: false) + rootSurfaceRawValue = PrRootSurface.github.rawValue + path = NavigationPath() + selectedPrTransitionId = nil + laneContextLaneId = createLaneId + createInitialLaneId = createLaneId + createPresented = true + syncService.requestedPrNavigation = nil + return + } + var target = prNavigationTarget( for: request, pullRequests: prs, @@ -1382,6 +1399,8 @@ struct PRsTabView: View { CreatePrWizardView( lanes: lanes, createCapabilities: mobileSnapshot?.createCapabilities, + initialLaneId: createInitialLaneId, + singleModeOnly: createInitialLaneId != nil, onCreateSingle: handleCreateSinglePr, onCreateQueue: handleCreateQueuePrs, onCreateIntegration: handleCreateIntegrationPr @@ -1418,6 +1437,7 @@ struct PRsTabView: View { ) }, onSuccess: { + createInitialLaneId = nil createPresented = false } ) diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 82ea248b6..4ad5976d3 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -422,7 +422,7 @@ let workAssistantMessageMaxLineBudget = 192 let workAssistantMessageInitialCharacterBudget = 1_600 let workAssistantMessageCharacterBudgetStep = 2_400 let workAssistantMessageSmallFullCharacterBudget = 6_000 -let workAssistantMessageTailFullLineBudget = 96 +let workAssistantMessageTailFullLineBudget = 128 let workAssistantMessageTailFullCharacterBudget = 12_000 let workAssistantMessageWideInitialLineBudget = 24 let workAssistantMessageWideMaxLineBudget = 96 @@ -491,6 +491,11 @@ func workAssistantMessageCharacterBudget(forLineBudget lineBudget: Int) -> Int { return workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) } +func workAssistantMessageCharacterBudget(forLineBudget lineBudget: Int, tailCanRenderFull: Bool) -> Int { + let steppedBudget = workAssistantMessageCharacterBudget(forLineBudget: lineBudget) + return tailCanRenderFull ? max(steppedBudget, workAssistantMessageTailFullCharacterBudget) : steppedBudget +} + func workAssistantMessagePreview( _ markdown: String, lineBudget: Int, diff --git a/apps/ios/ADE/Views/Work/WorkChatPrViews.swift b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift new file mode 100644 index 000000000..e396ee660 --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift @@ -0,0 +1,371 @@ +import SwiftUI + +struct WorkChatPrBadgeModel: Equatable { + let label: String + let title: String + let state: String + let checksStatus: String? + let reviewStatus: String? + let updatedAt: String +} + +func workChatPrBadgeModel(tag: LanePrTag?, pr: PullRequestListItem?, summary: PrSummary? = nil) -> WorkChatPrBadgeModel? { + guard let tag else { return nil } + return WorkChatPrBadgeModel( + label: formatLanePrBadgeLabel(tag), + title: tag.title, + state: tag.state, + checksStatus: pr?.checksStatus ?? summary?.checksStatus, + reviewStatus: pr?.reviewStatus ?? summary?.reviewStatus, + updatedAt: tag.updatedAt + ) +} + +struct WorkChatPrActivePopup: View { + let badge: WorkChatPrBadgeModel + let onOpen: () -> Void + + private var tint: Color { + lanePullRequestTint(badge.state) + } + + private var ciSymbol: String? { + switch badge.checksStatus { + case "passing": + return "checkmark.circle.fill" + case "failing": + return "xmark.circle.fill" + case "pending": + return "clock.fill" + default: + return nil + } + } + + private var accessibilityText: String { + var parts = [badge.label, lanePrStateLabel(badge.state)] + if let checksStatus = badge.checksStatus, !checksStatus.isEmpty { + parts.append("checks \(checksStatus)") + } + if let reviewStatus = badge.reviewStatus, !reviewStatus.isEmpty, reviewStatus != "none" { + parts.append("review \(reviewStatus.replacingOccurrences(of: "_", with: " "))") + } + return parts.joined(separator: ", ") + ". Tap for details." + } + + var body: some View { + Button(action: onOpen) { + HStack(spacing: 7) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 12, weight: .semibold)) + Text(badge.label) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if let ciSymbol { + Image(systemName: ciSymbol) + .font(.system(size: 10, weight: .bold)) + } + Image(systemName: "chevron.up") + .font(.system(size: 10, weight: .bold)) + } + .foregroundStyle(tint) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(ADEColor.cardBackground.opacity(0.76), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(0.24), lineWidth: 1) + ) + .contentShape(Capsule(style: .continuous)) + } + .buttonStyle(.plain) + .accessibilityLabel(accessibilityText) + } +} + +struct WorkChatPrDetailsSheet: View { + let tag: LanePrTag? + let pr: PullRequestListItem? + let summary: PrSummary? + let snapshot: PullRequestSnapshot? + let canCreate: Bool + let createBlockedReason: String? + let isRefreshing: Bool + let errorMessage: String? + let copiedLink: Bool + let onRefresh: () -> Void + let onCreate: () -> Void + let onOpenPrsTab: () -> Void + let onOpenGitHub: () -> Void + let onCopyLink: () -> Void + + private var prNumberLabel: String { + guard let tag else { return "Pull request" } + return formatLanePrBadgeLabel(tag) + } + + private var githubUrl: String { + (tag?.githubUrl ?? pr?.githubUrl ?? summary?.githubUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var checksStatus: String? { + snapshot?.status?.checksStatus ?? pr?.checksStatus ?? summary?.checksStatus + } + + private var reviewStatus: String? { + snapshot?.status?.reviewStatus ?? pr?.reviewStatus ?? summary?.reviewStatus + } + + private var additions: Int { + pr?.additions ?? summary?.additions ?? 0 + } + + private var deletions: Int { + pr?.deletions ?? summary?.deletions ?? 0 + } + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let tag { + existingPrContent(tag) + } else { + emptyPrContent + } + } + .padding(.horizontal, 20) + .padding(.vertical, 22) + } + .background(ADEColor.pageBackground.ignoresSafeArea()) + .navigationTitle(prNumberLabel) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button(action: onRefresh) { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + } + } + .disabled(isRefreshing) + .accessibilityLabel("Refresh pull request details") + } + } + } + } + + private func existingPrContent(_ tag: LanePrTag) -> some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 10) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 16, weight: .bold)) + .foregroundStyle(lanePullRequestTint(tag.state)) + .frame(width: 34, height: 34) + .background(lanePullRequestTint(tag.state).opacity(0.12), in: Circle()) + + VStack(alignment: .leading, spacing: 3) { + Text(formatLanePrBadgeLabel(tag)) + .font(.caption.weight(.semibold)) + .foregroundStyle(lanePullRequestTint(tag.state)) + Text(lanePrStateLabel(tag.state)) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + Spacer(minLength: 0) + } + + Text(tag.title) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: false, vertical: true) + + Text("Updated \(prRelativeTime(tag.updatedAt))") + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + } + + VStack(alignment: .leading, spacing: 10) { + if let branchLine = workChatPrBranchLine(pr: pr, summary: summary, tag: tag) { + WorkChatPrDetailRow(label: "Branch", value: branchLine, symbol: "arrow.triangle.branch") + if pr != nil || summary != nil { + WorkChatPrDetailRow(label: "Changes", value: "+\(additions) / -\(deletions)", symbol: "plus.forwardslash.minus") + } + } else if !tag.headBranch.isEmpty { + WorkChatPrDetailRow(label: "Branch", value: tag.headBranch, symbol: "arrow.triangle.branch") + } + if let checksStatus, !checksStatus.isEmpty { + WorkChatPrDetailRow(label: "Checks", value: workChatPrStatusLabel(checksStatus), symbol: workChatPrChecksSymbol(checksStatus)) + } + if let reviewStatus, !reviewStatus.isEmpty, reviewStatus != "none" { + WorkChatPrDetailRow(label: "Review", value: workChatPrStatusLabel(reviewStatus), symbol: "person.crop.circle.badge.checkmark") + } + if let mergeLine = workChatPrMergeLine(snapshot?.status) { + WorkChatPrDetailRow(label: "Merge", value: mergeLine, symbol: "arrow.merge") + } + } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + } + + VStack(spacing: 10) { + Button(action: onOpenPrsTab) { + Label("PRs tab", systemImage: "rectangle.grid.1x2") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + + HStack(spacing: 10) { + Button(action: onOpenGitHub) { + Label("GitHub", systemImage: "link") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(githubUrl.isEmpty) + + Button(action: onCopyLink) { + Label(copiedLink ? "Copied" : "Copy", systemImage: copiedLink ? "checkmark" : "doc.on.doc") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + .disabled(githubUrl.isEmpty) + } + } + } + } + + private var emptyPrContent: some View { + VStack(alignment: .leading, spacing: 18) { + VStack(alignment: .leading, spacing: 10) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 42, height: 42) + .background(ADEColor.accent.opacity(0.12), in: Circle()) + + Text("No pull request yet") + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + + Text("Create one from this lane or open the PRs tab with the lane preselected.") + .font(.subheadline) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + if let createBlockedReason, !createBlockedReason.isEmpty { + Text(createBlockedReason) + .font(.footnote) + .foregroundStyle(ADEColor.warning) + } + + VStack(spacing: 10) { + Button(action: onCreate) { + Label("Create pull request", systemImage: "plus") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(!canCreate) + + Button(action: onOpenPrsTab) { + Label("Open PR in PRs tab", systemImage: "rectangle.grid.1x2") + .frame(maxWidth: .infinity) + } + .buttonStyle(.bordered) + } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + } + } + } +} + +private struct WorkChatPrDetailRow: View { + let label: String + let value: String + let symbol: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + Text(value) + .font(.subheadline) + .foregroundStyle(ADEColor.textPrimary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private func workChatPrStatusLabel(_ status: String) -> String { + status + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + word.prefix(1).uppercased() + String(word.dropFirst()) + } + .joined(separator: " ") +} + +private func workChatPrBranchLine(pr: PullRequestListItem?, summary: PrSummary?, tag: LanePrTag) -> String? { + if let pr { + return "\(pr.headBranch) -> \(pr.baseBranch)" + } + if let summary { + return "\(summary.headBranch) -> \(summary.baseBranch)" + } + let head = tag.headBranch.trimmingCharacters(in: .whitespacesAndNewlines) + return head.isEmpty ? nil : head +} + +private func workChatPrChecksSymbol(_ status: String) -> String { + switch status { + case "passing": + return "checkmark.circle.fill" + case "failing": + return "xmark.circle.fill" + case "pending": + return "clock.fill" + default: + return "circle" + } +} + +private func workChatPrMergeLine(_ status: PrStatus?) -> String? { + guard let status else { return nil } + if status.mergeConflicts { + return "Merge conflicts" + } + if status.behindBaseBy > 0 { + return "\(status.behindBaseBy) behind base" + } + if status.mergeStateStatus == .draft { + return "Draft" + } + if status.mergeabilityComputing == true { + return "Computing" + } + if status.isMergeable { + return "Mergeable" + } + return "Blocked" +} diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 6e0c46af9..1c4af107f 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -199,6 +199,8 @@ struct WorkChatSessionView: View { var subagentSnapshotsRenderSignature: Int = 0 var selectedSubagentTaskId: String? = nil var onOpenSubagents: (() -> Void)? = nil + var prBadge: WorkChatPrBadgeModel? = nil + var onOpenPrDetails: (() -> Void)? = nil /// Live "turn is running" signal from the sync layer (chat_subscribe ack + /// live status/done events). Covers the gap where the synced session row /// still says idle while chat events are already streaming — without it @@ -411,10 +413,10 @@ struct WorkChatSessionView: View { } var latestScrollTargetId: String { - if isStreamingTurn { - return "chat-streaming-status" - } - return visibleTimelineRenderEntries.last?.id ?? "chat-end" + // The bottom sentinel is the same view used for scroll metrics. Pinning to a + // markdown block or streaming row can leave the sentinel below the viewport, + // which looks like a blank tail and keeps the Latest pill stale. + "chat-end" } var hiddenTimelineCount: Int { @@ -625,12 +627,20 @@ struct WorkChatSessionView: View { // lifecycle controls live outside the composer; this space is reserved // for pending input and send feedback. let runningSubagentCount = workSubagentRunningCount(subagentSnapshots) - if inputLockMessage == nil, - runningSubagentCount > 0, - let onOpenSubagents { - WorkSubagentActivePopup(count: runningSubagentCount, onOpen: onOpenSubagents) - .frame(maxWidth: .infinity, alignment: .leading) - .frame(height: workChatSubagentActivePopupHeight, alignment: .leading) + let showsSubagentBadge = inputLockMessage == nil && runningSubagentCount > 0 && onOpenSubagents != nil + let showsPrBadge = inputLockMessage == nil && prBadge != nil && onOpenPrDetails != nil + if showsSubagentBadge || showsPrBadge { + HStack(spacing: 8) { + if showsSubagentBadge, let onOpenSubagents { + WorkSubagentActivePopup(count: runningSubagentCount, onOpen: onOpenSubagents) + } + if showsPrBadge, let prBadge, let onOpenPrDetails { + WorkChatPrActivePopup(badge: prBadge, onOpen: onOpenPrDetails) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .frame(height: workChatSubagentActivePopupHeight, alignment: .leading) } if !pendingSteers.isEmpty { @@ -799,7 +809,6 @@ struct WorkChatSessionView: View { .clipped() .scrollIndicators(.hidden) .scrollDismissesKeyboard(.interactively) - .defaultScrollAnchor(.bottom) .coordinateSpace(name: workChatScrollCoordinateSpace) .background( GeometryReader { geometry in @@ -1422,13 +1431,17 @@ private func workTimelineEntriesWithAssistantPreviews( && message.markdown.count <= workAssistantMessageTailFullCharacterBudget let lineBudget = assistantLineBudgets[message.id] ?? (tailCanRenderFull ? workAssistantMessageTailFullLineBudget : workAssistantMessageInitialLineBudget) + let characterBudget = workAssistantMessageCharacterBudget( + forLineBudget: lineBudget, + tailCanRenderFull: tailCanRenderFull && assistantLineBudgets[message.id] == nil + ) if lineBudget == workAssistantMessageInitialLineBudget { message.assistantPreview = cache.preview(for: message, anchor: previewAnchor) } else { message.assistantPreview = workAssistantMessagePreview( message.markdown, lineBudget: lineBudget, - characterBudget: workAssistantMessageCharacterBudget(forLineBudget: lineBudget), + characterBudget: characterBudget, anchor: previewAnchor ) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 901cdd0a3..2557085f3 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -604,47 +604,125 @@ extension WorkSessionDestinationView { /// task identity (cancellation + still-current lane) after the await so a /// slow lookup for a previous lane can never surface its PR on a new lane. @MainActor - func resolveLaneOpenPr(for laneId: String) async { - laneOpenPr = nil + func resolveLaneOpenPr( + for laneId: String, + forceGithubRefresh: Bool = false, + clearBeforeLoad: Bool = true + ) async { + if clearBeforeLoad { + laneOpenPr = nil + lanePrSummary = nil + lanePrTag = nil + } let trimmed = laneId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return } + guard !trimmed.isEmpty else { + laneOpenPr = nil + lanePrSummary = nil + lanePrTag = nil + return + } - let resolved: PullRequestListItem? - do { - let items = try await syncService.fetchPullRequestListItems(laneId: trimmed) - // Prefer an open PR over a closed/merged one when a lane has several. - resolved = items.first { $0.state.lowercased() == "open" } ?? items.first - } catch { - resolved = nil + let items = (try? await syncService.fetchPullRequestListItems(laneId: trimmed)) ?? [] + let remoteSummary: PrSummary? + if hostReachable && syncService.supportsRemoteAction("prs.getForLane") { + remoteSummary = try? await syncService.fetchPullRequestForLane(laneId: trimmed) + } else { + remoteSummary = nil } + if hostReachable { + await syncService.refreshLaneGithubPrItems(force: forceGithubRefresh) + } + + let resolution = workChatResolveLanePr( + lane: lanes.first(where: { $0.id == trimmed }), + pullRequests: items, + remoteSummary: remoteSummary, + githubPrs: syncService.laneGithubPrItems + ) + let stillCurrent = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed guard !Task.isCancelled, stillCurrent else { return } - laneOpenPr = resolved + lanePrSummary = resolution.summary + lanePrTag = resolution.tag + laneOpenPr = resolution.mappedPr } /// Navigate to the resolved lane PR. No-op (rather than crash) if the PR was /// cleared between menu render and tap. func openLaneOpenPr() { - guard let pr = laneOpenPr else { return } - syncService.requestedPrNavigation = PrNavigationRequest( - prId: pr.id, - prNumber: pr.githubPrNumber, - laneId: pr.laneId - ) + guard let tag = lanePrTag else { return } + prDetailsPresented = false + if let prId = tag.prId ?? laneOpenPr?.id, !prId.isEmpty { + let laneId = (laneOpenPr?.laneId ?? headerMenuLaneId).trimmingCharacters(in: .whitespacesAndNewlines) + syncService.requestedPrNavigation = PrNavigationRequest( + prId: prId, + prNumber: tag.githubPrNumber, + laneId: laneId.isEmpty ? nil : laneId + ) + } else { + syncService.requestedPrNavigation = PrNavigationRequest(prNumber: tag.githubPrNumber) + } } func openLanePrOnGitHub() { - guard let urlString = laneOpenPr?.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines), - !urlString.isEmpty, - let url = URL(string: urlString) else { return } + guard !lanePrGitHubUrlString.isEmpty, + let url = URL(string: lanePrGitHubUrlString) else { return } UIApplication.shared.open(url) } + @MainActor + func openPrCreationInPrsTab() { + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !laneId.isEmpty else { return } + prDetailsPresented = false + createPrPresented = false + syncService.requestedPrNavigation = PrNavigationRequest(createLaneId: laneId) + } + + @MainActor + func presentChatPrDetails() { + prDetailsPresented = true + Task { await refreshChatPrDetails(force: true) } + } + + @MainActor + func refreshChatPrDetails(force: Bool = false) async { + guard force || !prDetailsRefreshing else { return } + prDetailsRefreshing = true + prDetailsError = nil + defer { prDetailsRefreshing = false } + + await resolveLaneOpenPr(for: headerMenuLaneId, forceGithubRefresh: force, clearBeforeLoad: false) + + guard let prId = laneOpenPr?.id ?? lanePrSummary?.id else { + prDetailsSnapshot = nil + await loadPrCreateCapabilitiesIfNeeded() + return + } + + if hostReachable { + do { + try await syncService.refreshPullRequestSnapshots(prId: prId) + let items = (try? await syncService.fetchPullRequestListItems(laneId: headerMenuLaneId)) ?? [] + laneOpenPr = workChatMappedPullRequest(for: lanePrTag, in: items) + } catch { + prDetailsError = SyncUserFacingError.message(for: error) + } + } + + do { + prDetailsSnapshot = try await syncService.fetchPullRequestSnapshot(prId: prId) + } catch { + prDetailsSnapshot = nil + prDetailsError = SyncUserFacingError.message(for: error) + } + } + @MainActor func copyLanePrLink() { - guard let urlString = laneOpenPr?.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines), - !urlString.isEmpty else { return } + let urlString = lanePrGitHubUrlString + guard !urlString.isEmpty else { return } UIPasteboard.general.string = urlString prLinkCopied = true Task { @@ -662,6 +740,11 @@ extension WorkSessionDestinationView { } func presentCreateLanePr() { + if prDetailsPresented { + createPrAfterDetailsDismiss = true + prDetailsPresented = false + return + } createPrPresented = true } @@ -708,7 +791,12 @@ extension WorkSessionDestinationView { strategy: strategy ) createPrPresented = false - await resolveLaneOpenPr(for: headerMenuLaneId) + try? await syncService.refreshPullRequestSnapshots() + await syncService.refreshLaneGithubPrItems(force: true) + await resolveLaneOpenPr(for: headerMenuLaneId, forceGithubRefresh: true) + if prDetailsPresented { + await refreshChatPrDetails(force: false) + } await loadPrCreateCapabilitiesIfNeeded() return true } catch { @@ -717,3 +805,72 @@ extension WorkSessionDestinationView { } } } + +struct WorkChatPrResolution { + var tag: LanePrTag? + var mappedPr: PullRequestListItem? + var summary: PrSummary? +} + +func workChatResolveLanePr( + lane: LaneSummary?, + pullRequests: [PullRequestListItem], + remoteSummary: PrSummary?, + githubPrs: [GitHubPrListItem] +) -> WorkChatPrResolution { + let tag: LanePrTag? + if let remoteSummary { + tag = workChatLanePrTag(from: remoteSummary) + } else if let lane { + tag = selectLaneTabPrTag(lane: lane, pullRequests: pullRequests, githubPrs: githubPrs) + } else if let pr = pullRequests.sorted(by: lanePrTagPrecedes).first { + tag = workChatLanePrTag(from: pr) + } else { + tag = nil + } + return WorkChatPrResolution( + tag: tag, + mappedPr: workChatMappedPullRequest(for: tag, in: pullRequests), + summary: remoteSummary + ) +} + +func workChatLanePrTag(from pr: PullRequestListItem) -> LanePrTag { + LanePrTag( + source: .ade, + prId: pr.id, + githubPrNumber: pr.githubPrNumber, + githubUrl: pr.githubUrl, + title: pr.title, + state: pr.state, + headBranch: pr.headBranch, + updatedAt: pr.updatedAt + ) +} + +func workChatLanePrTag(from pr: PrSummary) -> LanePrTag { + LanePrTag( + source: .ade, + prId: pr.id, + githubPrNumber: pr.githubPrNumber, + githubUrl: pr.githubUrl, + title: pr.title, + state: pr.state, + headBranch: pr.headBranch, + updatedAt: pr.updatedAt + ) +} + +func workChatMappedPullRequest( + for tag: LanePrTag?, + in pullRequests: [PullRequestListItem] +) -> PullRequestListItem? { + guard let tag else { return nil } + if let prId = tag.prId, + let match = pullRequests.first(where: { $0.id == prId }) { + return match + } + return pullRequests.first { pr in + pr.githubPrNumber == tag.githubPrNumber || pr.githubUrl == tag.githubUrl + } +} diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index eb606e29f..67b2f8def 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -331,8 +331,15 @@ struct WorkSessionDestinationView: View { /// item. Nil until resolved (or when the lane has no cached PR), which keeps /// that menu item disabled with a "No PR yet" hint. @State var laneOpenPr: PullRequestListItem? + @State var lanePrSummary: PrSummary? + @State var lanePrTag: LanePrTag? @State var prCreateCapabilities: PrCreateCapabilities? @State var createPrPresented = false + @State var createPrAfterDetailsDismiss = false + @State var prDetailsPresented = false + @State var prDetailsSnapshot: PullRequestSnapshot? + @State var prDetailsRefreshing = false + @State var prDetailsError: String? @State var prLinkCopied = false @State var sessionActionRenamePresented = false @State var sessionActionRenameText = "" @@ -490,6 +497,14 @@ struct WorkSessionDestinationView: View { return resolvedWorkNavigationLaneId(for: session, lanes: lanes) } + var headerMenuPrLookupKey: String { + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + let githubKey = syncService.laneGithubPrItems + .map { "\($0.id):\($0.state):\($0.isDraft):\($0.updatedAt):\($0.linkedPrId ?? "")" } + .joined(separator: "|") + return "\(laneId)|\(syncService.prsProjectionRevision)|\(githubKey)" + } + /// Trailing nav-bar overflow menu for chat sessions: proof drawer plus lane /// shortcuts when the session is lane-backed. @ViewBuilder @@ -569,44 +584,55 @@ struct WorkSessionDestinationView: View { @ViewBuilder private var chatPullRequestMenuItems: some View { - if let laneOpenPr { + Menu { Button { - openLaneOpenPr() + presentChatPrDetails() } label: { - Label("Open in ADE (#\(laneOpenPr.githubPrNumber))", systemImage: "arrow.triangle.pull") + Label("View PR details", systemImage: "sidebar.trailing") } - Button { - openLanePrOnGitHub() - } label: { - Label("Open on GitHub", systemImage: "link") - } - .disabled(laneOpenPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + if let tag = lanePrTag { + Button { + openLaneOpenPr() + } label: { + Label("PRs tab", systemImage: "rectangle.grid.1x2") + } + .accessibilityHint("Opens \(formatLanePrBadgeLabel(tag)) in the PRs tab") - Button { - copyLanePrLink() - } label: { - if prLinkCopied { - Label("Copied link", systemImage: "checkmark") - } else { - Label("Copy link", systemImage: "doc.on.doc") + Button { + openLanePrOnGitHub() + } label: { + Label("Open on GitHub", systemImage: "link") } - } - .disabled(laneOpenPr.githubUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } else { - Button { - presentCreateLanePr() - } label: { - Label("Create pull request", systemImage: "plus") - } - .disabled(!canCreatePullRequestForHeaderLane) + .disabled(lanePrGitHubUrlString.isEmpty) - if let blockedReason = createPullRequestBlockedReason { - Button {} label: { - Label(blockedReason, systemImage: "info.circle") + Button { + copyLanePrLink() + } label: { + if prLinkCopied { + Label("Copied link", systemImage: "checkmark") + } else { + Label("Copy link", systemImage: "doc.on.doc") + } + } + .disabled(lanePrGitHubUrlString.isEmpty) + } else { + Button { + openPrCreationInPrsTab() + } label: { + Label("Open PR in PRs tab", systemImage: "rectangle.grid.1x2") + } + .disabled(headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + if let blockedReason = createPullRequestBlockedReason { + Button {} label: { + Label(blockedReason, systemImage: "info.circle") + } + .disabled(true) } - .disabled(true) } + } label: { + Label("Pull request", systemImage: "arrow.triangle.pull") } Button { @@ -616,6 +642,11 @@ struct WorkSessionDestinationView: View { } } + var lanePrGitHubUrlString: String { + (lanePrTag?.githubUrl ?? laneOpenPr?.githubUrl ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + @ViewBuilder private func chatSessionDesktopMenuItems(_ session: TerminalSessionSummary) -> some View { Button { @@ -743,6 +774,42 @@ struct WorkSessionDestinationView: View { .sheet(isPresented: $createPrPresented) { chatCreatePrWizardSheet } + .sheet(isPresented: $prDetailsPresented, onDismiss: { + if createPrAfterDetailsDismiss { + createPrAfterDetailsDismiss = false + createPrPresented = true + } + }) { + WorkChatPrDetailsSheet( + tag: lanePrTag, + pr: laneOpenPr, + summary: lanePrSummary, + snapshot: prDetailsSnapshot, + canCreate: canCreatePullRequestForHeaderLane, + createBlockedReason: createPullRequestBlockedReason, + isRefreshing: prDetailsRefreshing, + errorMessage: prDetailsError, + copiedLink: prLinkCopied, + onRefresh: { + Task { await refreshChatPrDetails(force: true) } + }, + onCreate: { + presentCreateLanePr() + }, + onOpenPrsTab: { + if lanePrTag == nil { + openPrCreationInPrsTab() + } else { + openLaneOpenPr() + } + }, + onOpenGitHub: openLanePrOnGitHub, + onCopyLink: copyLanePrLink + ) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + .presentationContentInteraction(.scrolls) + } .alert("Rename session", isPresented: $sessionActionRenamePresented) { TextField("Title", text: $sessionActionRenameText) Button("Cancel", role: .cancel) { @@ -801,7 +868,7 @@ struct WorkSessionDestinationView: View { .task(id: session?.laneId ?? initialSession?.laneId ?? "") { await syncLanePresence() } - .task(id: headerMenuLaneId) { + .task(id: headerMenuPrLookupKey) { await resolveLaneOpenPr(for: headerMenuLaneId) await loadPrCreateCapabilitiesIfNeeded() } @@ -834,85 +901,7 @@ struct WorkSessionDestinationView: View { var sessionDestinationRoot: some View { if let session { if isChatSession(session) { - let viewingSubagent = subagentView != nil - let transcriptForView = viewingSubagent ? subagentTranscript : transcript - let fallbackEntriesForView: [AgentChatTranscriptEntry] = viewingSubagent ? [] : fallbackEntries - let sessionStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) - let shouldSteer = hostReachable && sessionStatus == "active" - WorkChatSessionView( - session: WorkChatSessionRenderContext(session), - chatSummaryContext: WorkChatSummaryRenderContext(chatSummary), - transcript: transcriptForView, - transcriptRenderSignature: viewingSubagent ? subagentTranscriptRenderSignature : transcriptRenderSignature, - fallbackEntries: fallbackEntriesForView, - fallbackEntriesRenderSignature: viewingSubagent ? 0 : fallbackEntriesRenderSignature, - artifacts: viewingSubagent ? [] : artifacts, - artifactsRenderSignature: viewingSubagent ? 0 : artifactsRenderSignature, - optimisticPendingSteers: viewingSubagent ? [] : optimisticPendingSteers, - optimisticPendingSteersRenderSignature: viewingSubagent ? 0 : workPendingSteersRenderSignature(optimisticPendingSteers), - localEchoMessages: viewingSubagent ? [] : localEchoMessages, - localEchoMessagesRenderSignature: viewingSubagent ? 0 : workLocalEchoMessagesRenderSignature(localEchoMessages), - expandedToolCardIdsSnapshot: expandedToolCardIds, - expandedToolCardIdsRenderSignature: workExpandedToolCardIdsRenderSignature(expandedToolCardIds), - artifactContentRenderSignature: artifactContentRenderSignature, - artifactDrawerPresentedSnapshot: artifactDrawerPresented, - sendingSnapshot: sending, - errorMessageSnapshot: errorMessage, - expandedToolCardIds: $expandedToolCardIds, - artifactContent: $artifactContent, - fullscreenImage: $fullscreenImage, - artifactDrawerPresented: $artifactDrawerPresented, - artifactRefreshInFlight: artifactRefreshInFlight, - artifactRefreshError: artifactRefreshError, - sending: $sending, - errorMessage: $errorMessage, - isLive: isLiveAndReachable, - hostUnreachable: syncService.connectionState.isHostUnreachable, - canComposeMessages: canComposeChatMessages && !viewingSubagent, - canSendMessages: canSendChatMessages && !viewingSubagent, - sendWillQueue: sendWillQueueChatMessage || shouldSteer, - sendWillQueueIsReconnect: sendWillQueueChatMessage, - inputLockMessage: viewingSubagent ? "Viewing subagent transcript. Return to main chat to send." : nil, - transitionNamespace: transitionNamespace, - onOpenLane: showsLaneActions ? openSessionLane : nil, - onSend: sendMessage, - onInterrupt: interruptSession, - onApproveRequest: approveRequest, - onRespondToQuestion: respondToQuestion, - onSubmitQuestionAnswers: submitQuestionAnswers, - onDeclineQuestion: declineQuestion, - onRespondToPermission: respondToPermission, - onRetryLoad: load, - onOpenFile: openFileReference, - onOpenPr: openPullRequestReference, - onLoadArtifact: loadArtifactContent, - onRefreshArtifacts: { - await refreshArtifacts(force: true) - }, - onCancelSteer: cancelSteer, - onEditSteer: editSteer, - onDispatchSteerInline: supportsManualSteerDispatch ? dispatchSteerInline : nil, - onDispatchSteerInterrupt: supportsManualSteerDispatch ? dispatchSteerInterrupt : nil, - onSelectModel: selectModel, - onSelectRuntimeMode: selectRuntimeMode, - onSelectEffort: selectReasoningEffort, - onSelectCodexFastMode: selectCodexFastMode, - resolvedSessionStatus: viewingSubagent ? "ended" : sessionStatus, - lanes: lanes, - lanesRenderSignature: workLaneListRenderSignature(lanes), - hasOlderTranscriptHistory: viewingSubagent ? false : hasOlderTranscriptHistory, - onLoadOlderTranscript: viewingSubagent ? nil : loadOlderTranscriptEntries, - subagentSnapshots: subagentSnapshots, - subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature(subagentSnapshots), - selectedSubagentTaskId: subagentView?.taskId, - onOpenSubagents: { Task { await prepareSubagentDrawerPresentation() } }, - liveTurnActiveHint: liveTurnActiveHint - ) - .id( - viewingSubagent - ? "subagent-\(subagentView?.taskId ?? "unknown")-\(subagentTranscriptRenderSignature)" - : "main-\(session.id)-\(mainChatRenderEpoch)" - ) + chatSessionDestinationRoot(for: session) } else { TerminalSessionScreen(session: session) .environmentObject(syncService) @@ -927,6 +916,131 @@ struct WorkSessionDestinationView: View { } } + @ViewBuilder + private func chatSessionDestinationRoot(for session: TerminalSessionSummary) -> some View { + let viewingSubagent = subagentView != nil + makeWorkChatSessionView(for: session, viewingSubagent: viewingSubagent) + .id(chatSessionDestinationRootId(for: session, viewingSubagent: viewingSubagent)) + } + + private func chatSessionDestinationRootId( + for session: TerminalSessionSummary, + viewingSubagent: Bool + ) -> String { + if viewingSubagent { + return "subagent-\(subagentView?.taskId ?? "unknown")-\(subagentTranscriptRenderSignature)" + } + return "main-\(session.id)-\(mainChatRenderEpoch)" + } + + private func makeWorkChatSessionView( + for session: TerminalSessionSummary, + viewingSubagent: Bool + ) -> WorkChatSessionView { + let transcriptForView = viewingSubagent ? subagentTranscript : transcript + let fallbackEntriesForView: [AgentChatTranscriptEntry] = viewingSubagent ? [] : fallbackEntries + let artifactsForView: [ComputerUseArtifactSummary] = viewingSubagent ? [] : artifacts + let optimisticPendingSteersForView: [WorkPendingSteerModel] = viewingSubagent ? [] : optimisticPendingSteers + let localEchoMessagesForView: [WorkLocalEchoMessage] = viewingSubagent ? [] : localEchoMessages + let sessionStatus = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) + let shouldSteer = hostReachable && sessionStatus == "active" + let chatPrBadge: WorkChatPrBadgeModel? = viewingSubagent + ? nil + : workChatPrBadgeModel(tag: lanePrTag, pr: laneOpenPr, summary: lanePrSummary) + let openPrDetails: (() -> Void)? = viewingSubagent ? nil : { presentChatPrDetails() } + let inputLockMessage: String? = viewingSubagent + ? "Viewing subagent transcript. Return to main chat to send." + : nil + let openLaneAction: (() -> Void)? = showsLaneActions ? { openSessionLane() } : nil + let dispatchSteerInlineAction: (@MainActor (String) async -> Void)? + let dispatchSteerInterruptAction: (@MainActor (String) async -> Void)? + if supportsManualSteerDispatch { + dispatchSteerInlineAction = { text in await dispatchSteerInline(text) } + dispatchSteerInterruptAction = { text in await dispatchSteerInterrupt(text) } + } else { + dispatchSteerInlineAction = nil + dispatchSteerInterruptAction = nil + } + let resolvedSessionStatus: String? = viewingSubagent ? "ended" : sessionStatus + let loadOlderTranscriptAction: (@MainActor () async -> Void)? + if viewingSubagent { + loadOlderTranscriptAction = nil + } else { + loadOlderTranscriptAction = { await loadOlderTranscriptEntries() } + } + return WorkChatSessionView( + session: WorkChatSessionRenderContext(session), + chatSummaryContext: WorkChatSummaryRenderContext(chatSummary), + transcript: transcriptForView, + transcriptRenderSignature: viewingSubagent ? subagentTranscriptRenderSignature : transcriptRenderSignature, + fallbackEntries: fallbackEntriesForView, + fallbackEntriesRenderSignature: viewingSubagent ? 0 : fallbackEntriesRenderSignature, + artifacts: artifactsForView, + artifactsRenderSignature: viewingSubagent ? 0 : artifactsRenderSignature, + optimisticPendingSteers: optimisticPendingSteersForView, + optimisticPendingSteersRenderSignature: viewingSubagent ? 0 : workPendingSteersRenderSignature(optimisticPendingSteers), + localEchoMessages: localEchoMessagesForView, + localEchoMessagesRenderSignature: viewingSubagent ? 0 : workLocalEchoMessagesRenderSignature(localEchoMessages), + expandedToolCardIdsSnapshot: expandedToolCardIds, + expandedToolCardIdsRenderSignature: workExpandedToolCardIdsRenderSignature(expandedToolCardIds), + artifactContentRenderSignature: artifactContentRenderSignature, + artifactDrawerPresentedSnapshot: artifactDrawerPresented, + sendingSnapshot: sending, + errorMessageSnapshot: errorMessage, + expandedToolCardIds: $expandedToolCardIds, + artifactContent: $artifactContent, + fullscreenImage: $fullscreenImage, + artifactDrawerPresented: $artifactDrawerPresented, + artifactRefreshInFlight: artifactRefreshInFlight, + artifactRefreshError: artifactRefreshError, + sending: $sending, + errorMessage: $errorMessage, + isLive: isLiveAndReachable, + hostUnreachable: syncService.connectionState.isHostUnreachable, + canComposeMessages: canComposeChatMessages && !viewingSubagent, + canSendMessages: canSendChatMessages && !viewingSubagent, + sendWillQueue: sendWillQueueChatMessage || shouldSteer, + sendWillQueueIsReconnect: sendWillQueueChatMessage, + inputLockMessage: inputLockMessage, + transitionNamespace: transitionNamespace, + onOpenLane: openLaneAction, + onSend: sendMessage, + onInterrupt: interruptSession, + onApproveRequest: approveRequest, + onRespondToQuestion: respondToQuestion, + onSubmitQuestionAnswers: submitQuestionAnswers, + onDeclineQuestion: declineQuestion, + onRespondToPermission: respondToPermission, + onRetryLoad: load, + onOpenFile: openFileReference, + onOpenPr: openPullRequestReference, + onLoadArtifact: loadArtifactContent, + onRefreshArtifacts: { + await refreshArtifacts(force: true) + }, + onCancelSteer: cancelSteer, + onEditSteer: editSteer, + onDispatchSteerInline: dispatchSteerInlineAction, + onDispatchSteerInterrupt: dispatchSteerInterruptAction, + onSelectModel: selectModel, + onSelectRuntimeMode: selectRuntimeMode, + onSelectEffort: selectReasoningEffort, + onSelectCodexFastMode: selectCodexFastMode, + resolvedSessionStatus: resolvedSessionStatus, + lanes: lanes, + lanesRenderSignature: workLaneListRenderSignature(lanes), + hasOlderTranscriptHistory: viewingSubagent ? false : hasOlderTranscriptHistory, + onLoadOlderTranscript: loadOlderTranscriptAction, + subagentSnapshots: subagentSnapshots, + subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature(subagentSnapshots), + selectedSubagentTaskId: subagentView?.taskId, + onOpenSubagents: { Task { await prepareSubagentDrawerPresentation() } }, + prBadge: chatPrBadge, + onOpenPrDetails: openPrDetails, + liveTurnActiveHint: liveTurnActiveHint + ) + } + var pollingKey: String { let status = normalizedWorkChatSessionStatus(session: session, summary: chatSummary) // liveTurnActiveHint participates so a desktop-started turn (session row @@ -1060,14 +1174,18 @@ struct WorkSessionDestinationView: View { if forceRemote, let currentSession = session ?? initialSession, isChatSession(currentSession) { let alreadySubscribed = syncService.subscribedChatSessionIds.contains(sessionId) + let needsOpeningSnapshot = transcript.isEmpty && fallbackEntries.isEmpty if status == "active" { // First visit subscribes (the host answers with a snapshot or a // sinceSeq replay). Once subscribed, live chat_event push plus the // host's transcript pump cover continuity — re-requesting a full // byte-capped snapshot on every 8s poll was redundant wire traffic // and a full dedupe/sort merge on the phone mid-stream. - try? await syncService.subscribeToChatEvents(sessionId: sessionId, requestSnapshot: !alreadySubscribed) - } else if !alreadySubscribed { + try? await syncService.subscribeToChatEvents( + sessionId: sessionId, + requestSnapshot: !alreadySubscribed || needsOpeningSnapshot + ) + } else if !alreadySubscribed || needsOpeningSnapshot { // Active streaming stays on reduced snapshots for performance, but an // idle detail view must reconcile against a full event snapshot. A // reduced JSONL tail can start mid-message and render as a broken diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 01148588c..ed60398ee 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -9975,6 +9975,56 @@ final class ADETests: XCTestCase { XCTAssertFalse(secondPage.text.contains("97. Line 97")) } + func testTailAssistantMessagePreviewRendersSmallLatestAnswerFully() { + let lineCount = 110 + let markdown = (1...lineCount).map { index in + "Line \(index): " + String(repeating: "latest transcript answer prose ", count: 2) + }.joined(separator: "\n") + XCTAssertGreaterThan(markdown.count, workAssistantMessageSmallFullCharacterBudget) + XCTAssertLessThan(markdown.count, workAssistantMessageTailFullCharacterBudget) + + let preview = workAssistantMessagePreview( + markdown, + lineBudget: workAssistantMessageTailFullLineBudget, + characterBudget: workAssistantMessageCharacterBudget( + forLineBudget: workAssistantMessageTailFullLineBudget, + tailCanRenderFull: true + ), + anchor: .tail + ) + + XCTAssertFalse(preview.isTruncated) + XCTAssertEqual(preview.visibleLineCount, lineCount) + XCTAssertEqual(preview.totalLineCount, lineCount) + XCTAssertEqual(preview.text, markdown) + + var message = WorkChatMessage( + id: "assistant-tail-small", + role: "assistant", + markdown: markdown, + timestamp: "2026-03-25T00:00:01.000Z", + turnId: "turn-1", + itemId: "item-1" + ) + message.assistantPreview = preview + let entry = WorkTimelineEntry( + id: "message-assistant-tail-small", + timestamp: message.timestamp, + rank: 0, + payload: .message(message) + ) + + let rendered = workTimelineRenderEntries( + from: [entry], + streamingAssistantMessageId: nil, + splitAssistantMessageId: message.id + ) + XCTAssertFalse(rendered.contains { renderEntry in + if case .assistantControls = renderEntry.payload { return true } + return false + }) + } + func testAssistantMessagePreviewCapsWireframesBeforeTheyCanOverloadLayout() { let markdown = (1...120).map { index in "│ \(String(repeating: "─", count: 72)) │ row \(index)" From 08e50400b8aa2b6db63d26479c7e069041a18b7e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:58:06 -0400 Subject: [PATCH 3/7] Mobile sync stability fixes + webhook relay productization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS crash/perf hardening (verified: iOS build green, desktop sync 24/24, ade-cli mpRpc 10/10): - Fix Int64(Double) bind trap (safeInt64Value) — poison-batch launch crasher - Fix gunzip pointer-escape UB; enforce 32MiB chunk-reassembly byte cap - Fix reconnect-wedge (generation-paired reconnectConnectInFlight flag) - Evict per-session chat-event history (64-session LRU) - Clamp chat_subscribe snapshot to client maxBytes + dedupe snapshot/pump overlap - Files diff: O(n·m) LCS -> CollectionDifference, computed once into @State - Attachment thumbnails downsample via CGImageSourceCreateThumbnailAtIndex - Handoff classification via isChatToolType (cursor/droid + orphan chat rows) - fetchSessionLocked keeps lane-less sessions fetchable by id Quick wins: memoize runtime build hash; cache compiled regex in workRegexMatches; shared date parser in CTO workflows; raise highlight cache to 160; LaneDetail defer-not-drop throttle; in-place recordChatEventEnvelope (no per-event array copy); cap prDetailCache; CtoTeamScreen visibility gate. Webhook relay productization: hosted Cloudflare Worker default, repo-scoped routes authorized by the user's GitHub token (no relay token / local.secret required); legacy project-token routes preserved for self-hosted. Co-Authored-By: Claude Fable 5 --- apps/ade-cli/src/bootstrap.ts | 1 + apps/ade-cli/src/headlessLinearServices.ts | 2 + .../ade-cli/src/multiProjectRpcServer.test.ts | 40 ++ apps/ade-cli/src/multiProjectRpcServer.ts | 33 +- .../src/services/sync/syncHostService.test.ts | 114 ++++ .../src/services/sync/syncHostService.ts | 45 +- apps/ade-cli/src/services/sync/syncService.ts | 11 +- apps/desktop/src/main/main.ts | 1 + .../automationIngressService.test.ts | 46 ++ .../automations/automationIngressService.ts | 41 +- .../services/chat/agentChatService.test.ts | 25 + .../main/services/chat/agentChatService.ts | 31 +- .../main/services/github/githubRelayConfig.ts | 32 +- .../services/github/githubService.test.ts | 46 +- .../src/main/services/github/githubService.ts | 1 + .../services/sync/syncHostService.test.ts | 38 +- .../main/services/sync/syncService.test.ts | 48 +- .../github/GitHubAppInstallPanel.tsx | 18 +- apps/ios/ADE/App/ContentView.swift | 7 + apps/ios/ADE/Models/RemoteModels.swift | 11 +- apps/ios/ADE/Models/RemoteRosterModels.swift | 5 +- apps/ios/ADE/Resources/DatabaseBootstrap.sql | 1 - apps/ios/ADE/Services/Database.swift | 38 +- apps/ios/ADE/Services/SyncService.swift | 262 +++++++-- .../Components/ADECodeRenderingCache.swift | 2 +- .../Views/Components/FilesCodeSupport.swift | 121 +++-- apps/ios/ADE/Views/Cto/CtoRootScreen.swift | 2 +- apps/ios/ADE/Views/Cto/CtoTeamScreen.swift | 3 + .../ADE/Views/Cto/CtoWorkflowsScreen.swift | 7 +- .../Views/Files/FilesDetailComponents.swift | 9 +- .../Files/FilesDetailScreen+Actions.swift | 12 +- .../ADE/Views/Files/FilesDetailScreen.swift | 18 +- .../Files/FilesDirectoryContentsView.swift | 1 + apps/ios/ADE/Views/Files/FilesModels.swift | 40 +- .../ADE/Views/Files/FilesRootComponents.swift | 14 +- .../ADE/Views/Files/FilesSearchScreen.swift | 1 + apps/ios/ADE/Views/Hub/HubComponents.swift | 141 ++--- .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 1 + .../Views/Hub/HubScreen+ChatNavigation.swift | 1 + apps/ios/ADE/Views/Hub/HubScreen.swift | 48 +- .../ADE/Views/Lanes/LaneDetailScreen.swift | 13 +- .../Views/Work/WorkChatAttachmentTray.swift | 52 +- .../Work/WorkChatHeaderAndMessageViews.swift | 175 +++++- apps/ios/ADE/Views/Work/WorkChatPrViews.swift | 500 ++++++++++++------ .../ADE/Views/Work/WorkChatSessionView.swift | 2 +- .../Work/WorkContextCompactDivider.swift | 120 +++-- .../Work/WorkErrorAndMessageHelpers.swift | 48 +- .../ADE/Views/Work/WorkModelPickerSheet.swift | 29 +- .../WorkNavigationAndTranscriptHelpers.swift | 2 +- .../ADE/Views/Work/WorkNewChatScreen.swift | 61 +-- .../Views/Work/WorkRootScreen+Actions.swift | 34 ++ apps/ios/ADE/Views/Work/WorkRootScreen.swift | 39 +- .../WorkSessionDestinationView+Actions.swift | 29 +- .../Work/WorkSessionDestinationView.swift | 292 +++++----- .../Views/Work/WorkSessionSettingsSheet.swift | 2 +- .../Work/WorkStatusAndFormattingHelpers.swift | 4 +- apps/ios/ADETests/ADETests.swift | 325 +++++++++++- .../SyncEnvelopeChunkAssemblerTests.swift | 20 + apps/webhook-relay/README.md | 59 ++- .../0003_github_events_repository.sql | 2 + apps/webhook-relay/src/relay.ts | 154 +++++- apps/webhook-relay/test/relay.test.ts | 118 ++++- docs/features/automations/README.md | 4 +- 63 files changed, 2500 insertions(+), 902 deletions(-) create mode 100644 apps/webhook-relay/migrations/0003_github_events_repository.sql diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index b6ad368e4..87e493bcb 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -1079,6 +1079,7 @@ export async function createAdeRuntime(args: { automationService, prService: headlessLinearServices.prService, secretService: automationSecretService, + githubService: headlessLinearServices.githubService, listRules: () => projectConfigService.get().effective.automations ?? [], }) : null; diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 00f76fe40..7f81eb509 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -1009,6 +1009,8 @@ export function createHeadlessGitHubService( return fetchGitHubAppInstallationStatus({ repo, secretReader: options.githubRelaySecretReader, + forceRefresh: args.forceRefresh === true, + githubToken: getToken(), }); }, async getRepoOrThrow() { diff --git a/apps/ade-cli/src/multiProjectRpcServer.test.ts b/apps/ade-cli/src/multiProjectRpcServer.test.ts index 7c555f546..69ac031c3 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.test.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import { createHash } from "node:crypto"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; @@ -48,6 +49,45 @@ function makeRuntime(label: string) { } describe("multi-project RPC server", () => { + it("reports a build hash for manually-started CLI entrypoints", async () => { + const { registry, root } = createRegistry(); + const cliPath = path.join(root, "manual-cli.cjs"); + fs.writeFileSync(cliPath, "console.log('manual runtime');\n"); + const expectedHash = createHash("sha256").update(fs.readFileSync(cliPath)).digest("hex"); + const originalArgv = process.argv; + const originalBuildHash = process.env.ADE_RUNTIME_BUILD_HASH; + process.argv = [originalArgv[0] ?? "node", cliPath]; + delete process.env.ADE_RUNTIME_BUILD_HASH; + try { + const handler = createMultiProjectRpcRequestHandler({ + serverVersion: "test", + projectRegistry: registry, + }); + + const init = await handler({ + jsonrpc: "2.0", + id: 1, + method: "ade/initialize", + params: {}, + }); + + expect(init).toMatchObject({ + runtimeInfo: { + buildHash: expectedHash, + multiProject: true, + }, + }); + handler.dispose(); + } finally { + process.argv = originalArgv; + if (originalBuildHash === undefined) { + delete process.env.ADE_RUNTIME_BUILD_HASH; + } else { + process.env.ADE_RUNTIME_BUILD_HASH = originalBuildHash; + } + } + }); + it("exposes runtime-scoped project registry methods", async () => { const { projectRoot, expectedProjectRoot, registry } = createRegistry(); const handler = createMultiProjectRpcRequestHandler({ diff --git a/apps/ade-cli/src/multiProjectRpcServer.ts b/apps/ade-cli/src/multiProjectRpcServer.ts index 8d8543f04..195122fe3 100644 --- a/apps/ade-cli/src/multiProjectRpcServer.ts +++ b/apps/ade-cli/src/multiProjectRpcServer.ts @@ -1,4 +1,6 @@ import { createAdeRpcRequestHandler } from "./adeRpcServer"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService"; @@ -282,6 +284,11 @@ function readLimit(value: unknown): number { : 100; } +// The entrypoint cannot change during the process lifetime, so hash it once and +// reuse the result. `undefined` means "not computed yet"; `null` is a cached +// failure (missing/unreadable entrypoint) that must not retry on every call. +let cachedRuntimeBuildHash: string | null | undefined; + export function createMultiProjectRpcRequestHandler( options: MultiProjectRpcHandlerOptions, ): JsonRpcHandler & { @@ -450,11 +457,35 @@ export function createMultiProjectRpcRequestHandler( return typeof value === "string" && value.trim() ? value.trim() : null; }; + const computeRuntimeBuildHash = (): string | null => { + if (cachedRuntimeBuildHash !== undefined) return cachedRuntimeBuildHash; + const entrypoint = process.argv[1]; + if (typeof entrypoint !== "string" || !entrypoint.trim()) { + cachedRuntimeBuildHash = null; + return null; + } + try { + const resolved = path.resolve(entrypoint); + const stat = fs.statSync(resolved); + if (!stat.isFile()) { + cachedRuntimeBuildHash = null; + return null; + } + cachedRuntimeBuildHash = createHash("sha256") + .update(fs.readFileSync(resolved)) + .digest("hex"); + return cachedRuntimeBuildHash; + } catch { + cachedRuntimeBuildHash = null; + return null; + } + }; + const resolveRuntimeEnvInfo = () => { const projectRoot = trimmedEnvOrNull("ADE_PROJECT_ROOT"); const packageChannel = trimmedEnvOrNull("ADE_PACKAGE_CHANNEL"); return { - buildHash: trimmedEnvOrNull("ADE_RUNTIME_BUILD_HASH"), + buildHash: trimmedEnvOrNull("ADE_RUNTIME_BUILD_HASH") ?? computeRuntimeBuildHash(), defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE), packageChannel, projectRoot: projectRoot ? path.resolve(projectRoot) : null, diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index f99c0c98f..a29585809 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -1509,6 +1509,120 @@ describe("sync host handoff over a shared listener", () => { }); }); +describe("chat_subscribe snapshots", () => { + beforeEach(() => { + publishMock.mockReset(); + spawnMock.mockReset(); + bonjourDestroyMock.mockReset(); + bonjourConstructorMock.mockReset(); + spawnMock.mockImplementation(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn() })); + }); + + it("passes the peer byte cap to chat history and does not replay snapshot events from the transcript pump", async () => { + const { projectRoot, cleanup } = createTempProjectRoot(); + const transcriptPath = path.join(projectRoot, "transcripts", "chat-1.chat.jsonl"); + fs.mkdirSync(path.dirname(transcriptPath), { recursive: true }); + fs.writeFileSync(transcriptPath, "", "utf8"); + const event: AgentChatEventEnvelope = { + sessionId: "chat-1", + timestamp: "2026-04-23T10:00:00.000Z", + sequence: 1, + event: { type: "text", text: "in-flight text" }, + }; + const laterEvent: AgentChatEventEnvelope = { + sessionId: "chat-1", + timestamp: "2026-04-23T10:00:01.000Z", + sequence: 2, + event: { type: "text", text: "later transcript text" }, + }; + const session = { + id: "chat-1", + laneId: "lane-1", + transcriptPath, + status: "running", + runtimeState: "running", + lastOutputPreview: "", + }; + const getChatEventHistory = vi.fn().mockReturnValue({ + sessionId: "chat-1", + events: [event], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + }); + const base = createHostArgs(projectRoot, []); + const host = createSyncHostService({ + ...base, + pollIntervalMs: 100, + projectId: "project-1", + db: { + sync: { + getSiteId: () => "site-host-chat-subscribe", + getDbVersion: () => 0, + exportChangesSince: () => [], + applyChanges: () => ({ appliedCount: 0 }), + discardUnpublishedChangesForTables: () => {}, + }, + }, + deviceRegistryService: { + ...base.deviceRegistryService, + upsertPeerMetadata: vi.fn(), + }, + sessionService: { + list: () => [session], + get: (id: string) => (id === "chat-1" ? session : null), + readTranscriptTail: async () => "", + }, + agentChatService: { + subscribeToEvents: vi.fn().mockReturnValue(() => {}), + getChatEventHistory, + getSessionSummary: vi.fn().mockResolvedValue({ status: "active" }), + }, + } as unknown as Parameters[0]); + let peer: Awaited> | null = null; + + try { + const port = await host.waitUntilListening(); + peer = await connectPeer(port, host.getBootstrapToken(), "ios-chat-subscribe"); + peer.ws.send(encodeSyncEnvelope({ + type: "chat_subscribe", + requestId: "chat-subscribe-1", + payload: { sessionId: "chat-1", maxBytes: 4_096 }, + })); + + const ack = await waitForEnvelope(peer.envelopes, "chat_subscribe", "chat-subscribe-1"); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { + maxEvents: CHAT_EVENT_REPLAY_MAX_EVENTS, + maxBytes: 4_096, + }); + expect((ack.payload as { events?: AgentChatEventEnvelope[] }).events).toEqual([event]); + expect(ack.payload).toMatchObject({ turnActive: true }); + + fs.appendFileSync(transcriptPath, `${JSON.stringify(event)}\n${JSON.stringify(laterEvent)}\n`, "utf8"); + const delivered = await waitForValue( + () => peer?.envelopes.find((envelope) => envelope.type === "chat_event"), + "later chat event", + ); + expect(delivered.payload).toMatchObject({ + sessionId: "chat-1", + event: { type: "text", text: "later transcript text" }, + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + expect(peer.envelopes.filter((envelope) => envelope.type === "chat_event")).toHaveLength(1); + } finally { + try { + peer?.ws.close(); + } catch { + // ignore + } + await host.dispose(); + cleanup(); + } + }); +}); + describe("chat event replay buffer (resumable chat streams)", () => { const sessionId = "session-replay"; diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 3e8d45c66..a48d9770f 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -37,6 +37,7 @@ import type { CreateProjectInput, SyncEnvelope, SyncChatEventPayload, + SyncChatSubscribePayload, SyncChatSubscribeSnapshotPayload, SyncChatUnsubscribePayload, SyncFileBlob, @@ -216,14 +217,6 @@ export type NativeLanDiscoveryProcess = { ppid: number; command: string; }; -const MOBILE_MUTATING_FILE_ACTIONS = new Set([ - "writeText", - "createFile", - "createDirectory", - "rename", - "deletePath", -]); - export function syncFileRequestWorkspaceId(payload: SyncFileRequest): string | null { switch (payload.action) { case "listTree": @@ -3379,13 +3372,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { .find((entry) => entry.id === workspaceId) ?? null; } - function assertWriteAllowed(peer: PeerState, workspace: FilesWorkspace | null): void { - if (!isMobilePeer(peer)) return; - if (!workspace || workspace.mobileReadOnly === true || workspace.isReadOnlyByDefault) { - throw new Error("Mobile file access is read-only for this workspace."); - } - } - function assertMobileExternalWorkspaceBlocked(peer: PeerState, payload: SyncFileRequest): void { assertFileRequestWorkspaceVisibleToPeer({ isMobile: isMobilePeer(peer), @@ -3393,20 +3379,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { }); } - function assertFileMutationAllowed(peer: PeerState, payload: SyncFileRequest): void { - if (!MOBILE_MUTATING_FILE_ACTIONS.has(payload.action)) return; - const workspaceId = toOptionalString((payload as { args?: { workspaceId?: unknown } }).args?.workspaceId); - assertWriteAllowed(peer, workspaceForId(workspaceId)); - } - - function assertLaneFileMutationAllowed(peer: PeerState, payload: SyncCommandPayload): void { - const laneId = toOptionalString((payload.args as Record | null | undefined)?.laneId); - if (!laneId) return; - const workspace = args.fileService.listWorkspaces({ includeArchived: true }) - .find((entry) => entry.laneId === laneId) ?? null; - assertWriteAllowed(peer, workspace); - } - async function handleFileRequest(peer: PeerState, requestId: string | null, payload: SyncFileRequest): Promise { const respond = (response: SyncFileResponsePayload) => { sendRequired(peer, "file_response", response, requestId); @@ -3414,7 +3386,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { try { assertMobileExternalWorkspaceBlocked(peer, payload); - assertFileMutationAllowed(peer, payload); let result: | FilesWorkspace[] | FileTreeNode[] @@ -3676,14 +3647,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { reject(`Remote command ${payload.action} is not available to paired controller devices.`, "forbidden_command"); return; } - if (payload.action === "files.writeTextAtomic") { - try { - assertLaneFileMutationAllowed(peer, payload); - } catch (error) { - reject(error instanceof Error ? error.message : String(error), "mobile_read_only"); - return; - } - } if (policy.localOnly || policy.requiresApproval) { reject(`Remote command ${payload.action} requires approval on this machine.`, "approval_required"); return; @@ -4322,7 +4285,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { break; } case "chat_subscribe": { - const payload = envelope.payload as { sessionId?: string; maxBytes?: number; sinceSeq?: number } | null; + const payload = envelope.payload as SyncChatSubscribePayload | null; const sessionId = toOptionalString(payload?.sessionId); if (!sessionId) break; peer.subscribedChatSessionIds.add(sessionId); @@ -4378,6 +4341,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ); 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) @@ -4392,6 +4356,9 @@ export function createSyncHostService(args: SyncHostServiceArgs) { ...(await resolveLiveStatusFields()), }; sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); + for (const event of events) { + rememberChatEventSent(peer, event); + } break; } case "chat_unsubscribe": { diff --git a/apps/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 92366ab03..f23404574 100644 --- a/apps/ade-cli/src/services/sync/syncService.ts +++ b/apps/ade-cli/src/services/sync/syncService.ts @@ -219,7 +219,11 @@ function migrateLegacySyncSecretFile(args: { } } const RUNNING_PROCESS_STATES = new Set(["starting", "running", "degraded"]); -const CHAT_TOOL_TYPES = new Set(["codex-chat", "claude-chat", "opencode-chat"]); +function isChatToolType(toolType: string | null | undefined): boolean { + const normalized = toolType?.trim().toLowerCase(); + if (!normalized) return false; + return normalized === "cursor" || normalized.endsWith("-chat"); +} const LEGACY_SYNC_HOST_PORT_RETRY_WINDOW = 13; const SYNC_HOST_PORT_RETRY_WINDOW = 8999 - DEFAULT_SYNC_HOST_PORT; const LEGACY_SYNC_HOST_MAX_PORT = DEFAULT_SYNC_HOST_PORT + LEGACY_SYNC_HOST_PORT_RETRY_WINDOW; @@ -1009,8 +1013,11 @@ export function createSyncService(args: SyncServiceArgs) { status: "running", limit: 500, })) { - if (CHAT_TOOL_TYPES.has(session.toolType ?? "")) { + if (isChatToolType(session.toolType)) { const chat = chatSummaries.get(session.id); + if (chat && chat.status !== "active") { + continue; + } const isCto = chat?.identityKey === "cto"; blockers.push({ kind: "chat_runtime", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 204821c3b..c49474c6d 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3193,6 +3193,7 @@ app.whenReady().then(async () => { automationService, prService, secretService: automationSecretService, + githubService, listRules: () => projectConfigService.get().effective.automations ?? [], }) : null; diff --git a/apps/desktop/src/main/services/automations/automationIngressService.test.ts b/apps/desktop/src/main/services/automations/automationIngressService.test.ts index e760f613f..5c252a653 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.test.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.test.ts @@ -284,6 +284,52 @@ describe("automationIngressService", () => { } }); + it("polls the hosted repo relay with the existing GitHub token when no relay secret is configured", async () => { + const updates: Array> = []; + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ + events: [], + nextCursor: null, + }), { headers: { "content-type": "application/json" } })); + + service = createAutomationIngressService({ + logger: makeLogger() as never, + automationService: { + updateIngressStatus: (patch: Record) => updates.push(patch), + dispatchIngressTrigger: vi.fn(), + getIngressCursor: () => null, + setIngressCursor: vi.fn(), + getIngressStatus: () => ({}), + } as never, + secretService: { + getSecret: () => null, + } as never, + githubService: { + detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })), + getTokenOrThrow: vi.fn(() => "ghp_user_token"), + }, + listRules: () => [], + }); + + await service.pollNow(); + + expect(fetchSpy).toHaveBeenCalledWith( + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events", + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "Bearer ghp_user_token", + }), + }), + ); + expect(updates).toContainEqual(expect.objectContaining({ + githubRelay: expect.objectContaining({ + configured: true, + apiBaseUrl: "https://ade-github-webhook-relay.arulsharma1028.workers.dev", + remoteProjectId: "arul28/ADE", + status: "polling", + }), + })); + }); + it("deduplicates overlapping GitHub relay polls", async () => { let resolveFetch!: (response: Response) => void; const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(() => new Promise((resolve) => { diff --git a/apps/desktop/src/main/services/automations/automationIngressService.ts b/apps/desktop/src/main/services/automations/automationIngressService.ts index 25013ac58..3eaee31bb 100644 --- a/apps/desktop/src/main/services/automations/automationIngressService.ts +++ b/apps/desktop/src/main/services/automations/automationIngressService.ts @@ -1,18 +1,22 @@ import { createHmac, timingSafeEqual } from "node:crypto"; import http from "node:http"; import { URL } from "node:url"; -import type { AutomationIngressEventRecord, AutomationIngressStatus, AutomationRule, AutomationTriggerType } from "../../../shared/types"; +import type { AutomationIngressEventRecord, AutomationIngressStatus, AutomationRule, AutomationTriggerType, GitHubRepoRef } from "../../../shared/types"; import type { Logger } from "../logging/logger"; import type { createAutomationService } from "./automationService"; import type { AutomationSecretService } from "./automationSecretService"; import type { createPrService } from "../prs/prService"; -import { gitHubRelayAuthorizationToken, readGitHubRelayConfig } from "../github/githubRelayConfig"; +import { gitHubRelayAuthorizationToken, readGitHubRelayConfig, shouldUseLegacyGitHubRelayProjectRoute } from "../github/githubRelayConfig"; type AutomationIngressServiceArgs = { logger: Logger; automationService: ReturnType; prService?: ReturnType | null; secretService: AutomationSecretService; + githubService?: { + detectRepo: () => Promise | GitHubRepoRef | null; + getTokenOrThrow: () => string; + } | null; listRules: () => AutomationRule[]; pollIntervalMs?: number; }; @@ -423,14 +427,37 @@ export function createAutomationIngressService(args: AutomationIngressServiceArg try { const cursor = args.automationService.getIngressCursor("github-relay"); const baseUrl = config.apiBaseUrl!.replace(/\/+$/, ""); - const eventsUrl = new URL( - `${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/events`, - ); + const legacyAuthToken = gitHubRelayAuthorizationToken(config); + const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); + const repo = useLegacyProjectRoute ? null : await args.githubService?.detectRepo(); + const eventsUrl = useLegacyProjectRoute + ? new URL(`${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/events`) + : repo + ? new URL(`${baseUrl}/github/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}/events`) + : null; + if (!eventsUrl) { + updateGithubRelayStatus({ + configured: true, + apiBaseUrl: config.apiBaseUrl, + remoteProjectId: null, + healthy: false, + status: "disabled", + lastError: null, + }); + return; + } if (cursor) eventsUrl.searchParams.set("after", cursor); - const authToken = gitHubRelayAuthorizationToken(config); + const githubToken = useLegacyProjectRoute ? null : args.githubService?.getTokenOrThrow(); + const authToken = useLegacyProjectRoute ? legacyAuthToken : githubToken; if (!authToken) { - throw new Error("GitHub relay access token is not configured."); + throw new Error("GitHub auth is required for relay polling."); } + updateGithubRelayStatus({ + configured: true, + apiBaseUrl: config.apiBaseUrl, + remoteProjectId: useLegacyProjectRoute ? config.remoteProjectId : repo ? `${repo.owner}/${repo.name}` : null, + status: "polling", + }); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), GITHUB_RELAY_POLL_TIMEOUT_MS); const response = await fetch( diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d5232ea30..0a67db126 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -12696,6 +12696,31 @@ describe("createAgentChatService", () => { expect(history.events).toHaveLength(1); }); + it("drops an oversized newest event when a strict mobile byte budget is requested", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const giant: AgentChatEventEnvelope = { + sessionId: session.id, + timestamp: "2026-04-23T10:00:00.000Z", + event: { type: "text", text: "giant-".concat("y".repeat(16_000)) }, + sequence: 1, + }; + const transcriptFile = path.join(tmpRoot, "transcripts", `${session.id}.chat.jsonl`); + fs.writeFileSync(transcriptFile, "ignored\n", "utf8"); + vi.mocked(parseAgentChatTranscript).mockReturnValue([giant]); + + const history = service.getChatEventHistory(session.id, { maxBytes: 8_192 }); + + expect(history.events).toHaveLength(0); + expect(history.windowTruncated).toBe(true); + expect(history.truncated).toBe(true); + }); + it("marks window truncation when the service response cap removes events", async () => { const { service } = createService(); const session = await service.createSession({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ce59b3160..465e9a9d3 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -5462,18 +5462,27 @@ export function createAgentChatService(args: { } }; - // Keep the newest items whose cumulative serialized size fits the budget - // (always at least the newest one, even when it alone exceeds it). + // Keep the newest items whose cumulative serialized size fits the budget. + // Desktop history keeps the newest item even when it alone exceeds the + // budget so a local pane can still show the latest event; mobile callers can + // request a hard cap to avoid sending an oversized snapshot over sync. const keepNewestWithinCharBudget = ( items: T[], maxChars: number, sizeOf: (item: T) => number, + options?: { keepOversizeNewest?: boolean }, ): T[] => { + const keepOversizeNewest = options?.keepOversizeNewest ?? true; let total = 0; let start = items.length; while (start > 0) { const next = total + sizeOf(items[start - 1]!); - if (start < items.length && next > maxChars) break; + if (next > maxChars) { + if (start === items.length && keepOversizeNewest) { + start -= 1; + } + break; + } total = next; start -= 1; } @@ -5496,7 +5505,9 @@ export function createAgentChatService(args: { const trimEnvelopesToByteBudget = ( envelopes: AgentChatEventEnvelope[], maxChars: number, - ): AgentChatEventEnvelope[] => keepNewestWithinCharBudget(envelopes, maxChars, estimateEnvelopeChars); + options?: { keepOversizeNewest?: boolean }, + ): AgentChatEventEnvelope[] => + keepNewestWithinCharBudget(envelopes, maxChars, estimateEnvelopeChars, options); const STORED_COMMAND_OUTPUT_RUNNING_MAX_BYTES = 4 * 1024; const STORED_COMMAND_OUTPUT_COMPLETED_MAX_BYTES = 16 * 1024; @@ -7228,7 +7239,7 @@ export function createAgentChatService(args: { */ const getChatEventHistory = ( sessionId: string, - options?: { maxEvents?: number }, + options?: { maxEvents?: number; maxBytes?: number }, ): AgentChatEventHistorySnapshot => { const trimmedId = sessionId.trim(); if (!trimmedId.length) { @@ -7255,6 +7266,12 @@ export function createAgentChatService(args: { Math.floor(options?.maxEvents ?? CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION), ), ); + const requestedMaxBytes = typeof options?.maxBytes === "number" && Number.isFinite(options.maxBytes) + ? Math.floor(options.maxBytes) + : null; + const responseMaxChars = requestedMaxBytes == null + ? CHAT_EVENT_HISTORY_RESPONSE_MAX_CHARS + : Math.max(1_024, Math.min(CHAT_EVENT_HISTORY_RESPONSE_MAX_CHARS, requestedMaxBytes)); // Stat the transcript on every snapshot; actual I/O is skipped when the // file size and mtime are unchanged (cached). A long-running background @@ -7282,7 +7299,9 @@ export function createAgentChatService(args: { // trimmed events sit AFTER tailStartOffset and are not reachable through // getChatEventHistoryPage (which pages strictly older) — an accepted // seam, the alternative being a response the client must discard. - const windowed = trimEnvelopesToByteBudget(countWindowed, CHAT_EVENT_HISTORY_RESPONSE_MAX_CHARS); + const windowed = trimEnvelopesToByteBudget(countWindowed, responseMaxChars, { + keepOversizeNewest: requestedMaxBytes == null, + }); const windowTruncated = mergedLengthBeforeResponseCap > CHAT_EVENT_HISTORY_RESPONSE_MAX_PER_SESSION || parentVisibleLength > maxEvents diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.ts b/apps/desktop/src/main/services/github/githubRelayConfig.ts index 5effc5d88..1029c2c0e 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -5,6 +5,7 @@ export const ADE_GITHUB_APP_DISPLAY_NAME = "ADE"; export const ADE_GITHUB_APP_SLUG = "ade-for-github"; export const ADE_GITHUB_APP_INSTALL_URL = `https://github.com/apps/${ADE_GITHUB_APP_SLUG}/installations/new`; export const GITHUB_APP_INSTALLATIONS_URL = "https://github.com/settings/installations"; +export const DEFAULT_GITHUB_RELAY_API_BASE_URL = "https://ade-github-webhook-relay.arulsharma1028.workers.dev"; export const GITHUB_RELAY_API_BASE_REF = "automations.githubRelay.apiBaseUrl"; export const GITHUB_RELAY_PROJECT_REF = "automations.githubRelay.remoteProjectId"; @@ -21,8 +22,8 @@ export type GitHubRelayConfig = { apiBaseUrl: string | null; remoteProjectId: string | null; accessToken: string | null; + usesHostedDefault: boolean; configured: boolean; - repoStatusConfigured: boolean; }; function firstEnvValue(keys: readonly string[]): string | null { @@ -39,9 +40,10 @@ function readSecret(reader: GitHubRelaySecretReader | null | undefined, ref: str } export function readGitHubRelayConfig(secretReader?: GitHubRelaySecretReader | null): GitHubRelayConfig { - const apiBaseUrl = + const configuredApiBaseUrl = readSecret(secretReader, GITHUB_RELAY_API_BASE_REF) || firstEnvValue(GITHUB_RELAY_API_BASE_ENV_KEYS); + const apiBaseUrl = configuredApiBaseUrl || DEFAULT_GITHUB_RELAY_API_BASE_URL; const remoteProjectId = readSecret(secretReader, GITHUB_RELAY_PROJECT_REF) || firstEnvValue(GITHUB_RELAY_PROJECT_ENV_KEYS); @@ -52,8 +54,8 @@ export function readGitHubRelayConfig(secretReader?: GitHubRelaySecretReader | n apiBaseUrl, remoteProjectId, accessToken, - configured: Boolean(apiBaseUrl && remoteProjectId && accessToken), - repoStatusConfigured: Boolean(apiBaseUrl && remoteProjectId && accessToken), + usesHostedDefault: !configuredApiBaseUrl, + configured: Boolean(apiBaseUrl && ((remoteProjectId && accessToken) || apiBaseUrl === DEFAULT_GITHUB_RELAY_API_BASE_URL)), }; } @@ -71,6 +73,13 @@ export function gitHubRelayAuthorizationToken(config: GitHubRelayConfig): string return deriveGitHubRelayProjectToken(config.accessToken, config.remoteProjectId); } +export function shouldUseLegacyGitHubRelayProjectRoute( + config: GitHubRelayConfig, +): boolean { + if (!config.remoteProjectId || !config.accessToken) return false; + return !config.usesHostedDefault; +} + function baseStatus(repo: GitHubRepoRef | null, patch: Partial): GitHubAppInstallationStatus { return { repo, @@ -147,6 +156,7 @@ export async function fetchGitHubAppInstallationStatus(args: { secretReader?: GitHubRelaySecretReader | null; fetchImpl?: typeof fetch; forceRefresh?: boolean; + githubToken?: string | null; }): Promise { const config = readGitHubRelayConfig(args.secretReader); if (!args.repo) { @@ -156,7 +166,7 @@ export async function fetchGitHubAppInstallationStatus(args: { error: "No GitHub repository was detected for this project.", }); } - if (!config.repoStatusConfigured) { + if (!config.apiBaseUrl) { return baseStatus(args.repo, { relayConfigured: false, state: "unconfigured", @@ -166,14 +176,18 @@ export async function fetchGitHubAppInstallationStatus(args: { try { const baseUrl = config.apiBaseUrl!.replace(/\/+$/, ""); - const projectId = encodeURIComponent(config.remoteProjectId!); - const url = `${baseUrl}/projects/${projectId}/github/repos/${encodeURIComponent(args.repo.owner)}/${encodeURIComponent(args.repo.name)}/status${args.forceRefresh ? "?refresh=1" : ""}`; - const authToken = gitHubRelayAuthorizationToken(config); + const githubToken = args.githubToken?.trim(); + const legacyAuthToken = gitHubRelayAuthorizationToken(config); + const useLegacyProjectRoute = shouldUseLegacyGitHubRelayProjectRoute(config); + const url = useLegacyProjectRoute + ? `${baseUrl}/projects/${encodeURIComponent(config.remoteProjectId!)}/github/repos/${encodeURIComponent(args.repo.owner)}/${encodeURIComponent(args.repo.name)}/status${args.forceRefresh ? "?refresh=1" : ""}` + : `${baseUrl}/github/repos/${encodeURIComponent(args.repo.owner)}/${encodeURIComponent(args.repo.name)}/status${args.forceRefresh ? "?refresh=1" : ""}`; + const authToken = useLegacyProjectRoute ? legacyAuthToken : githubToken; if (!authToken) { return baseStatus(args.repo, { relayConfigured: true, state: "error", - error: "GitHub App relay token is not configured for this ADE runtime.", + error: "GitHub auth is required to check the ADE GitHub App installation.", }); } const response = await (args.fetchImpl ?? fetch)(url, { diff --git a/apps/desktop/src/main/services/github/githubService.test.ts b/apps/desktop/src/main/services/github/githubService.test.ts index 9c0499ec2..e57371128 100644 --- a/apps/desktop/src/main/services/github/githubService.test.ts +++ b/apps/desktop/src/main/services/github/githubService.test.ts @@ -69,6 +69,8 @@ function resetMocks() { mockFetch.mockReset(); runGitMock.mockReset(); delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + delete process.env.ADE_GITHUB_TOKEN; delete process.env.ADE_GITHUB_RELAY_API_BASE_URL; delete process.env.ADE_GITHUB_RELAY_ACCESS_TOKEN; delete process.env.ADE_GITHUB_RELAY_REMOTE_PROJECT_ID; @@ -1312,19 +1314,53 @@ describe("githubService.getAppInstallationStatus", () => { resetMocks(); }); - it("returns an unconfigured status without calling the relay when relay config is missing", async () => { + it("reports that GitHub auth is required before checking the hosted relay", async () => { const status = await makeService().getAppInstallationStatus({ owner: "acme", name: "repo" }); expect(status).toMatchObject({ repo: { owner: "acme", name: "repo" }, - relayConfigured: false, + relayConfigured: true, installed: false, - state: "unconfigured", + state: "error", + error: "GitHub auth is required to check the ADE GitHub App installation.", }); expect(mockFetch).not.toHaveBeenCalled(); }); - it("checks the relay for a repo-scoped GitHub App installation", async () => { + it("checks the hosted relay with the user's existing GitHub token", async () => { + process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; + mockFetch.mockResolvedValueOnce(jsonResponse(200, { + installed: true, + state: "configured", + installationId: 123, + repositorySelection: "selected", + lastSeenAt: "2026-06-30T00:00:00.000Z", + checkedAt: "2026-06-30T00:00:01.000Z", + })); + + const status = await makeService().getAppInstallationStatus({ owner: "acme", name: "repo" }); + + expect(status).toMatchObject({ + repo: { owner: "acme", name: "repo" }, + relayConfigured: true, + installed: true, + state: "configured", + installationId: 123, + repositorySelection: "selected", + }); + expect(mockFetch).toHaveBeenCalledWith( + "https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/acme/repo/status", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + authorization: "Bearer ghp_user_token", + }), + }), + ); + }); + + it("keeps supporting legacy project-token relay installation checks", async () => { + process.env.ADE_GITHUB_TOKEN = "ghp_user_token"; mockFetch.mockResolvedValueOnce(jsonResponse(200, { installed: true, state: "configured", @@ -1364,7 +1400,7 @@ describe("githubService.getAppInstallationStatus", () => { ); }); - it("asks the relay for a live GitHub App status refresh when forced", async () => { + it("asks the legacy relay for a live GitHub App status refresh when forced", async () => { mockFetch.mockResolvedValueOnce(jsonResponse(200, { installed: false, state: "not_installed", diff --git a/apps/desktop/src/main/services/github/githubService.ts b/apps/desktop/src/main/services/github/githubService.ts index 5079b7a26..657726291 100644 --- a/apps/desktop/src/main/services/github/githubService.ts +++ b/apps/desktop/src/main/services/github/githubService.ts @@ -1052,6 +1052,7 @@ export function createGithubService({ repo, secretReader: githubRelaySecretReader, forceRefresh: args.forceRefresh === true, + githubToken: readAuthToken().token, }); }; diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index b4dea9c04..593c346a4 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -218,6 +218,11 @@ function createStubFileService(workspaceRoot: string) { fs.mkdirSync(path.dirname(absolute), { recursive: true }); fs.writeFileSync(absolute, text, "utf8"); }, + writeTextAtomic: ({ relPath, text }: { laneId: string; relPath: string; text: string }) => { + const absolute = resolveWorkspacePath(relPath); + fs.mkdirSync(path.dirname(absolute), { recursive: true }); + fs.writeFileSync(absolute, text, "utf8"); + }, createFile: ({ path: relPath, content }: { path: string; content?: string }) => { const absolute = resolveWorkspacePath(relPath); fs.mkdirSync(path.dirname(absolute), { recursive: true }); @@ -270,6 +275,14 @@ function createStubChatService() { deleteSession: vi.fn().mockResolvedValue(undefined), listSessions: vi.fn().mockResolvedValue([]), getSessionSummary: vi.fn().mockResolvedValue(null), + getChatEventHistory: vi.fn((sessionId: string) => ({ + sessionId, + events: [], + truncated: false, + transcriptTruncated: false, + windowTruncated: false, + sessionFound: true, + })), getChatTranscript: vi.fn().mockResolvedValue([]), createSession: vi.fn().mockResolvedValue(baseSession), getAvailableModels: vi.fn().mockResolvedValue([]), @@ -1790,9 +1803,9 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { const mobileWriteResponse = await phoneClient.queue.next("file_response"); const mobileWritePayload = mobileWriteResponse.payload as { ok: boolean; error?: { message: string } }; expect(mobileWriteResponse.requestId).toBe("mobile-write-text"); - expect(mobileWritePayload.ok).toBe(false); - expect(mobileWritePayload.error?.message).toMatch(/read-only/i); - expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("updated"); + expect(mobileWritePayload.ok).toBe(true); + expect(mobileWritePayload.error).toBeUndefined(); + expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("mobile update"); const atomicWrite = await sendCommand(phoneClient.ws, phoneClient.queue, { commandId: "mobile-atomic-write", @@ -1805,12 +1818,11 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { }); const atomicAckPayload = atomicWrite.ack.payload as { accepted: boolean; status: string }; const atomicResultPayload = atomicWrite.result.payload as { ok: boolean; error?: { code: string; message: string } }; - expect(atomicAckPayload.accepted).toBe(false); - expect(atomicAckPayload.status).toBe("rejected"); - expect(atomicResultPayload.ok).toBe(false); - expect(atomicResultPayload.error?.code).toBe("mobile_read_only"); - expect(atomicResultPayload.error?.message).toMatch(/read-only/i); - expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("updated"); + expect(atomicAckPayload.accepted).toBe(true); + expect(atomicAckPayload.status).toBe("accepted"); + expect(atomicResultPayload.ok).toBe(true); + expect(atomicResultPayload.error).toBeUndefined(); + expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("mobile atomic update"); client.ws.send(encodeSyncEnvelope({ type: "file_request", @@ -2752,7 +2764,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { expect(chatService.service.deleteSession).toHaveBeenCalledWith({ sessionId: "session-1" }); }, 15_000); - it("pairs a phone peer and keeps paired reconnects read-only even if hello metadata is spoofed", async () => { + it("pairs a phone peer and preserves paired reconnect identity even if hello metadata is spoofed", async () => { const brainDb = await openKvDb(makeDbPath("ade-sync-pairing-"), createLogger() as any); const projectRoot = makeProjectRoot("ade-sync-pairing-project-"); const workspaceRoot = path.join(projectRoot, "workspace"); @@ -2949,9 +2961,9 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { })); const spoofedWriteResponse = await authQueue.next("file_response"); const spoofedWritePayload = spoofedWriteResponse.payload as { ok: boolean; error?: { message: string } }; - expect(spoofedWritePayload.ok).toBe(false); - expect(spoofedWritePayload.error?.message).toMatch(/read-only/i); - expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("original"); + expect(spoofedWritePayload.ok).toBe(true); + expect(spoofedWritePayload.error).toBeUndefined(); + expect(fs.readFileSync(path.join(workspaceRoot, "notes.txt"), "utf8")).toBe("spoofed update"); host.revokePairedDevice("ios-phone-1"); if (authWs.readyState !== WebSocket.CLOSED) { diff --git a/apps/desktop/src/main/services/sync/syncService.test.ts b/apps/desktop/src/main/services/sync/syncService.test.ts index da8114662..92a61ac43 100644 --- a/apps/desktop/src/main/services/sync/syncService.test.ts +++ b/apps/desktop/src/main/services/sync/syncService.test.ts @@ -256,6 +256,27 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { status: "running", toolType: "codex-chat", }, + { + id: "chat-idle", + laneId: "lane-1", + title: "Idle worker chat", + status: "running", + toolType: "cursor", + }, + { + id: "chat-droid", + laneId: "lane-1", + title: "Droid worker chat", + status: "running", + toolType: "droid-chat", + }, + { + id: "chat-orphan", + laneId: "lane-1", + title: "Orphaned worker chat", + status: "running", + toolType: "cursor", + }, { id: "term-1", laneId: "lane-1", @@ -276,18 +297,24 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { sessionId: "chat-1", title: "CTO delegation thread", identityKey: "cto", - status: "idle", + status: "active", }, { - sessionId: "chat-2", + sessionId: "chat-idle", title: "Idle worker chat", identityKey: "agent:worker-1", status: "idle", }, + { + sessionId: "chat-droid", + title: "Droid worker chat", + identityKey: "agent:worker-2", + status: "active", + }, { sessionId: "chat-3", title: "Finished worker chat", - identityKey: "agent:worker-2", + identityKey: "agent:worker-3", status: "ended", }, ], @@ -315,6 +342,16 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { id: "chat-1", label: "CTO delegation thread", }), + expect.objectContaining({ + kind: "chat_runtime", + id: "chat-droid", + label: "Droid worker chat", + }), + expect.objectContaining({ + kind: "chat_runtime", + id: "chat-orphan", + label: "Orphaned worker chat", + }), expect.objectContaining({ kind: "terminal_session", id: "term-1" }), expect.objectContaining({ kind: "managed_process", @@ -322,6 +359,11 @@ describe.skipIf(!isCrsqliteAvailable())("syncService", () => { }), ]), ); + expect(readiness.blockers).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "chat-idle" }), + ]), + ); expect(readiness.survivableState).toEqual( expect.arrayContaining([ "CTO history and idle threads remain available on the new host.", diff --git a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx index 622203a64..962662ce4 100644 --- a/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx +++ b/apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx @@ -138,25 +138,25 @@ function statusView(status: GitHubAppInstallationStatus | null, loading: boolean color: COLORS.success, description: (repoLabel) => repoLabel - ? `The ADE GitHub App is installed for ${repoLabel}. PR updates can use the webhook relay with GitHub auth as fallback.` - : "The ADE GitHub App is installed. PR updates can use the webhook relay with GitHub auth as fallback.", + ? `The ADE GitHub App is installed for ${repoLabel}. PR updates can arrive instantly, with GitHub polling as fallback.` + : "The ADE GitHub App is installed. PR updates can arrive instantly, with GitHub polling as fallback.", }; } if (status?.installed && !status.relayConfigured) { return { - label: "Relay off", + label: "Installed", color: COLORS.warning, description: (repoLabel) => repoLabel - ? `The ADE GitHub App is installed for ${repoLabel}, but this runtime is missing relay polling config. Existing GitHub auth remains the fallback.` - : "The ADE GitHub App is installed, but this runtime is missing relay polling config. Existing GitHub auth remains the fallback.", + ? `The ADE GitHub App is installed for ${repoLabel}. ADE will use GitHub polling until realtime delivery is available.` + : "The ADE GitHub App is installed. ADE will use GitHub polling until realtime delivery is available.", }; } if (status?.state === "unconfigured" || (status && !status.relayConfigured && status.state !== "error")) { return { - label: "Relay off", + label: "Checking", color: COLORS.warning, - description: () => "The GitHub App relay is not configured in this runtime yet. ADE will keep using the existing GitHub auth path.", + description: () => "ADE could not confirm realtime delivery yet. GitHub polling remains available as fallback.", }; } if (status?.state === "error") { @@ -171,8 +171,8 @@ function statusView(status: GitHubAppInstallationStatus | null, loading: boolean color: COLORS.warning, description: (repoLabel) => repoLabel - ? `Install the ADE GitHub App for ${repoLabel} to enable instant PR updates. If you just installed it, change the repository selection once in GitHub or check Recent deliveries. Existing GitHub auth remains the fallback.` - : "Install the ADE GitHub App for instant PR updates. Existing GitHub auth remains the fallback.", + ? `Install the ADE GitHub App for ${repoLabel} to enable instant PR updates. If the App is installed for selected repositories, make sure this repo is selected.` + : "Install the ADE GitHub App for instant PR updates. If the App is installed for selected repositories, make sure this repo is selected.", }; } diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index c8437a1f8..cc075b9a3 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -89,6 +89,13 @@ struct ContentView: View { selectedTab = .lanes } } + .onChange(of: syncService.requestedWorkLaneNavigation?.id) { _, requestId in + guard requestId != nil else { return } + syncService.closeProjectHome() + if selectedTab != .work { + selectedTab = .work + } + } .onChange(of: syncService.requestedPrNavigation?.id) { _, requestId in guard requestId != nil else { return } syncService.closeProjectHome() diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index d321395d7..6f6997555 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2665,11 +2665,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { var branchRef: String? = nil var rootPath: String var isReadOnlyByDefault: Bool - var mobileReadOnly: Bool - - var readOnlyOnMobile: Bool { - mobileReadOnly || isReadOnlyByDefault - } init( id: String, @@ -2678,8 +2673,7 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { name: String, branchRef: String? = nil, rootPath: String, - isReadOnlyByDefault: Bool, - mobileReadOnly: Bool = true + isReadOnlyByDefault: Bool ) { self.id = id self.kind = kind @@ -2688,7 +2682,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { self.branchRef = branchRef self.rootPath = rootPath self.isReadOnlyByDefault = isReadOnlyByDefault - self.mobileReadOnly = mobileReadOnly } private enum CodingKeys: String, CodingKey { @@ -2699,7 +2692,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { case branchRef case rootPath case isReadOnlyByDefault - case mobileReadOnly } init(from decoder: Decoder) throws { @@ -2711,7 +2703,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { branchRef = try container.decodeIfPresent(String.self, forKey: .branchRef) rootPath = try container.decode(String.self, forKey: .rootPath) isReadOnlyByDefault = try container.decode(Bool.self, forKey: .isReadOnlyByDefault) - mobileReadOnly = try container.decodeIfPresent(Bool.self, forKey: .mobileReadOnly) ?? true } } diff --git a/apps/ios/ADE/Models/RemoteRosterModels.swift b/apps/ios/ADE/Models/RemoteRosterModels.swift index 9ce242008..77bd8b18d 100644 --- a/apps/ios/ADE/Models/RemoteRosterModels.swift +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -101,7 +101,10 @@ func rosterApplyDelta( guard let currentSeq else { return .needsSnapshot } if delta.seq <= currentSeq { return .dropped } if delta.seq > currentSeq + 1 { return .needsSnapshot } - var byId = Dictionary(uniqueKeysWithValues: current.map { ($0.projectId, $0) }) + var byId = [String: RemoteRosterProject]() + for project in current { + byId[project.projectId] = project + } for projectId in delta.removed ?? [] { byId.removeValue(forKey: projectId) } for project in delta.changed ?? [] { byId[project.projectId] = project } return .applied(projects: Array(byId.values), seq: delta.seq) diff --git a/apps/ios/ADE/Resources/DatabaseBootstrap.sql b/apps/ios/ADE/Resources/DatabaseBootstrap.sql index fa35eb191..f42cdff91 100644 --- a/apps/ios/ADE/Resources/DatabaseBootstrap.sql +++ b/apps/ios/ADE/Resources/DatabaseBootstrap.sql @@ -586,7 +586,6 @@ create table if not exists files_workspaces ( name text not null, root_path text not null, is_read_only_by_default integer not null default 1, - mobile_read_only integer not null default 1, updated_at text not null ); diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 324a1f798..548247c3d 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -1332,7 +1332,7 @@ final class DatabaseService { if tableExists("files_workspaces") { let cached = query( """ - select id, kind, lane_id, name, root_path, is_read_only_by_default, mobile_read_only + select id, kind, lane_id, name, root_path, is_read_only_by_default from files_workspaces order by case when kind = 'primary' then 0 else 1 end, name collate nocase asc """ @@ -1343,8 +1343,7 @@ final class DatabaseService { laneId: stringValue(statement, index: 2), name: stringValue(statement, index: 3) ?? "", rootPath: stringValue(statement, index: 4) ?? "", - isReadOnlyByDefault: sqlite3_column_int(statement, 5) == 1, - mobileReadOnly: sqlite3_column_int(statement, 6) != 0 + isReadOnlyByDefault: sqlite3_column_int(statement, 5) == 1 ) } let scoped = cached.filter { workspace in @@ -1367,8 +1366,7 @@ final class DatabaseService { laneId: lane.id, name: lane.name, rootPath: lane.attachedRootPath ?? lane.worktreePath, - isReadOnlyByDefault: lane.isEditProtected, - mobileReadOnly: true + isReadOnlyByDefault: lane.isEditProtected ) } } @@ -1438,15 +1436,14 @@ final class DatabaseService { _ = try execute( """ insert into files_workspaces( - id, kind, lane_id, name, root_path, is_read_only_by_default, mobile_read_only, updated_at - ) values (?, ?, ?, ?, ?, ?, ?, ?) + id, kind, lane_id, name, root_path, is_read_only_by_default, updated_at + ) values (?, ?, ?, ?, ?, ?, ?) on conflict(id) do update set kind = excluded.kind, lane_id = excluded.lane_id, name = excluded.name, root_path = excluded.root_path, is_read_only_by_default = excluded.is_read_only_by_default, - mobile_read_only = excluded.mobile_read_only, updated_at = excluded.updated_at """ ) { statement in @@ -1460,8 +1457,7 @@ final class DatabaseService { try bindText(workspace.name, to: statement, index: 4) try bindText(workspace.rootPath, to: statement, index: 5) sqlite3_bind_int(statement, 6, workspace.isReadOnlyByDefault ? 1 : 0) - sqlite3_bind_int(statement, 7, workspace.mobileReadOnly ? 1 : 0) - try bindText(timestamp, to: statement, index: 8) + try bindText(timestamp, to: statement, index: 7) } } try exec("commit") @@ -1694,6 +1690,7 @@ final class DatabaseService { private func fetchSessionLocked(id sessionId: String) -> TerminalSessionSummary? { let trimmedId = sessionId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedId.isEmpty else { return nil } + guard let projectId = currentProjectIdLocked() else { return nil } let sql = """ select s.id, s.lane_id, coalesce(nullif(s.lane_name, ''), l.name, s.lane_id), s.pty_id, s.tracked, s.pinned, s.manually_named, s.goal, s.tool_type, s.title, s.status, s.started_at, s.ended_at, s.exit_code, s.transcript_path, @@ -1701,11 +1698,12 @@ final class DatabaseService { s.resume_command, s.resume_metadata_json, s.chat_idle_since_at, s.chat_session_id, s.pending_input_item_id, s.archived_at from terminal_sessions s left join lanes l on l.id = s.lane_id - where s.id = ? + where s.id = ? and (l.project_id = ? or l.id is null) limit 1 """ return query(sql, bind: { [self] statement in try self.bindText(trimmedId, to: statement, index: 1) + try self.bindText(projectId, to: statement, index: 2) }) { statement in terminalSessionSummary(from: sessionRow(from: statement)) }.first @@ -3546,8 +3544,8 @@ final class DatabaseService { case .string(let stringValue): try bindText(stringValue, to: statement, index: index) case .number(let numberValue): - if numberValue.rounded(.towardZero) == numberValue { - sqlite3_bind_int64(statement, index, sqlite3_int64(numberValue)) + if let integerValue = safeInt64Value(from: numberValue) { + sqlite3_bind_int64(statement, index, sqlite3_int64(integerValue)) } else { sqlite3_bind_double(statement, index, numberValue) } @@ -3564,6 +3562,16 @@ final class DatabaseService { } } + private func safeInt64Value(from numberValue: Double) -> Int64? { + guard numberValue.isFinite, + numberValue.rounded(.towardZero) == numberValue, + numberValue >= Double(Int64.min), + numberValue < Double(Int64.max) else { + return nil + } + return Int64(numberValue) + } + private func scalarValue(_ statement: OpaquePointer, index: Int32) -> SyncScalarValue { switch sqlite3_column_type(statement, index) { case SQLITE_INTEGER: @@ -4178,9 +4186,7 @@ final class DatabaseService { bytes.append(UInt8(utf8.count)) bytes.append(contentsOf: utf8) case .number(let numberValue): - guard numberValue.rounded(.towardZero) == numberValue else { return nil } - guard numberValue >= Double(Int64.min), numberValue <= Double(Int64.max) else { return nil } - let integer = Int64(numberValue) + guard let integer = safeInt64Value(from: numberValue) else { return nil } if integer == 0 { bytes.append(0x08) } else if integer == 1 { diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 2458f5895..117eed9c3 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -279,6 +279,7 @@ private let syncChatHistoryTailPageMaxBytes = 600_000 private let syncReducedLoadChatSubscriptionMaxBytes = 512_000 private let syncTerminalBufferMaxCharacters = 240_000 private let chatEventHistoryMaxEvents = 1_000 +private let chatEventHistoryMaxSessions = 64 /// Coalescing window for chat-event UI notifications. The coalescer fires on /// the leading edge (first event after a quiet period surfaces immediately) /// and then at most once per window during a sustained burst. Was a 420 ms @@ -416,7 +417,7 @@ func syncConnectPortCandidates( .filter { seen.insert($0).inserted } } -struct SyncConnectionEndpointAttempt: Equatable { +struct SyncConnectionEndpointAttempt: Equatable, Hashable { var address: String var port: Int } @@ -437,6 +438,23 @@ func syncConnectionEndpointAttempts( return primaryAttempts + fallbackAttempts } +func syncStalePortRecoveryEndpointAttempts( + addresses: [String], + ports: [Int] +) -> [SyncConnectionEndpointAttempt] { + guard !addresses.isEmpty, !ports.isEmpty else { return [] } + let priorityAddresses = Array(addresses.prefix(2)) + let fallbackAddresses = Array(addresses.dropFirst(2)) + let prioritySweep = priorityAddresses.flatMap { address in + ports.map { port in SyncConnectionEndpointAttempt(address: address, port: port) } + } + let fallbackSweep = syncConnectionEndpointAttempts(addresses: fallbackAddresses, ports: ports) + var seen = Set() + return (prioritySweep + fallbackSweep).filter { attempt in + seen.insert(attempt).inserted + } +} + func syncWebSocketURLString(host rawHost: String, port defaultPort: Int) -> String? { guard let endpoint = syncParseRouteEndpoint(rawHost) else { return nil } let port = endpoint.port ?? defaultPort @@ -492,6 +510,7 @@ struct SyncPreprocessedEnvelope { } private let maxUncompressedSyncEnvelopeBytes = 25 * 1024 * 1024 +private let maxChunkedSyncEnvelopeBytes = 32 * 1024 * 1024 func syncPreprocessIncoming( _ text: String, @@ -580,18 +599,38 @@ func syncRaceAddressCandidates( /// in `index` order into the original envelope JSON. Bounded so a broken host /// cannot grow the buffer without limit. struct SyncEnvelopeChunkAssembler { + private struct Part { + let data: Data + let encodedBytes: Int + } + private struct PartialChunk { let total: Int - var parts: [Int: String] = [:] + var parts: [Int: Part] = [:] + var decodedBytes = 0 + var encodedBytes = 0 } private var buffers: [String: PartialChunk] = [:] private var arrivalOrder: [String] = [] private let maxConcurrentChunks = 8 private let maxTotalParts = 512 + private let maxEnvelopeBytes: Int + + init(maxEnvelopeBytes: Int = maxChunkedSyncEnvelopeBytes) { + self.maxEnvelopeBytes = max(1, maxEnvelopeBytes) + } mutating func add(chunkId: String, index: Int, total: Int, part: String) -> String? { guard total > 0, index >= 0, index < total, total <= maxTotalParts, !chunkId.isEmpty else { return nil } + let encodedBytes = part.utf8.count + let decodedUpperBound = ((encodedBytes + 3) / 4) * 3 + guard decodedUpperBound <= maxEnvelopeBytes, + let decodedPart = Data(base64Encoded: part) else { + buffers.removeValue(forKey: chunkId) + arrivalOrder.removeAll { $0 == chunkId } + return nil + } if buffers[chunkId] == nil { while arrivalOrder.count >= maxConcurrentChunks, let oldest = arrivalOrder.first { arrivalOrder.removeFirst() @@ -605,7 +644,20 @@ struct SyncEnvelopeChunkAssembler { arrivalOrder.removeAll { $0 == chunkId } return nil } - buffer.parts[index] = part + if let existing = buffer.parts[index] { + buffer.decodedBytes -= existing.data.count + buffer.encodedBytes -= existing.encodedBytes + } + let nextDecodedBytes = buffer.decodedBytes + decodedPart.count + let nextEncodedBytes = buffer.encodedBytes + encodedBytes + guard nextDecodedBytes <= maxEnvelopeBytes, nextEncodedBytes <= maxEnvelopeBytes * 2 else { + buffers.removeValue(forKey: chunkId) + arrivalOrder.removeAll { $0 == chunkId } + return nil + } + buffer.parts[index] = Part(data: decodedPart, encodedBytes: encodedBytes) + buffer.decodedBytes = nextDecodedBytes + buffer.encodedBytes = nextEncodedBytes guard buffer.parts.count == buffer.total else { buffers[chunkId] = buffer return nil @@ -613,9 +665,10 @@ struct SyncEnvelopeChunkAssembler { buffers.removeValue(forKey: chunkId) arrivalOrder.removeAll { $0 == chunkId } var data = Data() + data.reserveCapacity(buffer.decodedBytes) for partIndex in 0..= Self.prDetailCacheMaxEntries, + let oldest = prDetailCache.min(by: { $0.value.loadedAt < $1.value.loadedAt })?.key { + prDetailCache.removeValue(forKey: oldest) + } prDetailCache[prId] = entry } @@ -4330,14 +4404,10 @@ final class SyncService: ObservableObject { } } - private func ensureMobileFileMutationsAllowed(workspaceId: String) throws { - let workspace = database.listWorkspaces().first { $0.id == workspaceId } - guard let workspace else { + private func ensureFilesWorkspaceAvailable(workspaceId: String) throws { + guard database.listWorkspaces().contains(where: { $0.id == workspaceId }) else { throw NSError(domain: "ADE", code: 118, userInfo: [NSLocalizedDescriptionKey: "The selected Files workspace is no longer available on this phone."]) } - guard !workspace.readOnlyOnMobile else { - throw NSError(domain: "ADE", code: 119, userInfo: [NSLocalizedDescriptionKey: "Files stays read-only on iPhone for this workspace."]) - } } private func shouldUseCachedFileSnapshot(for error: Error) -> Bool { @@ -4386,7 +4456,7 @@ final class SyncService: ObservableObject { } func writeText(workspaceId: String, path: String, text: String) async throws { - try ensureMobileFileMutationsAllowed(workspaceId: workspaceId) + try ensureFilesWorkspaceAvailable(workspaceId: workspaceId) _ = try await sendFileRequest(action: "writeText", args: [ "workspaceId": workspaceId, "path": path, @@ -4395,7 +4465,7 @@ final class SyncService: ObservableObject { } func createFile(workspaceId: String, path: String, content: String = "") async throws { - try ensureMobileFileMutationsAllowed(workspaceId: workspaceId) + try ensureFilesWorkspaceAvailable(workspaceId: workspaceId) _ = try await sendFileRequest(action: "createFile", args: [ "workspaceId": workspaceId, "path": path, @@ -4404,7 +4474,7 @@ final class SyncService: ObservableObject { } func createDirectory(workspaceId: String, path: String) async throws { - try ensureMobileFileMutationsAllowed(workspaceId: workspaceId) + try ensureFilesWorkspaceAvailable(workspaceId: workspaceId) _ = try await sendFileRequest(action: "createDirectory", args: [ "workspaceId": workspaceId, "path": path, @@ -4412,7 +4482,7 @@ final class SyncService: ObservableObject { } func renamePath(workspaceId: String, oldPath: String, newPath: String) async throws { - try ensureMobileFileMutationsAllowed(workspaceId: workspaceId) + try ensureFilesWorkspaceAvailable(workspaceId: workspaceId) _ = try await sendFileRequest(action: "rename", args: [ "workspaceId": workspaceId, "oldPath": oldPath, @@ -4421,7 +4491,7 @@ final class SyncService: ObservableObject { } func deletePath(workspaceId: String, path: String) async throws { - try ensureMobileFileMutationsAllowed(workspaceId: workspaceId) + try ensureFilesWorkspaceAvailable(workspaceId: workspaceId) _ = try await sendFileRequest(action: "deletePath", args: [ "workspaceId": workspaceId, "path": path, @@ -5393,9 +5463,16 @@ final class SyncService: ObservableObject { @MainActor func getChatModelCatalog( mode: String = "refresh-stale", - refreshProvider: String? = nil + refreshProvider: String? = nil, + cursorSource: String? = nil ) async throws -> AgentChatModelCatalog { - let cacheKey = chatModelsCacheKey(provider: "catalog:\(mode):\(refreshProvider ?? "")") + let normalizedCursorSource = cursorSource? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + let cursorSourceKeySuffix = normalizedCursorSource.map { ":\($0)" } ?? "" + let cacheKey = chatModelsCacheKey( + provider: "catalog:\(mode):\(refreshProvider ?? "")\(cursorSourceKeySuffix)" + ) let now = Date() if mode != "force", @@ -5424,6 +5501,9 @@ final class SyncService: ObservableObject { if let refreshProvider { args["refreshProvider"] = refreshProvider } + if let normalizedCursorSource, !normalizedCursorSource.isEmpty { + args["cursorSource"] = normalizedCursorSource + } let response = try await self.sendCommand( action: "chat.modelCatalog", args: args, @@ -5439,7 +5519,9 @@ final class SyncService: ObservableObject { let catalog = try await task.value chatModelCatalogCache[cacheKey] = ChatModelCatalogCacheEntry(catalog: catalog, fetchedAt: now) if mode == "force", let refreshProvider { - let refreshStaleKey = chatModelsCacheKey(provider: "catalog:refresh-stale:\(refreshProvider)") + let refreshStaleKey = chatModelsCacheKey( + provider: "catalog:refresh-stale:\(refreshProvider)\(cursorSourceKeySuffix)" + ) chatModelCatalogCache[refreshStaleKey] = ChatModelCatalogCacheEntry(catalog: catalog, fetchedAt: now) } chatModelCatalogInFlight[cacheKey] = nil @@ -6842,6 +6924,19 @@ final class SyncService: ObservableObject { return connectAttemptGeneration } + private func markReconnectConnectInFlight(_ generation: UInt64) { + reconnectConnectInFlight = true + reconnectConnectInFlightGeneration = generation + } + + private func clearReconnectConnectInFlight(_ generation: UInt64? = nil) { + if let generation, reconnectConnectInFlightGeneration != generation { + return + } + reconnectConnectInFlight = false + reconnectConnectInFlightGeneration = nil + } + private func isCurrentConnectAttempt(_ generation: UInt64) -> Bool { connectAttemptGeneration == generation } @@ -7202,14 +7297,22 @@ final class SyncService: ObservableObject { publishConnecting: Bool ) async throws -> (host: String, port: Int) { var lastFailure: Error? + let matchingDiscovery = discoveredHosts.filter { host in + matchesDiscoveredHost(host, profile: profile) + } + var seenLivePorts = Set() + let livePorts = matchingDiscovery + .map(\.port) + .filter { $0 > 0 && seenLivePorts.insert($0).inserted } + let primaryPort = livePorts.first ?? profile.port let rawAddresses = preferLiveCandidatesOnly ? automaticReconnectAddresses(for: profile) : prioritizedAddresses(for: profile) let addresses = connectableAddresses(from: rawAddresses) let portCandidates = syncConnectPortCandidates( - primaryPort: profile.port, + primaryPort: primaryPort, addresses: addresses, - allowFallbackSweep: !preferLiveCandidatesOnly + allowFallbackSweep: !preferLiveCandidatesOnly || livePorts.isEmpty ) syncConnectLog.info( "ADE_SYNC_TRACE reconnect candidates preferLiveOnly=\(preferLiveCandidatesOnly) path=\(syncLogPathSummary(self.lastNetworkPathSnapshot), privacy: .public) profile=\(syncLogProfileSummary(profile), privacy: .public) raw=[\(syncLogAddressList(rawAddresses), privacy: .public)] ports=[\(portCandidates.map(String.init).joined(separator: ","), privacy: .public)] connectable=[\(syncLogAddressList(addresses), privacy: .public)]" @@ -7231,7 +7334,11 @@ final class SyncService: ObservableObject { ) } - for attempt in syncConnectionEndpointAttempts(addresses: racedAddresses, ports: portCandidates) { + let endpointAttempts = preferLiveCandidatesOnly && livePorts.isEmpty && portCandidates.count > 1 + ? syncStalePortRecoveryEndpointAttempts(addresses: racedAddresses, ports: portCandidates) + : syncConnectionEndpointAttempts(addresses: racedAddresses, ports: portCandidates) + + for attempt in endpointAttempts { guard isCurrentConnectAttempt(connectAttemptGeneration) else { throw CancellationError() } @@ -7357,7 +7464,7 @@ final class SyncService: ObservableObject { reconnectTask = nil networkPathReconnectTask?.cancel() networkPathReconnectTask = nil - reconnectConnectInFlight = false + clearReconnectConnectInFlight() autoReconnectAwaitingLiveDiscovery = false let machineName = syncTrimmedNonEmptyName(profile?.hostName) ?? syncTrimmedNonEmptyName(hostName) @@ -9295,20 +9402,28 @@ final class SyncService: ObservableObject { } func recordChatEventEnvelope(_ envelope: AgentChatEventEnvelope) { - var events = chatEventEnvelopesBySession[envelope.sessionId] ?? [] - if let last = events.last { + let sessionId = envelope.sessionId + if let last = chatEventEnvelopesBySession[sessionId]?.last { if canAppendChatEvent(envelope, after: last) { - events.append(envelope) - events = trimChatEventHistory(events) + // Hot streaming path: append + cap in place via the Dictionary `_modify` + // accessor so the up-to-chatEventHistoryMaxEvents array isn't copied on + // every chat_event. Semantics match trimChatEventHistory (keep the last + // chatEventHistoryMaxEvents, drop the overflow from the front). + chatEventEnvelopesBySession[sessionId, default: []].append(envelope) + let overflow = (chatEventEnvelopesBySession[sessionId]?.count ?? 0) - chatEventHistoryMaxEvents + if overflow > 0 { + chatEventEnvelopesBySession[sessionId, default: []].removeFirst(overflow) + } } else { + let events = chatEventEnvelopesBySession[sessionId] ?? [] guard !chatEventHistoryContainsDuplicate(envelope, in: events) else { return } - events = insertChatEventEnvelope(envelope, into: events) + chatEventEnvelopesBySession[sessionId] = insertChatEventEnvelope(envelope, into: events) } } else { - events.append(envelope) + chatEventEnvelopesBySession[sessionId, default: []].append(envelope) } - chatEventEnvelopesBySession[envelope.sessionId] = events - chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 + pruneChatEventHistoryCacheIfNeeded(preserving: [sessionId]) + chatEventRevisionsBySession[sessionId, default: 0] += 1 markSyncActivity() updateChatTurnActiveHintFromEvent(envelope) markChatEventsChanged() @@ -9318,6 +9433,7 @@ final class SyncService: ObservableObject { let next = deduplicatedChatEventHistory(events) guard chatEventEnvelopesBySession[sessionId] != next else { return } chatEventEnvelopesBySession[sessionId] = next + pruneChatEventHistoryCacheIfNeeded(preserving: [sessionId]) chatEventRevisionsBySession[sessionId, default: 0] += 1 markSyncActivity() markChatEventsChanged(immediate: true) @@ -9328,6 +9444,7 @@ final class SyncService: ObservableObject { let next = deduplicatedChatEventHistory(current + events) guard current != next else { return } chatEventEnvelopesBySession[sessionId] = next + pruneChatEventHistoryCacheIfNeeded(preserving: [sessionId]) chatEventRevisionsBySession[sessionId, default: 0] += 1 markSyncActivity() markChatEventsChanged(immediate: true) @@ -9504,6 +9621,37 @@ final class SyncService: ObservableObject { return Array(events.suffix(chatEventHistoryMaxEvents)) } + private func pruneChatEventHistoryCacheIfNeeded(preserving additionalSessionIds: Set = []) { + let targetCount = max(chatEventHistoryMaxSessions, subscribedChatSessionIds.count + additionalSessionIds.count) + guard chatEventEnvelopesBySession.count > targetCount else { return } + + let protectedSessionIds = subscribedChatSessionIds.union(additionalSessionIds) + let staleSessionIds = chatEventEnvelopesBySession.keys + .filter { !protectedSessionIds.contains($0) } + .sorted { lhs, rhs in + let leftEvent = chatEventEnvelopesBySession[lhs]?.last + let rightEvent = chatEventEnvelopesBySession[rhs]?.last + switch (leftEvent, rightEvent) { + case let (left?, right?): + let order = compareChatEvents(left, right) + return order == .orderedSame ? lhs < rhs : order == .orderedAscending + case (nil, nil): + return lhs < rhs + case (nil, _): + return true + case (_, nil): + return false + } + } + let dropCount = max(0, chatEventEnvelopesBySession.count - targetCount) + for sessionId in staleSessionIds.prefix(dropCount) { + chatEventEnvelopesBySession.removeValue(forKey: sessionId) + chatEventRevisionsBySession.removeValue(forKey: sessionId) + chatEventLastSeqBySession.removeValue(forKey: sessionId) + chatTurnActiveHintBySession.removeValue(forKey: sessionId) + } + } + private func trimmedTerminalBuffer(_ buffer: String) -> String { guard buffer.count > syncTerminalBufferMaxCharacters else { return buffer } return String(buffer.suffix(syncTerminalBufferMaxCharacters)) @@ -9589,6 +9737,8 @@ final class SyncService: ObservableObject { // Watermarks are only meaningful while the applied history is retained; // resuming from a seq after dropping history would skip those events. chatEventLastSeqBySession.removeAll() + } else { + pruneChatEventHistoryCacheIfNeeded() } markChatEventsChanged(immediate: true) localStateRevision += 1 @@ -10633,33 +10783,33 @@ private func gunzip(_ data: Data, maxOutputBytes: Int = maxUncompressedSyncEnvel var output = Data() let chunkSize = 16_384 - data.withUnsafeBytes { rawBuffer in - stream.next_in = UnsafeMutablePointer(mutating: rawBuffer.bindMemory(to: Bytef.self).baseAddress) - stream.avail_in = uint(data.count) - } - status = inflateInit2_(&stream, MAX_WBITS + 32, ZLIB_VERSION, Int32(MemoryLayout.size)) guard status == Z_OK else { throw NSError(domain: "ADE", code: 9, userInfo: [NSLocalizedDescriptionKey: "Unable to start gzip decoder."]) } defer { inflateEnd(&stream) } - repeat { - var chunk = [UInt8](repeating: 0, count: chunkSize) - chunk.withUnsafeMutableBytes { chunkBuffer in - stream.next_out = chunkBuffer.bindMemory(to: Bytef.self).baseAddress - stream.avail_out = uint(chunkSize) - status = inflate(&stream, Z_SYNC_FLUSH) - let produced = chunkSize - Int(stream.avail_out) - if produced > 0, let baseAddress = chunkBuffer.bindMemory(to: UInt8.self).baseAddress { - if output.count + produced > maxOutputBytes { - status = Z_MEM_ERROR - return + data.withUnsafeBytes { rawBuffer in + stream.next_in = UnsafeMutablePointer(mutating: rawBuffer.bindMemory(to: Bytef.self).baseAddress) + stream.avail_in = uint(data.count) + + repeat { + var chunk = [UInt8](repeating: 0, count: chunkSize) + chunk.withUnsafeMutableBytes { chunkBuffer in + stream.next_out = chunkBuffer.bindMemory(to: Bytef.self).baseAddress + stream.avail_out = uint(chunkSize) + status = inflate(&stream, Z_SYNC_FLUSH) + let produced = chunkSize - Int(stream.avail_out) + if produced > 0, let baseAddress = chunkBuffer.bindMemory(to: UInt8.self).baseAddress { + if output.count + produced > maxOutputBytes { + status = Z_MEM_ERROR + return + } + output.append(baseAddress, count: produced) } - output.append(baseAddress, count: produced) } - } - } while status == Z_OK + } while status == Z_OK + } guard status == Z_STREAM_END else { let message: String diff --git a/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift b/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift index 57729000d..3ed0510cf 100644 --- a/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift +++ b/apps/ios/ADE/Views/Components/ADECodeRenderingCache.swift @@ -11,7 +11,7 @@ final class ADECodeRenderingCache { private init() { tokenCache.countLimit = 64 - attributedCache.countLimit = 24 + attributedCache.countLimit = 160 regexCache.countLimit = 64 } diff --git a/apps/ios/ADE/Views/Components/FilesCodeSupport.swift b/apps/ios/ADE/Views/Components/FilesCodeSupport.swift index dafe45818..0484248e7 100644 --- a/apps/ios/ADE/Views/Components/FilesCodeSupport.swift +++ b/apps/ios/ADE/Views/Components/FilesCodeSupport.swift @@ -330,10 +330,7 @@ enum FilesInlineDiffKind: Equatable { } struct FilesInlineDiffLine: Identifiable, Equatable { - var id: String { - "\(kind)-\(originalLineNumber ?? -1)-\(modifiedLineNumber ?? -1)-\(text)" - } - + let id: String let kind: FilesInlineDiffKind let text: String let originalLineNumber: Int? @@ -348,33 +345,62 @@ func buildInlineDiffLines(original: String, modified: String) -> [FilesInlineDif return [] } - var lcs = Array( - repeating: Array(repeating: 0, count: modifiedLines.count + 1), - count: originalLines.count + 1 - ) - - if !originalLines.isEmpty && !modifiedLines.isEmpty { - for originalIndex in stride(from: originalLines.count - 1, through: 0, by: -1) { - for modifiedIndex in stride(from: modifiedLines.count - 1, through: 0, by: -1) { - if originalLines[originalIndex] == modifiedLines[modifiedIndex] { - lcs[originalIndex][modifiedIndex] = lcs[originalIndex + 1][modifiedIndex + 1] + 1 - } else { - lcs[originalIndex][modifiedIndex] = max(lcs[originalIndex + 1][modifiedIndex], lcs[originalIndex][modifiedIndex + 1]) - } - } - } - } - + let difference = modifiedLines.difference(from: originalLines) + let removedOffsets = Set(difference.compactMap { change -> Int? in + if case .remove(let offset, _, _) = change { return offset } + return nil + }) + let insertedOffsets = Set(difference.compactMap { change -> Int? in + if case .insert(let offset, _, _) = change { return offset } + return nil + }) var diffLines: [FilesInlineDiffLine] = [] var originalIndex = 0 var modifiedIndex = 0 var originalLineNumber = 1 var modifiedLineNumber = 1 - while originalIndex < originalLines.count && modifiedIndex < modifiedLines.count { - if originalLines[originalIndex] == modifiedLines[modifiedIndex] { + func appendLine(kind: FilesInlineDiffKind, text: String, originalLineNumber: Int?, modifiedLineNumber: Int?) { + diffLines.append( + FilesInlineDiffLine( + id: "line-\(diffLines.count)-\(kind)-\(originalLineNumber ?? -1)-\(modifiedLineNumber ?? -1)", + kind: kind, + text: text, + originalLineNumber: originalLineNumber, + modifiedLineNumber: modifiedLineNumber + ) + ) + } + + while originalIndex < originalLines.count || modifiedIndex < modifiedLines.count { + while originalIndex < originalLines.count && removedOffsets.contains(originalIndex) { + appendLine( + kind: .removed, + text: originalLines[originalIndex], + originalLineNumber: originalLineNumber, + modifiedLineNumber: nil + ) + originalIndex += 1 + originalLineNumber += 1 + } + + while modifiedIndex < modifiedLines.count && insertedOffsets.contains(modifiedIndex) { + appendLine( + kind: .added, + text: modifiedLines[modifiedIndex], + originalLineNumber: nil, + modifiedLineNumber: modifiedLineNumber + ) + modifiedIndex += 1 + modifiedLineNumber += 1 + } + + if originalIndex < originalLines.count, + modifiedIndex < modifiedLines.count, + originalLines[originalIndex] == modifiedLines[modifiedIndex] { diffLines.append( FilesInlineDiffLine( + id: "line-\(diffLines.count)-unchanged-\(originalLineNumber)-\(modifiedLineNumber)", kind: .unchanged, text: originalLines[originalIndex], originalLineNumber: originalLineNumber, @@ -385,57 +411,30 @@ func buildInlineDiffLines(original: String, modified: String) -> [FilesInlineDif modifiedIndex += 1 originalLineNumber += 1 modifiedLineNumber += 1 - } else if lcs[originalIndex + 1][modifiedIndex] >= lcs[originalIndex][modifiedIndex + 1] { - diffLines.append( - FilesInlineDiffLine( + } else { + if originalIndex < originalLines.count { + appendLine( kind: .removed, text: originalLines[originalIndex], originalLineNumber: originalLineNumber, modifiedLineNumber: nil ) - ) - originalIndex += 1 - originalLineNumber += 1 - } else { - diffLines.append( - FilesInlineDiffLine( + originalIndex += 1 + originalLineNumber += 1 + } + if modifiedIndex < modifiedLines.count { + appendLine( kind: .added, text: modifiedLines[modifiedIndex], originalLineNumber: nil, modifiedLineNumber: modifiedLineNumber ) - ) - modifiedIndex += 1 - modifiedLineNumber += 1 + modifiedIndex += 1 + modifiedLineNumber += 1 + } } } - while originalIndex < originalLines.count { - diffLines.append( - FilesInlineDiffLine( - kind: .removed, - text: originalLines[originalIndex], - originalLineNumber: originalLineNumber, - modifiedLineNumber: nil - ) - ) - originalIndex += 1 - originalLineNumber += 1 - } - - while modifiedIndex < modifiedLines.count { - diffLines.append( - FilesInlineDiffLine( - kind: .added, - text: modifiedLines[modifiedIndex], - originalLineNumber: nil, - modifiedLineNumber: modifiedLineNumber - ) - ) - modifiedIndex += 1 - modifiedLineNumber += 1 - } - return diffLines } diff --git a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift index ff58ab37b..646192945 100644 --- a/apps/ios/ADE/Views/Cto/CtoRootScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoRootScreen.swift @@ -77,7 +77,7 @@ struct CtoRootScreen: View { private var tabBody: some View { switch selectedTab { case .team: - CtoTeamScreen(path: $path) + CtoTeamScreen(path: $path, isTabActive: isTabActive) .environmentObject(syncService) case .workflows: CtoWorkflowsScreen() diff --git a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift index dcb2af865..2705c2a38 100644 --- a/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoTeamScreen.swift @@ -6,6 +6,7 @@ import SwiftUI struct CtoTeamScreen: View { @EnvironmentObject private var syncService: SyncService @Binding var path: NavigationPath + var isTabActive = true @State private var agents: [AgentIdentity] = [] @State private var fallbackWorkers: [CtoWorkerEntry] = [] @@ -367,6 +368,8 @@ struct CtoTeamScreen: View { } private var ctoAgentsLiveReloadKey: String? { + // Don't poll fetchCtoAgents/fetchCtoBudget while the CTO tab isn't frontmost. + guard isTabActive else { return nil } switch syncService.connectionState { case .connected, .syncing: return "live-\(syncService.localStateRevision)" diff --git a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift index 5f910d5f3..b6300ca5a 100644 --- a/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift +++ b/apps/ios/ADE/Views/Cto/CtoWorkflowsScreen.swift @@ -974,10 +974,9 @@ private struct EditOnMachineSheet: View { private enum CtoWorkflowsRelativeTime { static func format(iso: String) -> String? { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let date = formatter.date(from: iso) ?? ISO8601DateFormatter().date(from: iso) - guard let date else { return nil } + // Reuse the shared cached ISO8601 parser (fractional then fallback) instead of + // allocating formatters per row/render. + guard let date = prParsedDate(iso) else { return nil } let seconds = Int(Date().timeIntervalSince(date)) if seconds < 60 { return "\(max(seconds, 0))s" } let minutes = seconds / 60 diff --git a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift index 16469456e..33175f293 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailComponents.swift @@ -115,6 +115,7 @@ struct FilesViewerControlStrip: View { struct FilesHeaderStrip: View { @EnvironmentObject private var syncService: SyncService + let workspaceId: String let relativePath: String let fileKindLabel: String let fileSize: Int @@ -144,7 +145,7 @@ struct FilesHeaderStrip: View { .frame(width: 38, height: 38) .background(ADEColor.surfaceBackground, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) .glassEffect(in: .rect(cornerRadius: 12)) - .adeMatchedGeometry(id: transitionNamespace == nil ? nil : "files-icon-\(relativePath)", in: transitionNamespace) + .adeMatchedGeometry(id: transitionNamespace == nil ? nil : filesTransitionId(kind: "icon", workspaceId: workspaceId, path: relativePath), in: transitionNamespace) VStack(alignment: .leading, spacing: 3) { Text(lastPathComponent(relativePath)) @@ -152,7 +153,7 @@ struct FilesHeaderStrip: View { .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) .truncationMode(.middle) - .adeMatchedGeometry(id: transitionNamespace == nil ? nil : "files-title-\(relativePath)", in: transitionNamespace) + .adeMatchedGeometry(id: transitionNamespace == nil ? nil : filesTransitionId(kind: "title", workspaceId: workspaceId, path: relativePath), in: transitionNamespace) HStack(spacing: 6) { Text(fileKindLabel.uppercased()) @@ -162,10 +163,6 @@ struct FilesHeaderStrip: View { Text(formattedFileSize(fileSize)) .font(.caption2.monospaced()) .foregroundStyle(ADEColor.textSecondary) - Text("·").foregroundStyle(ADEColor.textMuted) - Text("Read only") - .font(.caption2.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) if let filesBrowserStatusSuffix { Text("·").foregroundStyle(ADEColor.textMuted) Text(filesBrowserStatusSuffix) diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift index 258713122..be5bdefce 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen+Actions.swift @@ -100,20 +100,26 @@ extension FilesDetailScreen { @MainActor func loadDiff() async { guard let laneId = workspace.laneId else { - diff = nil + clearDiffState() diffErrorMessage = nil hasLoadedDiff = true return } hasLoadedDiff = false do { - diff = try await syncService.fetchFileDiff(workspaceId: workspace.id, laneId: laneId, path: relativePath, mode: diffMode.rawValue) + let loaded = try await syncService.fetchFileDiff(workspaceId: workspace.id, laneId: laneId, path: relativePath, mode: diffMode.rawValue) + diffRenderState = FilesDiffRenderState(diff: loaded) diffErrorMessage = nil } catch { - diff = nil + clearDiffState() diffErrorMessage = error.localizedDescription } hasLoadedDiff = true } + @MainActor + private func clearDiffState() { + diffRenderState = nil + } + } diff --git a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift index dbfd02858..5a977d1c3 100644 --- a/apps/ios/ADE/Views/Files/FilesDetailScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesDetailScreen.swift @@ -16,7 +16,7 @@ struct FilesDetailScreen: View { @State var mode: FilesEditorMode = .preview @State var markdownViewMode: FilesMarkdownViewMode = .preview @State var diffMode: FilesDiffMode = .unstaged - @State var diff: FileDiff? + @State var diffRenderState: FilesDiffRenderState? @State var diffErrorMessage: String? @State var historyEntries: [GitFileHistoryEntry] = [] @State var historyErrorMessage: String? @@ -149,7 +149,7 @@ struct FilesDetailScreen: View { .padding(.bottom, 6) .background(ADEColor.pageBackground.opacity(0.94)) } - .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : "files-container-\(relativePath)", in: transitionNamespace) + .adeNavigationZoomTransition(id: transitionNamespace == nil ? nil : filesTransitionId(kind: "container", workspaceId: workspace.id, path: relativePath), in: transitionNamespace) .sheet(isPresented: $isDetailsSheetPresented) { FilesDetailsSheet( relativePath: relativePath, @@ -240,7 +240,7 @@ struct FilesDetailScreen: View { FilesContentFallback( symbol: binaryKind?.symbol ?? "doc.fill", title: binaryKind.map { "\($0.label) file" } ?? "Binary file", - message: "iPhone keeps this read-only. Use ADE on the machine to preview or open it with a local tool." + message: "iPhone cannot preview this binary inline. Use ADE on the machine to preview it or open it with a local tool." ) .padding(16) } @@ -297,31 +297,31 @@ struct FilesDetailScreen: View { onAction: { Task { await loadDiff() } } ) .padding(16) - } else if let diff, diff.isBinary == true { + } else if let diffRenderState, diffRenderState.diff.isBinary == true { FilesContentFallback( symbol: "doc.badge.gearshape", title: "Binary diff", message: "The machine reported a binary diff that cannot be rendered inline." ) .padding(16) - } else if let diff, !filesDiffHasChanges(diff) { + } else if let diffRenderState, !diffRenderState.hasVisibleChanges { FilesContentFallback( symbol: "checkmark.circle", title: "No \(diffMode.title.lowercased()) changes", message: "This file matches the selected \(diffMode.title.lowercased()) diff scope." ) .padding(16) - } else if let diff, let limit = filesDiffPreviewLimit(diff: diff) { + } else if let diffRenderState, let limit = diffRenderState.previewLimit { FilesContentFallback( symbol: "arrow.left.arrow.right", title: limit.title, message: limit.message ) .padding(16) - } else if let diff { + } else if let diffRenderState { FilesInlineDiffView( - lines: buildInlineDiffLines(original: diff.original.text, modified: diff.modified.text), - language: FilesLanguage.detect(languageId: diff.language, filePath: relativePath), + lines: diffRenderState.inlineLines, + language: FilesLanguage.detect(languageId: diffRenderState.diff.language, filePath: relativePath), layoutMode: codeLayoutMode, fillsContainer: true ) diff --git a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift index b8bb19474..36ad197eb 100644 --- a/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift +++ b/apps/ios/ADE/Views/Files/FilesDirectoryContentsView.swift @@ -43,6 +43,7 @@ struct FilesDirectoryContentsView: View { } else { ForEach(filesSortedNodes(nodes)) { node in FilesTreeNodeRow( + workspaceId: workspace.id, node: node, transitionNamespace: transitionNamespace, isSelectedTransitionSource: selectedFilePath == node.path, diff --git a/apps/ios/ADE/Views/Files/FilesModels.swift b/apps/ios/ADE/Views/Files/FilesModels.swift index 8a55ea0a4..50a3372d9 100644 --- a/apps/ios/ADE/Views/Files/FilesModels.swift +++ b/apps/ios/ADE/Views/Files/FilesModels.swift @@ -17,6 +17,10 @@ struct FilesBreadcrumbItem: Equatable { let isDirectory: Bool } +func filesTransitionId(kind: String, workspaceId: String, path: String) -> String { + "files-\(kind)-\(workspaceId)::\(path)" +} + enum FilesEditorMode: String, CaseIterable, Identifiable { case preview case diff @@ -124,6 +128,25 @@ struct FilesPreviewLimit: Equatable { let message: String } +struct FilesDiffRenderState { + let diff: FileDiff + let hasVisibleChanges: Bool + let previewLimit: FilesPreviewLimit? + let inlineLines: [FilesInlineDiffLine] + + init(diff: FileDiff) { + let hasVisibleChanges = filesDiffHasChanges(diff) + let previewLimit = filesDiffPreviewLimit(diff: diff) + + self.diff = diff + self.hasVisibleChanges = hasVisibleChanges + self.previewLimit = previewLimit + self.inlineLines = diff.isBinary == true || !hasVisibleChanges || previewLimit != nil + ? [] + : buildInlineDiffLines(original: diff.original.text, modified: diff.modified.text) + } +} + let filesDetailRefreshMinimumInterval: TimeInterval = 0.75 func filesDetailRefreshDelay( @@ -139,6 +162,7 @@ private let filesTextPreviewByteLimit = 300 * 1024 private let filesTextPreviewLineLimit = 4_000 private let filesDiffPreviewByteLimit = 400 * 1024 private let filesDiffPreviewLineLimit = 6_000 +private let filesDiffPreviewLinePairLimit = 1_500_000 func filesTextPreviewLimit(blob: SyncFileBlob) -> FilesPreviewLimit? { guard !blob.isBinary else { return nil } @@ -257,10 +281,19 @@ func filesDiffPreviewLimit(diff: FileDiff) -> FilesPreviewLimit? { ) } + let originalLineCount = filesEstimatedLineCount(diff.original.text) + let modifiedLineCount = filesEstimatedLineCount(diff.modified.text) + if filesLinePairCountExceedsLimit(originalLineCount: originalLineCount, modifiedLineCount: modifiedLineCount) { + return FilesPreviewLimit( + title: "Diff preview paused", + message: "This diff compares \(originalLineCount) original lines against \(modifiedLineCount) modified lines. Open the file from ADE on your machine or inspect a smaller diff before rendering it on iPhone." + ) + } + let combinedText = "\(diff.original.text)\n\(diff.modified.text)" return filesTextLimit( byteCount: combinedText.utf8.count, - lineCount: filesEstimatedLineCount(combinedText), + lineCount: originalLineCount + modifiedLineCount, lineLimit: filesDiffPreviewLineLimit, byteLimit: filesDiffPreviewByteLimit, title: "Diff preview paused", @@ -303,6 +336,11 @@ private func filesEstimatedLineCount(_ text: String) -> Int { } } +private func filesLinePairCountExceedsLimit(originalLineCount: Int, modifiedLineCount: Int, limit: Int = filesDiffPreviewLinePairLimit) -> Bool { + guard originalLineCount > 0, modifiedLineCount > 0 else { return false } + return originalLineCount > limit / modifiedLineCount +} + func resolveFilesWorkspace(for request: FilesNavigationRequest, in workspaces: [FilesWorkspace]) -> FilesWorkspace? { if let exact = workspaces.first(where: { $0.id == request.workspaceId }) { return exact diff --git a/apps/ios/ADE/Views/Files/FilesRootComponents.swift b/apps/ios/ADE/Views/Files/FilesRootComponents.swift index f901c2c9c..335db49ba 100644 --- a/apps/ios/ADE/Views/Files/FilesRootComponents.swift +++ b/apps/ios/ADE/Views/Files/FilesRootComponents.swift @@ -152,6 +152,7 @@ struct FilesProofArtifactRow: View { } struct FilesTreeNodeRow: View { + let workspaceId: String let node: FileTreeNode let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool @@ -166,13 +167,13 @@ struct FilesTreeNodeRow: View { .font(.system(size: 14, weight: .semibold)) .foregroundStyle(node.type == "directory" ? ADEColor.accent : fileTint(for: node.name)) .frame(width: 18) - .adeMatchedGeometry(id: canTransition ? "files-icon-\(node.path)" : nil, in: transitionNamespace) + .adeMatchedGeometry(id: canTransition ? filesTransitionId(kind: "icon", workspaceId: workspaceId, path: node.path) : nil, in: transitionNamespace) Text(node.name) .font(.subheadline.weight(.medium)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) - .adeMatchedGeometry(id: canTransition ? "files-title-\(node.path)" : nil, in: transitionNamespace) + .adeMatchedGeometry(id: canTransition ? filesTransitionId(kind: "title", workspaceId: workspaceId, path: node.path) : nil, in: transitionNamespace) if let changeStatus = node.changeStatus { ADEStatusPill(text: changeStatus.uppercased(), tint: changeStatusTint(changeStatus)) @@ -216,7 +217,7 @@ struct FilesTreeNodeRow: View { "role": "row" ] ) - .adeMatchedTransitionSource(id: canTransition ? "files-container-\(node.path)" : nil, in: transitionNamespace) + .adeMatchedTransitionSource(id: canTransition ? filesTransitionId(kind: "container", workspaceId: workspaceId, path: node.path) : nil, in: transitionNamespace) } private var canTransition: Bool { @@ -232,6 +233,7 @@ struct FilesTreeNodeRow: View { } struct FilesResultRow: View { + let workspaceId: String let path: String let transitionNamespace: Namespace.ID? let isSelectedTransitionSource: Bool @@ -240,14 +242,14 @@ struct FilesResultRow: View { HStack(spacing: 10) { Image(systemName: fileIcon(for: path)) .foregroundStyle(fileTint(for: path)) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-icon-\(path)" : nil, in: transitionNamespace) + .adeMatchedGeometry(id: isSelectedTransitionSource ? filesTransitionId(kind: "icon", workspaceId: workspaceId, path: path) : nil, in: transitionNamespace) VStack(alignment: .leading, spacing: 3) { Text(lastPathComponent(path)) .font(.subheadline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) .lineLimit(1) .truncationMode(.tail) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "files-title-\(path)" : nil, in: transitionNamespace) + .adeMatchedGeometry(id: isSelectedTransitionSource ? filesTransitionId(kind: "title", workspaceId: workspaceId, path: path) : nil, in: transitionNamespace) Text(path) .font(.caption.monospaced()) .foregroundStyle(ADEColor.textSecondary) @@ -260,7 +262,7 @@ struct FilesResultRow: View { .foregroundStyle(ADEColor.textMuted) } .adeListCard(cornerRadius: 16) - .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "files-container-\(path)" : nil, in: transitionNamespace) + .adeMatchedTransitionSource(id: isSelectedTransitionSource ? filesTransitionId(kind: "container", workspaceId: workspaceId, path: path) : nil, in: transitionNamespace) .accessibilityElement(children: .combine) .accessibilityLabel("\(lastPathComponent(path)), file") .adeInspectable( diff --git a/apps/ios/ADE/Views/Files/FilesSearchScreen.swift b/apps/ios/ADE/Views/Files/FilesSearchScreen.swift index bc873ef4e..cbb2a7b71 100644 --- a/apps/ios/ADE/Views/Files/FilesSearchScreen.swift +++ b/apps/ios/ADE/Views/Files/FilesSearchScreen.swift @@ -120,6 +120,7 @@ struct FilesSearchScreen: View { open(path: item.path, line: nil) } label: { FilesResultRow( + workspaceId: workspace.id, path: item.path, transitionNamespace: nil, isSelectedTransitionSource: false diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index 786825b75..e74bbdc2b 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -418,11 +418,14 @@ struct HubProjectCard: View, Equatable { let onToggleCollapse: () -> Void let onOpenProject: () -> Void let onOpenChat: (RemoteRosterChat, RemoteRosterLane?) -> Void + let onViewLaneInWork: (RemoteRosterLane) -> Void + let onViewLaneInLanes: (RemoteRosterLane) -> Void let onArchiveChat: (RemoteRosterChat) -> Void let onDeleteChat: (RemoteRosterChat) -> Void let onForget: () -> Void private var project: MobileProjectSummary { presentation.project } + private var hasExpandableContent: Bool { !presentation.lanes.isEmpty } var body: some View { VStack(alignment: .leading, spacing: 0) { @@ -439,51 +442,48 @@ struct HubProjectCard: View, Equatable { .zIndex(1) // Expanded lanes + chats hang below the card, indented and unboundaried. - if !isCollapsed { - Group { - if presentation.lanes.isEmpty { - HubProjectEmptyChats(isLoading: presentation.isLoading) - } else { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(presentation.lanes) { lanePresentation in - HubLaneSection( - project: project, - presentation: lanePresentation, - isCollapsed: collapsedLaneKeysSnapshot.contains(laneKey(lanePresentation.lane)), - onToggle: { toggleLane(lanePresentation.lane) }, - onOpenChat: { chat in onOpenChat(chat, lanePresentation.lane) }, - onArchiveChat: onArchiveChat, - onDeleteChat: onDeleteChat - ) - .equatable() - } - } - .padding(.top, 10) + if hasExpandableContent && !isCollapsed { + VStack(alignment: .leading, spacing: 10) { + ForEach(presentation.lanes) { lanePresentation in + HubLaneSection( + project: project, + presentation: lanePresentation, + isCollapsed: collapsedLaneKeysSnapshot.contains(laneKey(lanePresentation.lane)), + onToggle: { toggleLane(lanePresentation.lane) }, + onOpenChat: { chat in onOpenChat(chat, lanePresentation.lane) }, + onViewInWork: { onViewLaneInWork(lanePresentation.lane) }, + onViewInLanes: { onViewLaneInLanes(lanePresentation.lane) }, + onArchiveChat: onArchiveChat, + onDeleteChat: onDeleteChat + ) + .equatable() } } + .padding(.top, 10) .padding(.leading, 16) .padding(.trailing, 4) .zIndex(0) - // Pure vertical slide (no cross-fade). The parent `.clipped()` masks the - // rows to the card's animating bounds, so on collapse they roll up - // behind the card instead of lingering over the cards below. - .transition(.move(edge: .top)) } } - .clipped() } private var header: some View { HStack(spacing: 11) { - Button(action: onToggleCollapse) { - Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") - .font(.system(size: 12, weight: .bold)) - .foregroundStyle(ADEColor.textMuted) + if hasExpandableContent { + Button(action: onToggleCollapse) { + Image(systemName: isCollapsed ? "chevron.right" : "chevron.down") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(ADEColor.textMuted) + .frame(width: 22, height: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isCollapsed ? "Expand project" : "Collapse project") + } else { + Color.clear .frame(width: 22, height: 22) - .contentShape(Rectangle()) + .accessibilityHidden(true) } - .buttonStyle(.plain) - .accessibilityLabel(isCollapsed ? "Expand project" : "Collapse project") HubProjectIcon(iconDataUrl: project.iconDataUrl, isActive: presentation.isActive, size: 44) @@ -585,6 +585,8 @@ struct HubLaneSection: View, Equatable { let isCollapsed: Bool let onToggle: () -> Void let onOpenChat: (RemoteRosterChat) -> Void + let onViewInWork: () -> Void + let onViewInLanes: () -> Void let onArchiveChat: (RemoteRosterChat) -> Void let onDeleteChat: (RemoteRosterChat) -> Void @@ -623,13 +625,14 @@ struct HubLaneSection: View, Equatable { .contentShape(Rectangle()) } .buttonStyle(.plain) - // Opaque backing + zIndex so chat rows slide up behind the lane header on - // collapse instead of showing through it. - .background(ADEColor.pageBackground) + .contextMenu { + Button { onViewInWork() } label: { Label("View in Work tab", systemImage: "terminal") } + Button { onViewInLanes() } label: { Label("View in Lanes tab", systemImage: "square.stack.3d.up") } + } .zIndex(1) if !isCollapsed { - LazyVStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 2) { ForEach(presentation.rows) { row in HubChatRow( row: row, @@ -643,10 +646,8 @@ struct HubLaneSection: View, Equatable { } .padding(.leading, 6) .zIndex(0) - .transition(.move(edge: .top)) } } - .clipped() } static func == (lhs: HubLaneSection, rhs: HubLaneSection) -> Bool { @@ -669,34 +670,29 @@ struct HubChatRow: View, Equatable { var body: some View { // Deliberately minimal: provider logo, chat name, and the relative // timestamp. Nothing else competes for the eye at the hub's glance level. - HStack(spacing: 10) { - WorkProviderBareLogo(provider: row.providerKey, fallbackSymbol: "terminal.fill", tint: ADEColor.textSecondary, size: compact ? 16 : 20) + Button(action: onOpen) { + HStack(spacing: 10) { + WorkProviderBareLogo(provider: row.providerKey, fallbackSymbol: "terminal.fill", tint: ADEColor.textSecondary, size: compact ? 16 : 20) - Text(row.title) - .font(.system(.footnote, design: .rounded).weight(.medium)) - .foregroundStyle(ADEColor.textPrimary) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) + Text(row.title) + .font(.system(.footnote, design: .rounded).weight(.medium)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) - if let activity = row.activityLabel { - Text(activity) - .font(.system(.caption2, design: .rounded)) - .foregroundStyle(ADEColor.textMuted) - } - } - .padding(.horizontal, 8) - .padding(.vertical, compact ? 5 : 7) - .contentShape(Rectangle()) - .accessibilityHidden(true) - .overlay { - Button(action: onOpen) { - Color.black.opacity(0.001) - .contentShape(Rectangle()) + if let activity = row.activityLabel { + Text(activity) + .font(.system(.caption2, design: .rounded)) + .foregroundStyle(ADEColor.textMuted) + } } - .buttonStyle(.plain) - .accessibilityLabel(row.title) - .accessibilityHint("Opens chat.") + .padding(.horizontal, 8) + .padding(.vertical, compact ? 5 : 7) + .contentShape(Rectangle()) } + .buttonStyle(.plain) + .accessibilityLabel(row.title) + .accessibilityHint("Opens chat.") // The hub uses a scrolling LazyVStack (not a List), where SwiftUI // `.swipeActions` are unavailable — so pin/archive/close are offered through // a long-press context menu instead, routed to the chat's project. @@ -791,27 +787,6 @@ struct HubComposerBar: View { // MARK: - State cards -struct HubProjectEmptyChats: View { - let isLoading: Bool - var body: some View { - HStack(spacing: 8) { - if isLoading { - ProgressView().controlSize(.small) - Text("Loading chats…") - } else { - Image(systemName: "tray") - .foregroundStyle(ADEColor.textMuted) - Text("No chats yet") - } - } - .font(.system(.caption, design: .rounded)) - .foregroundStyle(ADEColor.textMuted) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.bottom, 12) - } -} - struct HubConnectingCard: View { var body: some View { HStack(spacing: 10) { diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index 28b985eaf..f1cca0881 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -746,6 +746,7 @@ struct HubComposerDrawer: View { targetProjectRootPath: targetProjectRootPath ) sessionId = summary.sessionId + try await syncService.sendChatMessage(sessionId: summary.sessionId, text: opener) } // Persist the composer + destination so the next New Chat restores both. diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index 59f8a0837..3cc9b75c9 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -45,6 +45,7 @@ private struct HubChatCover: View { transitionNamespace: nil, isLive: true, navigationChrome: .pushedDetail, + forceFreshTranscriptOnOpen: true, lanes: target.lane.map { [$0.asLaneSummary()] } ?? [] ) .id(target.id) diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index f1ad66d49..72cd2d250 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -126,6 +126,18 @@ struct HubScreen: View { onOpenChat: { chat, lane in openChatTarget = HubChatTarget(project: project, lane: lane, chat: chat) }, + onViewLaneInWork: { lane in + Task { @MainActor in + await syncService.openProjectForHubChat(project) + syncService.requestedWorkLaneNavigation = WorkLaneNavigationRequest(laneId: lane.id) + } + }, + onViewLaneInLanes: { lane in + Task { @MainActor in + await syncService.openProjectForHubChat(project) + syncService.requestedLaneNavigation = LaneNavigationRequest(laneId: lane.id) + } + }, onArchiveChat: { chat in runRosterChatAction("chat.archive", chat: chat, project: project) }, onDeleteChat: { chat in runRosterChatAction("chat.delete", chat: chat, project: project) }, onForget: { syncService.forgetProject(project) } @@ -148,25 +160,27 @@ struct HubScreen: View { } .scrollIndicators(.hidden) .safeAreaInset(edge: .bottom, spacing: 0) { - VStack(spacing: 0) { - LinearGradient( - colors: [ - ADEColor.pageBackground.opacity(0), - ADEColor.pageBackground.opacity(0.96) - ], - startPoint: .top, - endPoint: .bottom + if canShowProjects { + VStack(spacing: 0) { + LinearGradient( + colors: [ + ADEColor.pageBackground.opacity(0), + ADEColor.pageBackground.opacity(0.96) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 14) + .allowsHitTesting(false) + + HubComposerBar { composerPresented = true } + } + .background( + ADEColor.pageBackground + .opacity(0.96) + .ignoresSafeArea(edges: .bottom) ) - .frame(height: 14) - .allowsHitTesting(false) - - HubComposerBar { composerPresented = true } } - .background( - ADEColor.pageBackground - .opacity(0.96) - .ignoresSafeArea(edges: .bottom) - ) } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) diff --git a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift index cc022a84e..6650c047c 100644 --- a/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift +++ b/apps/ios/ADE/Views/Lanes/LaneDetailScreen.swift @@ -118,9 +118,16 @@ struct LaneDetailScreen: View { return } guard detail != nil else { return } - let now = Date() - guard now.timeIntervalSince(lastLaneDetailLocalReload) >= 0.35 else { return } - lastLaneDetailLocalReload = now + let revision = laneDetailProjectionReloadKey + let elapsed = Date().timeIntervalSince(lastLaneDetailLocalReload) + if elapsed < 0.35 { + // Defer (don't drop) a bump that lands inside the throttle window: sleep + // out the remainder, then re-check. A newer bump cancels/restarts this + // task, so only the trailing reload proceeds. + try? await Task.sleep(for: .milliseconds(max(1, Int((0.35 - elapsed) * 1_000)))) + guard !Task.isCancelled, laneDetailProjectionReloadKey == revision else { return } + } + lastLaneDetailLocalReload = Date() await loadDetail(refreshRemote: false) } .refreshable { await loadDetail(refreshRemote: true) } diff --git a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift index 6581d0923..685d3c816 100644 --- a/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift +++ b/apps/ios/ADE/Views/Work/WorkChatAttachmentTray.swift @@ -1,8 +1,10 @@ import SwiftUI +import ImageIO import UIKit private let workChatRemoteImageMaxBytes = 5 * 1024 * 1024 private let workChatRemoteImageTimeoutSeconds: TimeInterval = 12 +private let workChatAttachmentPreviewMinimumPixels: CGFloat = 96 private let workChatRemoteImageSession: URLSession = { let configuration = URLSessionConfiguration.ephemeral @@ -128,6 +130,7 @@ private struct WorkChatAttachmentChip: View { @EnvironmentObject private var syncService: SyncService @Environment(\.workChatLaneId) private var laneId @Environment(\.workChatRequestedCwd) private var requestedCwd + @Environment(\.displayScale) private var displayScale @State private var previewImage: UIImage? @State private var loadFailed = false @@ -200,12 +203,13 @@ private struct WorkChatAttachmentChip: View { @MainActor private func loadPreviewIfNeeded() async { guard workChatAttachmentIsImage(attachment) else { return } + let maxPixelSize = max(workChatAttachmentPreviewMinimumPixels, ceil(size * displayScale)) if attachment.type == "image-url", let urlString = attachment.url, let url = URL(string: urlString), let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { do { let data = try await workChatRemoteImageData(from: url) - if let image = UIImage(data: data) { + if let image = WorkChatAttachmentImagePreview.downsampledImage(data: data, maxPixelSize: maxPixelSize) { previewImage = image loadFailed = false return @@ -237,15 +241,16 @@ private struct WorkChatAttachmentChip: View { return } let blob = try await syncService.readFile(workspaceId: workspace.id, path: relativePath) - if let dataUrl = blob.dataUrl, let image = workChatUIImage(fromDataUrl: dataUrl) { + if let dataUrl = blob.dataUrl, + let image = WorkChatAttachmentImagePreview.image(fromDataUrl: dataUrl, maxPixelSize: maxPixelSize) { previewImage = image loadFailed = false return } if blob.isBinary, !blob.content.isEmpty, - let data = Data(base64Encoded: blob.content), - let image = UIImage(data: data) { + let data = WorkChatAttachmentImagePreview.base64DecodedImageData(blob.content, maxBytes: workChatRemoteImageMaxBytes), + let image = WorkChatAttachmentImagePreview.downsampledImage(data: data, maxPixelSize: maxPixelSize) { previewImage = image loadFailed = false return @@ -313,9 +318,38 @@ extension EnvironmentValues { } } -private func workChatUIImage(fromDataUrl dataUrl: String) -> UIImage? { - guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil } - let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...]) - guard let data = Data(base64Encoded: base64) else { return nil } - return UIImage(data: data) +enum WorkChatAttachmentImagePreview { + static func base64DecodedImageData(_ base64: String, maxBytes: Int) -> Data? { + let encodedBytes = base64.utf8.count + let decodedUpperBound = ((encodedBytes + 3) / 4) * 3 + guard decodedUpperBound <= maxBytes, + let data = Data(base64Encoded: base64), + data.count <= maxBytes else { + return nil + } + return data + } + + static func downsampledImage(data: Data, maxPixelSize: CGFloat) -> UIImage? { + guard !data.isEmpty, maxPixelSize > 0 else { return nil } + let sourceOptions = [ + kCGImageSourceShouldCache: false + ] as CFDictionary + guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil } + let thumbnailOptions = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: Int(ceil(maxPixelSize)) + ] as CFDictionary + guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions) else { return nil } + return UIImage(cgImage: cgImage) + } + + static func image(fromDataUrl dataUrl: String, maxPixelSize: CGFloat) -> UIImage? { + guard let commaIndex = dataUrl.firstIndex(of: ",") else { return nil } + let base64 = String(dataUrl[dataUrl.index(after: commaIndex)...]) + guard let data = base64DecodedImageData(base64, maxBytes: workChatRemoteImageMaxBytes) else { return nil } + return downsampledImage(data: data, maxPixelSize: maxPixelSize) + } } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 4ad5976d3..fd24982af 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -79,6 +79,176 @@ struct WorkSessionHeader: View { } } +/// Menu-relevant values for the chat header overflow menu, split out so the +/// menu view can be `Equatable`-gated on exactly this data. +struct WorkChatHeaderMenuModel: Equatable { + var subagentCount: Int + var artifactCount: Int + var showsLaneActions: Bool + var prTag: LanePrTag? + var prGitHubUrlAvailable: Bool + var prLinkCopied: Bool + var laneAvailable: Bool + var createPrBlockedReason: String? + var sessionPinned: Bool + var sessionIdCopied: Bool + var sessionDeepLinkCopied: Bool +} + +/// Chat header overflow menu, extracted from `WorkSessionDestinationView` and +/// compared via `.equatable()` on `model` only. +/// +/// The destination view re-renders continuously while a chat streams +/// (transcript signatures, artifact/subagent refreshes, PR lookup keys). Every +/// re-evaluation of an open `Menu` rebuilds the presented UIMenu, which makes +/// the liquid-glass menu flicker and instantly dismisses any open nested +/// submenu. Gating on `model` means the presented menu is only rebuilt when +/// something the menu actually displays has changed. +struct WorkChatHeaderMenu: View, Equatable { + var model: WorkChatHeaderMenuModel + var onShowSubagents: () -> Void + var onShowProof: () -> Void + var onViewPrDetails: () -> Void + var onOpenPrsTab: () -> Void + var onOpenGitHub: () -> Void + var onCopyPrLink: () -> Void + var onOpenPrCreation: () -> Void + var onOpenLane: () -> Void + var onRename: () -> Void + var onDelete: () -> Void + var onCopySessionId: () -> Void + var onCopySessionDeepLink: () -> Void + var onTogglePinned: () -> Void + + static func == (lhs: WorkChatHeaderMenu, rhs: WorkChatHeaderMenu) -> Bool { + lhs.model == rhs.model + } + + var body: some View { + Menu { + Button(action: onShowSubagents) { + if model.subagentCount == 0 { + Label("Subagents", systemImage: "person.2") + } else { + Label("Subagents (\(model.subagentCount))", systemImage: "person.2") + } + } + + Divider() + + Button(action: onShowProof) { + if model.artifactCount == 0 { + Label("Proof", systemImage: "cube.transparent") + } else { + Label("Proof (\(model.artifactCount))", systemImage: "cube.transparent") + } + } + .accessibilityHint("Opens the proof drawer") + + if model.showsLaneActions { + Divider() + + pullRequestItems + } + + Divider() + + sessionItems + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ADEColor.textSecondary) + .frame(width: 34, height: 34) + .background(ADEColor.surfaceBackground.opacity(0.9), in: Circle()) + .overlay( + Circle() + .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.5) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Chat actions") + } + + @ViewBuilder + private var pullRequestItems: some View { + if let tag = model.prTag { + Menu { + Button(action: onViewPrDetails) { + Label("View PR details", systemImage: "sidebar.trailing") + } + + Button(action: onOpenPrsTab) { + Label("PRs tab", systemImage: "rectangle.grid.1x2") + } + .accessibilityHint("Opens \(formatLanePrBadgeLabel(tag)) in the PRs tab") + + Button(action: onOpenGitHub) { + Label("Open on GitHub", systemImage: "link") + } + .disabled(!model.prGitHubUrlAvailable) + } label: { + Label(formatLanePrBadgeLabel(tag), systemImage: "arrow.triangle.pull") + } + + Button(action: onCopyPrLink) { + if model.prLinkCopied { + Label("Copied link", systemImage: "checkmark") + } else { + Label("Copy link", systemImage: "doc.on.doc") + } + } + .disabled(!model.prGitHubUrlAvailable) + } else { + Button(action: onViewPrDetails) { + Label("View PR details", systemImage: "sidebar.trailing") + } + + Button(action: onOpenPrCreation) { + Label("Open PR in PRs tab", systemImage: "rectangle.grid.1x2") + } + .disabled(!model.laneAvailable) + + if let blockedReason = model.createPrBlockedReason { + Button {} label: { + Label(blockedReason, systemImage: "info.circle") + } + .disabled(true) + } + } + + Button(action: onOpenLane) { + Label("Open lane", systemImage: "arrow.triangle.branch") + } + } + + @ViewBuilder + private var sessionItems: some View { + Button(action: onRename) { + Label("Rename", systemImage: "pencil") + } + + Button(role: .destructive, action: onDelete) { + Label("Delete chat", systemImage: "trash") + } + + Button(action: onCopySessionId) { + Label(model.sessionIdCopied ? "Copied session ID" : "Copy session ID", + systemImage: model.sessionIdCopied ? "checkmark" : "doc.on.doc") + } + + Button(action: onCopySessionDeepLink) { + Label(model.sessionDeepLinkCopied ? "Copied session deep link" : "Copy session deep link", + systemImage: model.sessionDeepLinkCopied ? "checkmark" : "link") + } + + Button(action: onTogglePinned) { + Label(model.sessionPinned ? "Unpin from front" : "Pin to front", + systemImage: model.sessionPinned ? "pin.slash" : "pin") + } + } +} + /// Desktop-shaped message row. /// /// Assistant messages live inside a dark rounded card with only a small @@ -522,7 +692,7 @@ func workAssistantMessagePreview( ) let clampedCharacterBudget = max( usesMonospacedPreview - ? min(characterBudget, workAssistantMessageWideCharacterBudget(forLineBudget: clampedLineBudget)) + ? max(characterBudget, workAssistantMessageWideCharacterBudget(forLineBudget: clampedLineBudget)) : characterBudget, 256 ) @@ -672,7 +842,8 @@ private func workAssistantMessageEffectiveLineBudget( private func workAssistantMessageWideCharacterBudget(forLineBudget lineBudget: Int) -> Int { let extraSteps = max((lineBudget - workAssistantMessageWideInitialLineBudget) / workAssistantMessageLineBudgetStep, 0) - return workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) + let steppedBudget = workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) + return max(steppedBudget, lineBudget * 96) } func workAssistantMessageLineCount(_ text: String) -> Int { diff --git a/apps/ios/ADE/Views/Work/WorkChatPrViews.swift b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift index e396ee660..e38e1bf9d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatPrViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift @@ -88,20 +88,19 @@ struct WorkChatPrDetailsSheet: View { let pr: PullRequestListItem? let summary: PrSummary? let snapshot: PullRequestSnapshot? + let laneColor: Color? let canCreate: Bool let createBlockedReason: String? let isRefreshing: Bool let errorMessage: String? - let copiedLink: Bool let onRefresh: () -> Void let onCreate: () -> Void let onOpenPrsTab: () -> Void let onOpenGitHub: () -> Void - let onCopyLink: () -> Void - private var prNumberLabel: String { + private var sheetTitle: String { guard let tag else { return "Pull request" } - return formatLanePrBadgeLabel(tag) + return "PR #\(tag.githubPrNumber) \(lanePrStateLabel(tag.state))" } private var githubUrl: String { @@ -112,10 +111,6 @@ struct WorkChatPrDetailsSheet: View { snapshot?.status?.checksStatus ?? pr?.checksStatus ?? summary?.checksStatus } - private var reviewStatus: String? { - snapshot?.status?.reviewStatus ?? pr?.reviewStatus ?? summary?.reviewStatus - } - private var additions: Int { pr?.additions ?? summary?.additions ?? 0 } @@ -125,87 +120,75 @@ struct WorkChatPrDetailsSheet: View { } var body: some View { - NavigationStack { + VStack(spacing: 0) { + topBar + ScrollView { - VStack(alignment: .leading, spacing: 18) { - if let tag { - existingPrContent(tag) - } else { - emptyPrContent - } + if let tag { + existingPrContent(tag) + } else { + emptyPrContent } - .padding(.horizontal, 20) - .padding(.vertical, 22) } - .background(ADEColor.pageBackground.ignoresSafeArea()) - .navigationTitle(prNumberLabel) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button(action: onRefresh) { - if isRefreshing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: "arrow.clockwise") - } + .scrollIndicators(.hidden) + } + .background(ADEColor.pageBackground.ignoresSafeArea()) + } + + private var topBar: some View { + ZStack { + Text(sheetTitle) + .font(.headline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + .padding(.horizontal, 58) + + HStack { + Spacer() + Button(action: onRefresh) { + if isRefreshing { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.clockwise") + .font(.system(size: 16, weight: .bold)) } - .disabled(isRefreshing) - .accessibilityLabel("Refresh pull request details") } + .foregroundStyle(ADEColor.accent) + .frame(width: 36, height: 36) + .background(ADEColor.surfaceBackground.opacity(0.86), in: Circle()) + .overlay(Circle().stroke(ADEColor.glassBorder.opacity(0.8), lineWidth: 0.7)) + .disabled(isRefreshing) + .accessibilityLabel("Refresh pull request details") } } + .padding(.horizontal, 18) + .padding(.top, 18) + .padding(.bottom, 8) } private func existingPrContent(_ tag: LanePrTag) -> some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 10) { - Image(systemName: "arrow.triangle.pull") - .font(.system(size: 16, weight: .bold)) - .foregroundStyle(lanePullRequestTint(tag.state)) - .frame(width: 34, height: 34) - .background(lanePullRequestTint(tag.state).opacity(0.12), in: Circle()) - - VStack(alignment: .leading, spacing: 3) { - Text(formatLanePrBadgeLabel(tag)) - .font(.caption.weight(.semibold)) - .foregroundStyle(lanePullRequestTint(tag.state)) - Text(lanePrStateLabel(tag.state)) - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } - Spacer(minLength: 0) - } - - Text(tag.title) - .font(.headline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - .fixedSize(horizontal: false, vertical: true) + let branches = workChatPrBranches(pr: pr, summary: summary, tag: tag) + let stateTint = workChatPrStateTint(tag.state) + let branchTint = laneColor ?? stateTint + + return VStack(alignment: .leading, spacing: 12) { + WorkChatPrSummaryHeader( + title: tag.title, + updatedText: "Updated \(prRelativeTime(tag.updatedAt))", + symbol: workChatPrStateSymbol(tag.state), + tint: stateTint + ) - Text("Updated \(prRelativeTime(tag.updatedAt))") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - } + WorkChatPrBranchFlowCard( + headBranch: branches.head, + baseBranch: branches.base, + tint: branchTint + ) - VStack(alignment: .leading, spacing: 10) { - if let branchLine = workChatPrBranchLine(pr: pr, summary: summary, tag: tag) { - WorkChatPrDetailRow(label: "Branch", value: branchLine, symbol: "arrow.triangle.branch") - if pr != nil || summary != nil { - WorkChatPrDetailRow(label: "Changes", value: "+\(additions) / -\(deletions)", symbol: "plus.forwardslash.minus") - } - } else if !tag.headBranch.isEmpty { - WorkChatPrDetailRow(label: "Branch", value: tag.headBranch, symbol: "arrow.triangle.branch") - } - if let checksStatus, !checksStatus.isEmpty { - WorkChatPrDetailRow(label: "Checks", value: workChatPrStatusLabel(checksStatus), symbol: workChatPrChecksSymbol(checksStatus)) - } - if let reviewStatus, !reviewStatus.isEmpty, reviewStatus != "none" { - WorkChatPrDetailRow(label: "Review", value: workChatPrStatusLabel(reviewStatus), symbol: "person.crop.circle.badge.checkmark") - } - if let mergeLine = workChatPrMergeLine(snapshot?.status) { - WorkChatPrDetailRow(label: "Merge", value: mergeLine, symbol: "arrow.merge") - } + HStack(spacing: 10) { + WorkChatPrChangesMetricCard(additions: additions, deletions: deletions) + WorkChatPrChecksMetricCard(status: checksStatus) } if let errorMessage, !errorMessage.isEmpty { @@ -214,50 +197,37 @@ struct WorkChatPrDetailsSheet: View { .foregroundStyle(ADEColor.danger) } - VStack(spacing: 10) { - Button(action: onOpenPrsTab) { - Label("PRs tab", systemImage: "rectangle.grid.1x2") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - - HStack(spacing: 10) { - Button(action: onOpenGitHub) { - Label("GitHub", systemImage: "link") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(githubUrl.isEmpty) - - Button(action: onCopyLink) { - Label(copiedLink ? "Copied" : "Copy", systemImage: copiedLink ? "checkmark" : "doc.on.doc") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) - .disabled(githubUrl.isEmpty) - } + HStack(spacing: 10) { + WorkChatPrActionButton( + title: "Open in ADE", + symbol: "rectangle.grid.1x2", + tint: ADEColor.accent, + prominent: true, + action: onOpenPrsTab + ) + + WorkChatPrActionButton( + title: "Open in GitHub", + symbol: "link", + tint: ADEColor.accent, + disabled: githubUrl.isEmpty, + action: onOpenGitHub + ) } } + .padding(.horizontal, 18) + .padding(.top, 6) + .padding(.bottom, 18) } private var emptyPrContent: some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 10) { - Image(systemName: "arrow.triangle.pull") - .font(.system(size: 20, weight: .bold)) - .foregroundStyle(ADEColor.accent) - .frame(width: 42, height: 42) - .background(ADEColor.accent.opacity(0.12), in: Circle()) - - Text("No pull request yet") - .font(.headline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - - Text("Create one from this lane or open the PRs tab with the lane preselected.") - .font(.subheadline) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } + VStack(alignment: .leading, spacing: 12) { + WorkChatPrSummaryHeader( + title: "No pull request yet", + updatedText: "Create one from this lane or open PRs with the lane preselected.", + symbol: "arrow.triangle.pull", + tint: ADEColor.accent + ) if let createBlockedReason, !createBlockedReason.isEmpty { Text(createBlockedReason) @@ -265,19 +235,22 @@ struct WorkChatPrDetailsSheet: View { .foregroundStyle(ADEColor.warning) } - VStack(spacing: 10) { - Button(action: onCreate) { - Label("Create pull request", systemImage: "plus") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .disabled(!canCreate) - - Button(action: onOpenPrsTab) { - Label("Open PR in PRs tab", systemImage: "rectangle.grid.1x2") - .frame(maxWidth: .infinity) - } - .buttonStyle(.bordered) + HStack(spacing: 10) { + WorkChatPrActionButton( + title: "Create PR", + symbol: "plus", + tint: ADEColor.accent, + prominent: true, + disabled: !canCreate, + action: onCreate + ) + + WorkChatPrActionButton( + title: "Open in ADE", + symbol: "rectangle.grid.1x2", + tint: ADEColor.accent, + action: onOpenPrsTab + ) } if let errorMessage, !errorMessage.isEmpty { @@ -286,86 +259,269 @@ struct WorkChatPrDetailsSheet: View { .foregroundStyle(ADEColor.danger) } } + .padding(.horizontal, 18) + .padding(.top, 6) + .padding(.bottom, 18) } } -private struct WorkChatPrDetailRow: View { - let label: String - let value: String +private struct WorkChatPrSummaryHeader: View { + let title: String + let updatedText: String let symbol: String + let tint: Color var body: some View { - HStack(alignment: .top, spacing: 10) { + HStack(alignment: .top, spacing: 12) { Image(systemName: symbol) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 18, height: 18) - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption.weight(.semibold)) - .foregroundStyle(ADEColor.textSecondary) - Text(value) - .font(.subheadline) + .font(.system(size: 18, weight: .bold)) + .foregroundStyle(tint) + .frame(width: 38, height: 38) + .background(tint.opacity(0.14), in: Circle()) + + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(.headline.weight(.semibold)) .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) .fixedSize(horizontal: false, vertical: true) + Text(updatedText) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .lineLimit(2) } + Spacer(minLength: 0) } + } +} + +private struct WorkChatPrBranchFlowCard: View { + let headBranch: String + let baseBranch: String? + let tint: Color + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Label("Branch", systemImage: "arrow.triangle.branch") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + + HStack(spacing: 8) { + branchPill(headBranch, tint: tint, emphasized: true) + .frame(maxWidth: .infinity, alignment: .leading) + + if let baseBranch, !baseBranch.isEmpty { + Image(systemName: "arrow.right") + .font(.system(size: 12, weight: .bold)) + .foregroundStyle(tint) + branchPill(baseBranch, tint: ADEColor.textSecondary, emphasized: false) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } .padding(12) .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tint.opacity(0.2), lineWidth: 0.8) + ) + } + + private func branchPill(_ branch: String, tint: Color, emphasized: Bool) -> some View { + Text(branch) + .font(.caption.weight(.semibold)) + .foregroundStyle(emphasized ? tint : ADEColor.textPrimary) + .lineLimit(1) + .truncationMode(.middle) + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background(tint.opacity(emphasized ? 0.16 : 0.08), in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(emphasized ? 0.34 : 0.16), lineWidth: 0.7) + ) } } -private func workChatPrStatusLabel(_ status: String) -> String { - status - .replacingOccurrences(of: "_", with: " ") - .split(separator: " ") - .map { word in - word.prefix(1).uppercased() + String(word.dropFirst()) +private struct WorkChatPrChangesMetricCard: View { + let additions: Int + let deletions: Int + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Changes", systemImage: "plus.forwardslash.minus") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + + HStack(spacing: 8) { + Text("+\(additions)") + .foregroundStyle(ADEColor.success) + Text("/").foregroundStyle(ADEColor.textMuted) + Text("-\(deletions)") + .foregroundStyle(ADEColor.danger) + } + .font(.headline.weight(.semibold)) + .lineLimit(1) + .minimumScaleFactor(0.78) } - .joined(separator: " ") + .frame(maxWidth: .infinity, minHeight: 70, alignment: .leading) + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + } +} + +private struct WorkChatPrChecksMetricCard: View { + let status: String? + + private var normalized: String { + status?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" + } + + private var tint: Color { + workChatPrChecksTint(normalized) + } + + var body: some View { + VStack(alignment: .leading, spacing: 7) { + Label("Checks", systemImage: workChatPrChecksSymbol(normalized)) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textSecondary) + + Text(workChatPrChecksLabel(normalized)) + .font(.headline.weight(.semibold)) + .foregroundStyle(tint) + .lineLimit(1) + .minimumScaleFactor(0.78) + } + .frame(maxWidth: .infinity, minHeight: 70, alignment: .leading) + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(tint.opacity(0.16), lineWidth: 0.8) + ) + } } -private func workChatPrBranchLine(pr: PullRequestListItem?, summary: PrSummary?, tag: LanePrTag) -> String? { +private struct WorkChatPrActionButton: View { + let title: String + let symbol: String + let tint: Color + var prominent = false + var disabled = false + let action: () -> Void + + var body: some View { + Button { + guard !disabled else { return } + action() + } label: { + Label(title, systemImage: symbol) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(disabled ? ADEColor.textSecondary.opacity(0.45) : (prominent ? Color.white : tint)) + .lineLimit(1) + .minimumScaleFactor(0.82) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background(buttonBackground, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(disabled ? ADEColor.glassBorder.opacity(0.55) : tint.opacity(prominent ? 0 : 0.28), lineWidth: 0.8) + ) + } + .buttonStyle(.plain) + .disabled(disabled) + } + + private var buttonBackground: Color { + if disabled { + return ADEColor.surfaceBackground.opacity(0.45) + } + return prominent ? tint : tint.opacity(0.13) + } +} + +private func workChatPrBranches(pr: PullRequestListItem?, summary: PrSummary?, tag: LanePrTag) -> (head: String, base: String?) { if let pr { - return "\(pr.headBranch) -> \(pr.baseBranch)" + return (pr.headBranch, pr.baseBranch) } if let summary { - return "\(summary.headBranch) -> \(summary.baseBranch)" + return (summary.headBranch, summary.baseBranch) } let head = tag.headBranch.trimmingCharacters(in: .whitespacesAndNewlines) - return head.isEmpty ? nil : head + return (head.isEmpty ? "Branch unavailable" : head, nil) +} + +private func workChatPrStateSymbol(_ state: String) -> String { + switch state { + case "merged": + return "arrow.merge" + case "closed": + return "xmark.circle" + default: + return "arrow.triangle.pull" + } +} + +private func workChatPrStateTint(_ state: String) -> Color { + switch state { + case "open": + return Color(red: 0x60 / 255, green: 0xA5 / 255, blue: 0xFA / 255) + case "merged": + return Color(red: 0x4A / 255, green: 0xDE / 255, blue: 0x80 / 255) + case "draft": + return ADEColor.warning + case "closed": + return Color(red: 0xA1 / 255, green: 0xA1 / 255, blue: 0xAA / 255) + default: + return ADEColor.textSecondary + } } private func workChatPrChecksSymbol(_ status: String) -> String { switch status { - case "passing": + case "passing", "passed", "success": return "checkmark.circle.fill" - case "failing": + case "failing", "failed", "failure", "error": return "xmark.circle.fill" - case "pending": + case "pending", "queued", "running", "in_progress": return "clock.fill" default: return "circle" } } -private func workChatPrMergeLine(_ status: PrStatus?) -> String? { - guard let status else { return nil } - if status.mergeConflicts { - return "Merge conflicts" - } - if status.behindBaseBy > 0 { - return "\(status.behindBaseBy) behind base" - } - if status.mergeStateStatus == .draft { - return "Draft" - } - if status.mergeabilityComputing == true { - return "Computing" +private func workChatPrChecksTint(_ status: String) -> Color { + switch status { + case "passing", "passed", "success": + return ADEColor.success + case "failing", "failed", "failure", "error": + return ADEColor.danger + case "pending", "queued", "running", "in_progress": + return ADEColor.warning + default: + return ADEColor.textSecondary } - if status.isMergeable { - return "Mergeable" +} + +private func workChatPrChecksLabel(_ status: String) -> String { + switch status { + case "", "none", "unknown": + return "None" + case "passing", "passed", "success": + return "Passing" + case "failing", "failed", "failure", "error": + return "Failing" + case "pending", "queued", "running", "in_progress": + return "Pending" + default: + return status + .replacingOccurrences(of: "_", with: " ") + .split(separator: " ") + .map { word in + word.prefix(1).uppercased() + String(word.dropFirst()) + } + .joined(separator: " ") } - return "Blocked" } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 1c4af107f..8ad8bce99 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -768,7 +768,7 @@ struct WorkChatSessionView: View { ScrollViewReader { proxy in VStack(spacing: 0) { ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 14) { sessionOverviewSection timelineSection(proxy: proxy) streamingStatusSection diff --git a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift index d5a27cbfb..768baef59 100644 --- a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift +++ b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift @@ -19,28 +19,12 @@ struct WorkContextCompactDivider: View { } var body: some View { - HStack(spacing: 10) { - Rectangle() - .fill( - LinearGradient( - colors: [.clear, ADEColor.warning.opacity(0.22), .clear], - startPoint: .leading, - endPoint: .trailing - ) - ) - .frame(height: 0.6) + HStack(spacing: 8) { + dividerLine(startPoint: .leading, endPoint: .trailing) chip - Rectangle() - .fill( - LinearGradient( - colors: [.clear, ADEColor.warning.opacity(0.22), .clear], - startPoint: .trailing, - endPoint: .leading - ) - ) - .frame(height: 0.6) + dividerLine(startPoint: .trailing, endPoint: .leading) } .padding(.vertical, 4) .accessibilityElement(children: .combine) @@ -49,51 +33,97 @@ struct WorkContextCompactDivider: View { @ViewBuilder private var chip: some View { + ViewThatFits(in: .horizontal) { + chipContainer { + chipContent( + title: isInProgress ? "Compacting context..." : "Context compacted", + showsTokenCount: true, + showsTrigger: true + ) + } + chipContainer { + chipContent( + title: isInProgress ? "Compacting..." : "Compacted", + showsTokenCount: false, + showsTrigger: true + ) + } + chipContainer { + chipContent( + title: isInProgress ? "Compacting..." : "Compacted", + showsTokenCount: false, + showsTrigger: false + ) + } + } + .layoutPriority(1) + } + + private func dividerLine(startPoint: UnitPoint, endPoint: UnitPoint) -> some View { + Rectangle() + .fill( + LinearGradient( + colors: [.clear, ADEColor.warning.opacity(0.22), .clear], + startPoint: startPoint, + endPoint: endPoint + ) + ) + .frame(minWidth: 8, maxWidth: .infinity) + .frame(height: 0.6) + .layoutPriority(-1) + } + + private func chipContainer(@ViewBuilder content: () -> Content) -> some View { + content() + .foregroundStyle(ADEColor.warning) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(ADEColor.warning.opacity(0.08), in: Capsule()) + .overlay( + Capsule().stroke(ADEColor.warning.opacity(0.2), lineWidth: 0.5) + ) + } + + @ViewBuilder + private func chipContent(title: String, showsTokenCount: Bool, showsTrigger: Bool) -> some View { HStack(spacing: 6) { if isInProgress { ProgressView() .controlSize(.mini) .tint(ADEColor.warning) - Text("Compacting context…") + Text(title) .font(.caption2.weight(.semibold)) .tracking(0.3) - if let triggerLabel = parsed.triggerLabel { - Text(triggerLabel) - .font(.caption2.weight(.bold)) - .tracking(0.3) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(ADEColor.warning.opacity(0.14), in: Capsule()) - } } else { Image(systemName: "rectangle.compress.vertical") .font(.caption2.weight(.bold)) - Text("Context compacted") + Text(title) .font(.caption2.weight(.semibold)) .tracking(0.3) - if let tokensFreedLabel = parsed.tokensFreedLabel { + } + + if showsTokenCount, let tokensFreedLabel = parsed.tokensFreedLabel { + Group { Text("·").foregroundStyle(ADEColor.warning.opacity(0.4)) Text(tokensFreedLabel) .font(.caption2.monospaced()) .foregroundStyle(ADEColor.warning.opacity(0.7)) } - if let triggerLabel = parsed.triggerLabel { - Text(triggerLabel) - .font(.caption2.weight(.bold)) - .tracking(0.3) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background(ADEColor.warning.opacity(0.14), in: Capsule()) - } + } + + if showsTrigger, let triggerLabel = parsed.triggerLabel { + Text(triggerLabel) + .font(.caption2.weight(.bold)) + .tracking(0.3) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background(ADEColor.warning.opacity(0.14), in: Capsule()) } } - .foregroundStyle(ADEColor.warning) - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background(ADEColor.warning.opacity(0.08), in: Capsule()) - .overlay( - Capsule().stroke(ADEColor.warning.opacity(0.2), lineWidth: 0.5) - ) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 632037638..1787b2eaa 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -896,11 +896,23 @@ private func duplicateWorkTextEnvelopeCount(_ transcript: [WorkChatEnvelope]) -> func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [WorkChatEnvelope] { guard !transcript.isEmpty else { return transcript } var resolvedSteerIds = Set() + var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { switch envelope.event { - case .userMessage(_, _, _, let steerId, let deliveryState, _): - if let steerId, deliveryState != "queued" { - resolvedSteerIds.insert(steerId) + case .userMessage(let text, _, _, let steerId, let deliveryState, _): + if let steerId, deliveryState == "queued" { + let normalizedText = normalizedQueuedSteerText(text) + if !normalizedText.isEmpty { + queuedSteerIdsByText[normalizedText, default: []].insert(steerId) + } + } else { + if let steerId { + resolvedSteerIds.insert(steerId) + } + let normalizedText = normalizedQueuedSteerText(text) + if !normalizedText.isEmpty, let matchingQueuedSteerIds = queuedSteerIdsByText[normalizedText] { + resolvedSteerIds.formUnion(matchingQueuedSteerIds) + } } case .systemNotice(_, let message, _, _, let steerId): if let steerId, workSystemNoticeResolvesQueuedSteer(message) { @@ -1449,16 +1461,29 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin var queue: [String: WorkPendingSteerModel] = [:] var order: [String] = [] var resolved = Set() + var queuedSteerIdsByText: [String: Set] = [:] for envelope in sortedWorkChatEnvelopes(transcript) { switch envelope.event { case .userMessage(let text, _, let turnId, let steerId, let deliveryState, _): - guard let steerId, !resolved.contains(steerId) else { continue } - if deliveryState == "queued" { + if let steerId, deliveryState == "queued", !resolved.contains(steerId) { if queue[steerId] == nil { order.append(steerId) } queue[steerId] = WorkPendingSteerModel(id: steerId, text: text, turnId: turnId, timestamp: envelope.timestamp) - } else if deliveryState == "delivered" || deliveryState == "inline" || deliveryState == "failed" { - queue.removeValue(forKey: steerId) - resolved.insert(steerId) + let normalizedText = normalizedQueuedSteerText(text) + if !normalizedText.isEmpty { + queuedSteerIdsByText[normalizedText, default: []].insert(steerId) + } + } else { + if let steerId, deliveryState == "delivered" || deliveryState == "inline" || deliveryState == "failed" { + queue.removeValue(forKey: steerId) + resolved.insert(steerId) + } + let normalizedText = normalizedQueuedSteerText(text) + if !normalizedText.isEmpty, let matchingQueuedSteerIds = queuedSteerIdsByText[normalizedText] { + for queuedSteerId in matchingQueuedSteerIds { + queue.removeValue(forKey: queuedSteerId) + resolved.insert(queuedSteerId) + } + } } case .systemNotice(_, let message, _, _, let steerId): if let steerId, workSystemNoticeResolvesQueuedSteer(message) { @@ -1472,6 +1497,13 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin return order.compactMap { queue[$0] } } +func normalizedQueuedSteerText(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression) + .lowercased() +} + func workSystemNoticeResolvesQueuedSteer(_ message: String) -> Bool { let normalized = message.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() return normalized.contains("cancelled") diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index d83dd675f..e9b73cbea 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -92,6 +92,18 @@ struct WorkModelPickerSheet: View { !catalog.isEmpty } + private var cursorCatalogSourceValue: String { + switch cursorAvailabilityMode { + case .chat: return "sdk" + case .cli: return "cli" + } + } + + private func cursorCatalogSource(for refreshProvider: String? = nil) -> String? { + guard refreshProvider == nil || refreshProvider == "cursor" else { return nil } + return cursorCatalogSourceValue + } + var body: some View { NavigationStack { VStack(spacing: 0) { @@ -438,7 +450,10 @@ struct WorkModelPickerSheet: View { } do { - let hostCatalog = try await syncService.getChatModelCatalog(mode: "cached") + let hostCatalog = try await syncService.getChatModelCatalog( + mode: "cached", + cursorSource: cursorCatalogSource() + ) guard !Task.isCancelled else { return } apply(hostCatalog: hostCatalog) } catch { @@ -457,11 +472,19 @@ struct WorkModelPickerSheet: View { } guard let refreshProvider else { return } do { - let hostCatalog = try await syncService.getChatModelCatalog(mode: "refresh-stale", refreshProvider: refreshProvider) + let hostCatalog = try await syncService.getChatModelCatalog( + mode: "refresh-stale", + refreshProvider: refreshProvider, + cursorSource: cursorCatalogSource(for: refreshProvider) + ) guard !Task.isCancelled else { return } apply(hostCatalog: hostCatalog) if hostCatalog.stale == true { - let freshCatalog = try await syncService.getChatModelCatalog(mode: "force", refreshProvider: refreshProvider) + let freshCatalog = try await syncService.getChatModelCatalog( + mode: "force", + refreshProvider: refreshProvider, + cursorSource: cursorCatalogSource(for: refreshProvider) + ) guard !Task.isCancelled else { return } apply(hostCatalog: freshCatalog) } diff --git a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift index 80cdbcc57..e5411806d 100644 --- a/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkNavigationAndTranscriptHelpers.swift @@ -709,7 +709,7 @@ func extractWorkNavigationTargets(from text: String) -> WorkNavigationTargets { } func workRegexMatches(pattern: String, in text: String) -> [String] { - guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + guard let regex = ADECodeRenderingCache.shared.regex(for: pattern) else { return [] } let range = NSRange(location: 0, length: (text as NSString).length) return regex.matches(in: text, range: range).compactMap { match in Range(match.range, in: text).map { String(text[$0]) } diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 6ba805b08..59f7f5b80 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -84,24 +84,6 @@ struct WorkSessionTypeSwitcher: View { } } -/// Serializes the single allowed resume of the auto-create lane-naming race so -/// the naming call and the 10s timeout can both attempt to finish it: the first -/// to arrive resumes the continuation, and any later arrival is a no-op. This -/// lets the timeout proceed without waiting on a stuck host naming command. -private actor AutoLaneNameResolver { - private var continuation: CheckedContinuation? - - init(_ continuation: CheckedContinuation) { - self.continuation = continuation - } - - func resume(with value: String) { - guard let continuation else { return } - self.continuation = nil - continuation.resume(returning: value) - } -} - /// `yyyyMMdd-HHmmss` stamp for generic auto-created lane fallback names. private let workAutoLaneNameFormatter: DateFormatter = { let formatter = DateFormatter() @@ -326,8 +308,8 @@ struct WorkNewChatScreen: View { /// advertised fast model and wrongly hide the toggle. @State private var selectedModelOption: WorkModelOption? @State private var sessionMode: WorkNewSessionMode = .chat - /// Status banner shown above the composer during auto-create lane naming, - /// mirroring desktop's "Naming lane with … → Creating lane…" flow. + /// Status banner shown above the composer while an auto-created lane is being + /// minted before the chat/CLI session starts. @State private var autoCreateStatus: String? init( @@ -630,49 +612,12 @@ struct WorkNewChatScreen: View { let targetLaneId: String var createdLaneId: String? if isAutoCreateLane { - // Resolve the lane name first (desktop parity): try the host's small - // naming model, but never let naming block or fail lane creation. Any - // error / timeout / offline / host-disabled falls back to the same - // deterministic name mobile already used. - let deterministicName = autoCreatedLaneName(opener: opener) - var resolvedName = deterministicName - if let contextLaneId = defaultNewSessionLane?.id, !contextLaneId.isEmpty { - withAnimation(.snappy(duration: 0.16)) { - autoCreateStatus = "Naming lane with \(prettyNewChatModelName(modelId))…" - } - // Race the naming call against a 10s deadline (mirrors desktop's - // Promise.race([suggestLaneName, timeout])). A Swift task group would - // await BOTH children on scope exit, and the sync request continuation - // is not cancellation-aware, so a slow/stuck host naming command could - // keep the banner and lane creation blocked well past 10s. Using a - // continuation lets us proceed the instant the timeout wins; the losing - // task keeps running detached and its result is harmlessly discarded. - // The naming task swallows its own errors into the deterministic - // fallback so a host/offline failure never throws here. - resolvedName = await withCheckedContinuation { (continuation: CheckedContinuation) in - let resolver = AutoLaneNameResolver(continuation) - Task { - let name = (try? await syncService.suggestLaneName( - laneId: contextLaneId, - prompt: opener, - modelId: modelId, - fallbackName: deterministicName - )) ?? deterministicName - await resolver.resume(with: name) - } - Task { - try? await Task.sleep(nanoseconds: 10_000_000_000) - await resolver.resume(with: deterministicName) - } - } - } - withAnimation(.snappy(duration: 0.16)) { autoCreateStatus = "Creating lane…" } do { let lane = try await syncService.createLane( - name: resolvedName, + name: autoCreatedLaneName(opener: opener), description: opener.isEmpty ? "" : String(opener.prefix(280)) ) targetLaneId = lane.id diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index a7a25d7cb..b956f104f 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -357,6 +357,40 @@ extension WorkRootScreen { syncService.requestedWorkSessionNavigation = nil } + @MainActor + func handleRequestedWorkLaneNavigation(proxy: ScrollViewProxy) async { + guard let request = syncService.requestedWorkLaneNavigation else { return } + let sectionId = "lane:\(request.laneId)" + + navigationMutationPending = false + selectedSessionTransitionId = nil + path = NavigationPath() + searchText = "" + selectedLaneId = "all" + selectedStatus = .all + sessionOrganizationRaw = WorkSessionOrganization.byLane.rawValue + + var collapsed = collapsedSectionIds + if collapsed.remove(sectionId) != nil { + collapsedSectionIdsStorage = workSerializeCollapsedSectionIds(collapsed) + } + + if lanes.isEmpty || !lanes.contains(where: { $0.id == request.laneId }) { + await reload(refreshRemote: isLive) + } + scheduleSessionPresentationRebuild() + + // Let the context menu dismiss, the tab switch complete, and the by-lane + // presentation render before asking the List to reveal the lane header. + try? await Task.sleep(for: .milliseconds(650)) + guard syncService.requestedWorkLaneNavigation?.id == request.id else { return } + + withAnimation(.snappy) { + proxy.scrollTo(sectionId, anchor: .top) + } + syncService.requestedWorkLaneNavigation = nil + } + func deleteChatSession(_ session: TerminalSessionSummary) { Task { do { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index f53678da5..8976667a1 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -4,7 +4,7 @@ import AVKit let workDateFormatter = ISO8601DateFormatter() -private let workRootBottomTabBarScrollMargin: CGFloat = 150 +private let workRootBottomTabBarScrollMargin: CGFloat = 24 func resolvedWorkArchivedSessionIds( localStorage: String, @@ -244,6 +244,10 @@ struct WorkRootScreen: View { syncService.requestedWorkSessionNavigation?.id } + var workLaneNavigationRequestKey: String? { + syncService.requestedWorkLaneNavigation?.id + } + var body: some View { NavigationStack(path: $path) { ScrollViewReader { proxy in @@ -348,6 +352,7 @@ struct WorkRootScreen: View { } } ) + .id(group.id) .listRowBackground(Color.clear) .listRowSeparator(.hidden) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 2, trailing: 16)) @@ -425,12 +430,6 @@ struct WorkRootScreen: View { } } } - - Color.clear - .frame(height: workRootBottomTabBarScrollMargin) - .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) } .listStyle(.plain) .listSectionSpacing(.compact) @@ -558,18 +557,36 @@ struct WorkRootScreen: View { guard isTabActive, workSessionNavigationRequestKey != nil else { return } await handleRequestedWorkSessionNavigation() } + .task(id: workLaneNavigationRequestKey) { + guard isTabActive, workLaneNavigationRequestKey != nil else { return } + await handleRequestedWorkLaneNavigation(proxy: proxy) + } .onAppear { - guard isTabActive, syncService.requestedWorkSessionNavigation != nil else { return } - Task { await handleRequestedWorkSessionNavigation() } + guard isTabActive else { return } + if syncService.requestedWorkLaneNavigation != nil { + Task { await handleRequestedWorkLaneNavigation(proxy: proxy) } + } + if syncService.requestedWorkSessionNavigation != nil { + Task { await handleRequestedWorkSessionNavigation() } + } } .onChange(of: isTabActive) { _, active in - guard active, syncService.requestedWorkSessionNavigation != nil else { return } - Task { await handleRequestedWorkSessionNavigation() } + guard active else { return } + if syncService.requestedWorkLaneNavigation != nil { + Task { await handleRequestedWorkLaneNavigation(proxy: proxy) } + } + if syncService.requestedWorkSessionNavigation != nil { + Task { await handleRequestedWorkSessionNavigation() } + } } .onChange(of: syncService.requestedWorkSessionNavigation?.id) { _, requestId in guard isTabActive, requestId != nil else { return } Task { await handleRequestedWorkSessionNavigation() } } + .onChange(of: syncService.requestedWorkLaneNavigation?.id) { _, requestId in + guard isTabActive, requestId != nil else { return } + Task { await handleRequestedWorkLaneNavigation(proxy: proxy) } + } .navigationDestination(for: WorkSessionRoute.self) { route in let routeTransitionNamespace = route.openingPrompt == nil && selectedSessionTransitionId == route.sessionId ? (ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? sessionTransitionNamespace : nil) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 2557085f3..0b81f2320 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -599,23 +599,29 @@ extension WorkSessionDestinationView { } /// Resolve the lane's primary cached PR for the header overflow menu. Runs - /// inside a `.task(id: headerMenuLaneId)`, so SwiftUI cancels and replaces it - /// whenever the lane changes. We clear any stale PR up front and re-check the - /// task identity (cancellation + still-current lane) after the await so a - /// slow lookup for a previous lane can never surface its PR on a new lane. + /// inside a `.task(id: headerMenuPrLookupKey)`, so SwiftUI cancels and + /// replaces it whenever the lane or the PR projection changes. Re-resolves + /// are stale-while-revalidate: the current PR is only cleared up front when + /// the *lane* changed (so a slow lookup for a previous lane can never surface + /// its PR on a new lane), never on same-lane projection refreshes — clearing + /// there collapses the header menu's PR section to the "no PR" branch for a + /// frame and rebuilds the open liquid-glass menu mid-interaction. Final + /// assignments are equality-guarded for the same reason. @MainActor func resolveLaneOpenPr( for laneId: String, forceGithubRefresh: Bool = false, clearBeforeLoad: Bool = true ) async { - if clearBeforeLoad { + let trimmed = laneId.trimmingCharacters(in: .whitespacesAndNewlines) + let laneChanged = trimmed != lastResolvedPrLaneId + if clearBeforeLoad, laneChanged { laneOpenPr = nil lanePrSummary = nil lanePrTag = nil } - let trimmed = laneId.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { + lastResolvedPrLaneId = trimmed laneOpenPr = nil lanePrSummary = nil lanePrTag = nil @@ -643,9 +649,10 @@ extension WorkSessionDestinationView { let stillCurrent = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) == trimmed guard !Task.isCancelled, stillCurrent else { return } - lanePrSummary = resolution.summary - lanePrTag = resolution.tag - laneOpenPr = resolution.mappedPr + lastResolvedPrLaneId = trimmed + if lanePrSummary != resolution.summary { lanePrSummary = resolution.summary } + if lanePrTag != resolution.tag { lanePrTag = resolution.tag } + if laneOpenPr != resolution.mappedPr { laneOpenPr = resolution.mappedPr } } /// Navigate to the resolved lane PR. No-op (rather than crash) if the PR was @@ -757,7 +764,9 @@ extension WorkSessionDestinationView { do { let snapshot = try await syncService.fetchPrMobileSnapshot() guard !Task.isCancelled else { return } - prCreateCapabilities = snapshot.createCapabilities + if prCreateCapabilities != snapshot.createCapabilities { + prCreateCapabilities = snapshot.createCapabilities + } } catch { guard !Task.isCancelled else { return } prCreateCapabilities = nil diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 67b2f8def..a570623ff 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -85,7 +85,9 @@ func latestActiveTurnId(from transcript: [WorkChatEnvelope]) -> String? { return nil } -func transcriptContainsResolvedSteer(_ transcript: [WorkChatEnvelope], steerId: String) -> Bool { +func transcriptContainsResolvedSteer(_ transcript: [WorkChatEnvelope], steer: WorkPendingSteerModel) -> Bool { + let steerId = steer.id + let normalizedSteerText = normalizedQueuedSteerText(steer.text) for envelope in sortedWorkChatEnvelopes(transcript).reversed() { switch envelope.event { case .userMessage(_, _, _, let candidate, let deliveryState, _): @@ -98,6 +100,13 @@ func transcriptContainsResolvedSteer(_ transcript: [WorkChatEnvelope], steerId: continue } } + guard !normalizedSteerText.isEmpty else { return false } + for envelope in sortedWorkChatEnvelopes(transcript).reversed() { + guard case .userMessage(let text, _, _, let candidate, let deliveryState, _) = envelope.event, + normalizedQueuedSteerText(text) == normalizedSteerText + else { continue } + return candidate != steerId || deliveryState != "queued" + } return false } @@ -254,6 +263,23 @@ struct WorkLiveTranscriptCache { } } +private struct WorkChatTranscriptPresentationCacheEntry { + var transcript: [WorkChatEnvelope] + var fallbackEntries: [AgentChatTranscriptEntry] + var olderTranscriptCursor: Int? + var olderChatEventHistoryCursor: Int? + var storedAt: Date + + var hasVisibleTranscript: Bool { + !transcript.isEmpty || !fallbackEntries.isEmpty + } +} + +@MainActor +private var workChatTranscriptPresentationCacheBySession: [String: WorkChatTranscriptPresentationCacheEntry] = [:] + +private let workChatTranscriptPresentationCacheLimit = 8 + private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !raw.isEmpty else { return nil } @@ -277,6 +303,7 @@ struct WorkSessionDestinationView: View { let transitionNamespace: Namespace.ID? let isLive: Bool let navigationChrome: WorkSessionNavigationChrome + var forceFreshTranscriptOnOpen = false var showsLaneActions = true var navigationTitleOverride: String? /// Lanes forwarded to the chat composer for `@`-mention autocomplete. @@ -333,6 +360,9 @@ struct WorkSessionDestinationView: View { @State var laneOpenPr: PullRequestListItem? @State var lanePrSummary: PrSummary? @State var lanePrTag: LanePrTag? + /// Lane the last completed PR resolve ran for; lets same-lane re-resolves + /// keep showing the current PR instead of clearing it first. + @State var lastResolvedPrLaneId: String? @State var prCreateCapabilities: PrCreateCapabilities? @State var createPrPresented = false @State var createPrAfterDetailsDismiss = false @@ -351,6 +381,7 @@ struct WorkSessionDestinationView: View { @State var lastCanonicalTranscriptRefreshAt = Date.distantPast @State var lastArtifactRefreshAt = Date.distantPast @State var initialTranscriptTailHydrated = false + @State var openingLoadInFlight = false @State var emptyTranscriptHydrationInFlight = false @State var canonicalTranscriptRefreshInFlight = false @State var handledOpeningPromptKey: String? @@ -363,6 +394,7 @@ struct WorkSessionDestinationView: View { } transcript = next transcriptRenderSignature = workChatEnvelopeListRenderSignature(next) + cacheCurrentTranscriptPresentationIfNeeded() } @MainActor @@ -372,6 +404,7 @@ struct WorkSessionDestinationView: View { } fallbackEntries = next fallbackEntriesRenderSignature = workFallbackEntriesRenderSignature(next) + cacheCurrentTranscriptPresentationIfNeeded() } @MainActor @@ -382,6 +415,47 @@ struct WorkSessionDestinationView: View { initialTranscriptTailHydrated = false } + @MainActor + func cacheCurrentTranscriptPresentationIfNeeded() { + guard !transcript.isEmpty || !fallbackEntries.isEmpty else { return } + workChatTranscriptPresentationCacheBySession[sessionId] = WorkChatTranscriptPresentationCacheEntry( + transcript: transcript, + fallbackEntries: fallbackEntries, + olderTranscriptCursor: olderTranscriptCursor, + olderChatEventHistoryCursor: olderChatEventHistoryCursor, + storedAt: Date() + ) + guard workChatTranscriptPresentationCacheBySession.count > workChatTranscriptPresentationCacheLimit else { return } + let overflow = workChatTranscriptPresentationCacheBySession.count - workChatTranscriptPresentationCacheLimit + let expiredSessionIds = workChatTranscriptPresentationCacheBySession + .sorted { $0.value.storedAt < $1.value.storedAt } + .prefix(overflow) + .map(\.key) + for expiredSessionId in expiredSessionIds { + workChatTranscriptPresentationCacheBySession.removeValue(forKey: expiredSessionId) + } + } + + @MainActor + func seedTranscriptFromPresentationCacheIfNeeded() { + guard !forceFreshTranscriptOnOpen else { return } + guard transcript.isEmpty, + fallbackEntries.isEmpty, + let cached = workChatTranscriptPresentationCacheBySession[sessionId], + cached.hasVisibleTranscript + else { return } + + olderTranscriptCursor = cached.olderTranscriptCursor + olderChatEventHistoryCursor = cached.olderChatEventHistoryCursor + if !cached.transcript.isEmpty { + setTranscript(cached.transcript) + } + if !cached.fallbackEntries.isEmpty { + setFallbackEntries(cached.fallbackEntries) + } + initialTranscriptTailHydrated = true + } + @MainActor func setArtifacts(_ next: [ComputerUseArtifactSummary]) { artifacts = next @@ -529,117 +603,43 @@ struct WorkSessionDestinationView: View { .accessibilityLabel("Back to main chat") } - Menu { - Button { - Task { await prepareSubagentDrawerPresentation() } - } label: { - if subagentSnapshots.isEmpty { - Label("Subagents", systemImage: "person.2") - } else { - Label("Subagents (\(subagentSnapshots.count))", systemImage: "person.2") - } - } - - Divider() - - Button { - artifactDrawerPresented = true - } label: { - if artifacts.isEmpty { - Label("Proof", systemImage: "cube.transparent") - } else { - Label("Proof (\(artifacts.count))", systemImage: "cube.transparent") - } - } - .accessibilityHint("Opens the proof drawer") - - if showsLaneActions { - Divider() - - chatPullRequestMenuItems - } - - Divider() - - chatSessionDesktopMenuItems(session) - } label: { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(ADEColor.textSecondary) - .frame(width: 34, height: 34) - .background(ADEColor.surfaceBackground.opacity(0.9), in: Circle()) - .overlay( - Circle() - .stroke(ADEColor.glassBorder.opacity(0.75), lineWidth: 0.5) - ) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Chat actions") + WorkChatHeaderMenu( + model: headerMenuModel(session), + onShowSubagents: { Task { await prepareSubagentDrawerPresentation() } }, + onShowProof: { artifactDrawerPresented = true }, + onViewPrDetails: { presentChatPrDetails() }, + onOpenPrsTab: { openLaneOpenPr() }, + onOpenGitHub: { openLanePrOnGitHub() }, + onCopyPrLink: { copyLanePrLink() }, + onOpenPrCreation: { openPrCreationInPrsTab() }, + onOpenLane: { openSessionLane() }, + onRename: { presentSessionRename() }, + onDelete: { Task { await deleteCurrentChatSession() } }, + onCopySessionId: { copyCurrentSessionId() }, + onCopySessionDeepLink: { copyCurrentSessionDeepLink() }, + onTogglePinned: { Task { await toggleCurrentSessionPinned() } } + ) + .equatable() } } else { EmptyView() } } - @ViewBuilder - private var chatPullRequestMenuItems: some View { - Menu { - Button { - presentChatPrDetails() - } label: { - Label("View PR details", systemImage: "sidebar.trailing") - } - - if let tag = lanePrTag { - Button { - openLaneOpenPr() - } label: { - Label("PRs tab", systemImage: "rectangle.grid.1x2") - } - .accessibilityHint("Opens \(formatLanePrBadgeLabel(tag)) in the PRs tab") - - Button { - openLanePrOnGitHub() - } label: { - Label("Open on GitHub", systemImage: "link") - } - .disabled(lanePrGitHubUrlString.isEmpty) - - Button { - copyLanePrLink() - } label: { - if prLinkCopied { - Label("Copied link", systemImage: "checkmark") - } else { - Label("Copy link", systemImage: "doc.on.doc") - } - } - .disabled(lanePrGitHubUrlString.isEmpty) - } else { - Button { - openPrCreationInPrsTab() - } label: { - Label("Open PR in PRs tab", systemImage: "rectangle.grid.1x2") - } - .disabled(headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - if let blockedReason = createPullRequestBlockedReason { - Button {} label: { - Label(blockedReason, systemImage: "info.circle") - } - .disabled(true) - } - } - } label: { - Label("Pull request", systemImage: "arrow.triangle.pull") - } - - Button { - openSessionLane() - } label: { - Label("Open lane", systemImage: "arrow.triangle.branch") - } + private func headerMenuModel(_ session: TerminalSessionSummary) -> WorkChatHeaderMenuModel { + WorkChatHeaderMenuModel( + subagentCount: subagentSnapshots.count, + artifactCount: artifacts.count, + showsLaneActions: showsLaneActions, + prTag: lanePrTag, + prGitHubUrlAvailable: !lanePrGitHubUrlString.isEmpty, + prLinkCopied: prLinkCopied, + laneAvailable: !headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + createPrBlockedReason: createPullRequestBlockedReason, + sessionPinned: session.pinned, + sessionIdCopied: sessionIdCopied, + sessionDeepLinkCopied: sessionDeepLinkCopied + ) } var lanePrGitHubUrlString: String { @@ -647,47 +647,12 @@ struct WorkSessionDestinationView: View { .trimmingCharacters(in: .whitespacesAndNewlines) } - @ViewBuilder - private func chatSessionDesktopMenuItems(_ session: TerminalSessionSummary) -> some View { - Button { - presentSessionRename() - } label: { - Label("Rename", systemImage: "pencil") - } - - Button(role: .destructive) { - Task { await deleteCurrentChatSession() } - } label: { - Label("Delete chat", systemImage: "trash") - } - - Button { - openSessionLane() - } label: { - Label("Go to lane", systemImage: "arrow.triangle.branch") - } - .disabled(headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - - Button { - copyCurrentSessionId() - } label: { - Label(sessionIdCopied ? "Copied session ID" : "Copy session ID", - systemImage: sessionIdCopied ? "checkmark" : "doc.on.doc") - } - - Button { - copyCurrentSessionDeepLink() - } label: { - Label(sessionDeepLinkCopied ? "Copied session deep link" : "Copy session deep link", - systemImage: sessionDeepLinkCopied ? "checkmark" : "link") - } - - Button { - Task { await toggleCurrentSessionPinned() } - } label: { - Label(session.pinned ? "Unpin from front" : "Pin to front", - systemImage: session.pinned ? "pin.slash" : "pin") - } + private var headerMenuLaneColor: Color? { + let laneId = headerMenuLaneId.trimmingCharacters(in: .whitespacesAndNewlines) + guard !laneId.isEmpty, + let lane = lanes.first(where: { $0.id == laneId }) + else { return nil } + return LaneColorPalette.color(forHex: lane.color) } private var canCreatePullRequestForHeaderLane: Bool { @@ -785,11 +750,11 @@ struct WorkSessionDestinationView: View { pr: laneOpenPr, summary: lanePrSummary, snapshot: prDetailsSnapshot, + laneColor: headerMenuLaneColor, canCreate: canCreatePullRequestForHeaderLane, createBlockedReason: createPullRequestBlockedReason, isRefreshing: prDetailsRefreshing, errorMessage: prDetailsError, - copiedLink: prLinkCopied, onRefresh: { Task { await refreshChatPrDetails(force: true) } }, @@ -803,10 +768,9 @@ struct WorkSessionDestinationView: View { openLaneOpenPr() } }, - onOpenGitHub: openLanePrOnGitHub, - onCopyLink: copyLanePrLink + onOpenGitHub: openLanePrOnGitHub ) - .presentationDetents([.medium, .large]) + .presentationDetents([.height(500), .large]) .presentationDragIndicator(.visible) .presentationContentInteraction(.scrolls) } @@ -829,6 +793,7 @@ struct WorkSessionDestinationView: View { session = initialSession chatSummary = initialChatSummary setTranscript(initialTranscript ?? []) + seedTranscriptFromPresentationCacheIfNeeded() stageInitialOpeningPromptEchoIfNeeded() await load() await sendInitialOpeningPromptIfNeeded() @@ -1092,6 +1057,10 @@ struct WorkSessionDestinationView: View { @MainActor func load() async { + guard !openingLoadInFlight else { return } + openingLoadInFlight = true + defer { openingLoadInFlight = false } + do { if let fetchedSession = try await syncService.fetchSession(id: sessionId) { session = fetchedSession @@ -1102,7 +1071,7 @@ struct WorkSessionDestinationView: View { await refreshArtifacts(force: true) } await loadTranscript(forceRemote: shouldHydrateTranscriptFromHost, preferLightweight: syncService.prefersReducedSyncLoad) - await hydrateEmptyTranscriptFromHostIfNeeded(force: true) + await hydrateEmptyTranscriptFromHostIfNeeded() errorMessage = nil } catch { errorMessage = error.localizedDescription @@ -1150,7 +1119,8 @@ struct WorkSessionDestinationView: View { guard transcript.isEmpty, fallbackEntries.isEmpty, shouldHydrateTranscriptFromHost, - !emptyTranscriptHydrationInFlight + !emptyTranscriptHydrationInFlight, + force || !openingLoadInFlight else { return } let now = Date() @@ -1166,6 +1136,9 @@ struct WorkSessionDestinationView: View { @MainActor func loadTranscript(forceRemote: Bool, preferLightweight: Bool = false) async { + seedTranscriptFromPresentationCacheIfNeeded() + let forceOpeningTranscriptRefresh = forceFreshTranscriptOnOpen && !initialTranscriptTailHydrated + let status = normalizedWorkChatSessionStatus(session: session ?? initialSession, summary: chatSummary ?? initialChatSummary) let transcriptStatus = workChatTranscriptPreferenceStatus( sessionStatus: status, @@ -1174,7 +1147,10 @@ struct WorkSessionDestinationView: View { if forceRemote, let currentSession = session ?? initialSession, isChatSession(currentSession) { let alreadySubscribed = syncService.subscribedChatSessionIds.contains(sessionId) - let needsOpeningSnapshot = transcript.isEmpty && fallbackEntries.isEmpty + let hasReusablePresentation = workChatTranscriptPresentationCacheBySession[sessionId]?.hasVisibleTranscript == true + let hasCachedEventHistory = !syncService.chatEventHistory(sessionId: sessionId).isEmpty + let needsOpeningSnapshot = forceOpeningTranscriptRefresh + || (transcript.isEmpty && fallbackEntries.isEmpty && !hasReusablePresentation && !hasCachedEventHistory) if status == "active" { // First visit subscribes (the host answers with a snapshot or a // sinceSeq replay). Once subscribed, live chat_event push plus the @@ -1185,7 +1161,7 @@ struct WorkSessionDestinationView: View { sessionId: sessionId, requestSnapshot: !alreadySubscribed || needsOpeningSnapshot ) - } else if !alreadySubscribed || needsOpeningSnapshot { + } else if needsOpeningSnapshot { // Active streaming stays on reduced snapshots for performance, but an // idle detail view must reconcile against a full event snapshot. A // reduced JSONL tail can start mid-message and render as a broken @@ -1197,6 +1173,7 @@ struct WorkSessionDestinationView: View { || transcript.isEmpty || transcriptStatus != "active" || !initialTranscriptTailHydrated + || forceOpeningTranscriptRefresh if shouldHydrateCanonicalEventTail { do { if syncService.supportsRemoteAction("chat.getChatEventHistory") { @@ -1244,7 +1221,8 @@ struct WorkSessionDestinationView: View { // answer, which are useful while streaming but not enough for final copy // or history. let needsInitialTailHydration = forceRemote && !initialTranscriptTailHydrated - let shouldFetchFallback = needsInitialTailHydration + let shouldFetchFallback = forceOpeningTranscriptRefresh + || needsInitialTailHydration || !preferLightweight || (liveTranscript.isEmpty && transcript.isEmpty) || (!liveTranscript.isEmpty && transcriptStatus != "active") @@ -1735,7 +1713,7 @@ struct WorkSessionDestinationView: View { guard !optimisticPendingSteers.isEmpty else { return } let pendingIds = Set(derivePendingWorkSteers(from: transcript).map(\.id)) optimisticPendingSteers.removeAll { steer in - transcriptContainsResolvedSteer(transcript, steerId: steer.id) || pendingIds.contains(steer.id) + transcriptContainsResolvedSteer(transcript, steer: steer) || pendingIds.contains(steer.id) } } diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 384db7752..9d72ac1d9 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -341,7 +341,7 @@ private func workRuntimeModeSubtitle(provider: String, mode: String) -> String { switch mode { case "plan": return "Read-only planning mode." case "edit": return "Read-only Q&A mode." - case "full-auto": return "Cursor force mode." + case "full-auto": return "Cursor full auto mode." default: return "Cursor Agent's normal approval flow." } case "droid", "factory": diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index 7d3aa939b..14314488f 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -602,7 +602,7 @@ func workRuntimeModeOptions(provider: String) -> [WorkRuntimeModeOption] { WorkRuntimeModeOption(id: "default", title: "Agent"), WorkRuntimeModeOption(id: "plan", title: "Plan"), WorkRuntimeModeOption(id: "edit", title: "Ask"), - WorkRuntimeModeOption(id: "full-auto", title: "Force"), + WorkRuntimeModeOption(id: "full-auto", title: "Full auto"), ] case "droid", "factory": return [ @@ -647,7 +647,7 @@ func workRuntimeModeLabel(provider: String, mode: String) -> String { switch mode { case "plan": return "Plan" case "edit", "ask": return "Ask" - case "full-auto": return "Force" + case "full-auto": return "Full auto" default: return "Agent" } case "droid", "factory": diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index ed60398ee..72586ed97 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -296,8 +296,7 @@ final class ADETests: XCTestCase { DeepLinkRouter.shared.handleNotificationUserInfo(["prNumber": 9876]) - XCTAssertEqual(service.requestedPrNavigation?.prNumber, 9876) - XCTAssertEqual(service.requestedPrNavigation?.prId, "github-pr-number:9876") + XCTAssertEqual(service.requestedPrNavigation?.target, .githubNumber(9876)) } @MainActor @@ -315,8 +314,10 @@ final class ADETests: XCTestCase { "prNumber": "42", ]) - XCTAssertEqual(service.requestedPrNavigation?.prId, "pr_123") - XCTAssertEqual(service.requestedPrNavigation?.prNumber, 42) + XCTAssertEqual( + service.requestedPrNavigation?.target, + .detail(prId: "pr_123", prNumber: 42, laneId: nil) + ) } @MainActor @@ -430,6 +431,8 @@ final class ADETests: XCTestCase { XCTAssertEqual(workRuntimeModeOptions(provider: "codex").map(\.id), ["default", "edit", "plan", "full-auto", "config-toml"]) XCTAssertEqual(workRuntimeModeOptions(provider: "opencode").map(\.id), ["plan", "edit", "full-auto", "config-toml"]) XCTAssertEqual(workRuntimeModeOptions(provider: "cursor").map(\.id), ["default", "plan", "edit", "full-auto"]) + XCTAssertEqual(workRuntimeModeOptions(provider: "cursor").map(\.title), ["Agent", "Plan", "Ask", "Full auto"]) + XCTAssertEqual(workRuntimeModeLabel(provider: "cursor", mode: "full-auto"), "Full auto") XCTAssertEqual(workRuntimeModeOptions(provider: "droid").map(\.id), ["read-only", "auto-low", "auto-medium", "auto-high", "agi"]) let claudeAuto = workRuntimeWireFields(provider: "claude", mode: "auto") @@ -446,6 +449,10 @@ final class ADETests: XCTestCase { XCTAssertEqual(cursorAsk.permissionMode, "edit") XCTAssertEqual(cursorAsk.cursorModeId, "ask") + let cursorFullAuto = workRuntimeWireFields(provider: "cursor", mode: "full-auto") + XCTAssertEqual(cursorFullAuto.permissionMode, "full-auto") + XCTAssertEqual(cursorFullAuto.cursorModeId, "full-auto") + let opencodeLegacyDefault = workRuntimeWireFields(provider: "opencode", mode: "default") XCTAssertEqual(opencodeLegacyDefault.permissionMode, "edit") XCTAssertEqual(opencodeLegacyDefault.opencodePermissionMode, "edit") @@ -2259,6 +2266,33 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.chatEventRevision(for: "session-1"), 1) } + @MainActor + func testChatEventHistoryEvictsOldUnsubscribedSessionsButKeepsSubscribedHistory() async throws { + let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) + try await service.subscribeToChatEvents(sessionId: "session-0") + + for index in 0..<70 { + service.recordChatEventEnvelope(AgentChatEventEnvelope( + sessionId: "session-\(index)", + timestamp: String(format: "2026-03-17T00:00:00.%03dZ", index), + event: .text( + text: "event-\(index)", + messageId: "msg-\(index)", + turnId: "turn-\(index)", + itemId: "item-\(index)" + ), + sequence: index, + provenance: nil + )) + } + + XCTAssertFalse(service.chatEventHistory(sessionId: "session-0").isEmpty) + XCTAssertTrue(service.chatEventHistory(sessionId: "session-1").isEmpty) + XCTAssertTrue(service.chatEventHistory(sessionId: "session-6").isEmpty) + XCTAssertFalse(service.chatEventHistory(sessionId: "session-7").isEmpty) + XCTAssertFalse(service.chatEventHistory(sessionId: "session-69").isEmpty) + } + @MainActor func testTruncatedChatSubscribeSnapshotMergesWithExistingHistory() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -2663,7 +2697,6 @@ final class ADETests: XCTestCase { name text not null, root_path text not null, is_read_only_by_default integer not null default 1, - mobile_read_only integer not null default 1, updated_at text not null ); """) @@ -2691,8 +2724,7 @@ final class ADETests: XCTestCase { laneId: "lane-one", name: "One", rootPath: "/tmp/project-one/.ade/worktrees/one", - isReadOnlyByDefault: false, - mobileReadOnly: true + isReadOnlyByDefault: false ), ]) @@ -2713,8 +2745,7 @@ final class ADETests: XCTestCase { laneId: "lane-two", name: "Two", rootPath: "/tmp/project-two/.ade/worktrees/two", - isReadOnlyByDefault: false, - mobileReadOnly: true + isReadOnlyByDefault: false ), ]) @@ -3197,7 +3228,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.activeProjectRootPath, "/tmp/project-one") XCTAssertEqual(database.currentProjectId(), "runtime-project") XCTAssertEqual(service.projects.map(\.id), ["runtime-project"]) - XCTAssertFalse(service.shouldShowProjectHome) + XCTAssertTrue(service.shouldShowProjectHome) database.close() } @@ -3559,6 +3590,46 @@ final class ADETests: XCTestCase { target.close() } + func testDatabaseApplyChangesDoesNotTrapOnOutOfRangeIntegralDouble() throws { + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + XCTAssertNil(database.initializationError) + + let poison = Double(Int64.max) + XCTAssertNoThrow(try database.applyChanges([ + CrsqlChangeRow( + table: "lanes", + pk: .number(poison), + cid: "name", + val: .number(poison), + colVersion: 1, + dbVersion: 2, + siteId: "b00e9b92c864a27958669c1595fcb2c3", + cl: 1, + seq: 0 + ) + ])) + + database.close() + } + + func testDatabaseExportDoesNotTrapOnMaxIntegerPrimaryKey() throws { + let database = DatabaseService(baseURL: makeTemporaryDirectory(), bootstrapSQL: """ + create table if not exists numeric_rows ( + id integer primary key, + value text not null + ); + """) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into numeric_rows (id, value) values (9223372036854775807, 'max-int') + """) + + let changes = database.exportChangesSince(version: 0) + XCTAssertFalse(changes.filter { $0.table == "numeric_rows" }.isEmpty) + database.close() + } + func testSyncChangesetBatchPayloadDecodesLegacyBatchWithoutBatchId() throws { let data = """ { @@ -3816,6 +3887,39 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseFetchSessionIsScopedToActiveProject() throws { + let baseURL = makeTemporaryDirectory() + let database = makeTerminalSessionSyncDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values + ('project-1', '/tmp/project-one', 'One', 'main', '2026-04-20T00:00:00.000Z', '2026-04-20T00:00:00.000Z'), + ('project-2', '/tmp/project-two', 'Two', 'main', '2026-04-20T00:00:00.000Z', '2026-04-20T00:00:00.000Z'); + + insert into lanes ( + id, project_id, name, lane_type, base_ref, branch_ref, worktree_path, status, created_at + ) values + ('lane-1', 'project-1', 'Project one lane', 'worktree', 'main', 'main', '/tmp/project-one', 'active', '2026-04-20T00:00:00.000Z'), + ('lane-2', 'project-2', 'Project two lane', 'worktree', 'main', 'main', '/tmp/project-two', 'active', '2026-04-20T00:00:00.000Z'); + + insert into terminal_sessions ( + id, lane_id, title, started_at, transcript_path, status, tool_type + ) values + ('session-active', 'lane-1', 'Active project chat', '2026-04-20T00:01:00.000Z', '', 'running', 'codex-chat'), + ('session-foreign', 'lane-2', 'Foreign project chat', '2026-04-20T00:02:00.000Z', '', 'running', 'codex-chat'); + """) + + database.setActiveProjectId("project-1") + + XCTAssertEqual(database.fetchSession(id: "session-active")?.id, "session-active") + XCTAssertNil(database.fetchSession(id: "session-foreign")) + XCTAssertEqual(database.fetchSessions().map(\.id), ["session-active"]) + database.close() + } + func testDatabaseRecreatesDeferredRowAfterStoredDeleteMarker() throws { let baseURL = makeTemporaryDirectory() let database = makeTerminalSessionSyncDatabase(baseURL: baseURL) @@ -7410,6 +7514,38 @@ final class ADETests: XCTestCase { XCTAssertTrue(lines.contains(where: { $0.kind == .added && $0.text == "let value = 2" })) XCTAssertTrue(lines.contains(where: { $0.kind == .unchanged && $0.text == "print(value)" })) XCTAssertTrue(lines.contains(where: { $0.kind == .added && $0.text == "print(\"done\")" })) + XCTAssertFalse(lines.contains(where: { $0.id.contains("let value") })) + } + + func testFilesDiffPreviewLimitPausesDenseLinePairComparisons() { + let original = (0..<1_501).map { "old\($0)" }.joined(separator: "\n") + let modified = (0..<1_000).map { "new\($0)" }.joined(separator: "\n") + let diff = FileDiff( + path: "Sources/App.swift", + mode: "unstaged", + original: DiffSide(exists: true, text: original), + modified: DiffSide(exists: true, text: modified), + isBinary: false, + language: "swift" + ) + + let limit = filesDiffPreviewLimit(diff: diff) + + XCTAssertEqual(limit?.title, "Diff preview paused") + XCTAssertTrue(limit?.message.contains("1501 original lines") == true) + XCTAssertTrue(limit?.message.contains("1000 modified lines") == true) + } + + func testFilesRoutesAndTransitionIdsKeepSamePathDistinctAcrossWorkspaces() { + let path = "Sources/App.swift" + let firstRoute = FilesRoute.editor(workspaceId: "workspace-a", relativePath: path, focusLine: nil) + let secondRoute = FilesRoute.editor(workspaceId: "workspace-b", relativePath: path, focusLine: nil) + + XCTAssertNotEqual(firstRoute, secondRoute) + XCTAssertNotEqual( + filesTransitionId(kind: "container", workspaceId: "workspace-a", path: path), + filesTransitionId(kind: "container", workspaceId: "workspace-b", path: path) + ) } func testFileIconMapsCommonExtensionsToSfSymbols() { @@ -7427,7 +7563,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(formattedFileSize(1_572_864), "1.5 MB") } - func testFilesWorkspaceDefaultsToMobileReadOnlyWhenHostOmitsFlag() throws { + func testFilesWorkspaceIgnoresLegacyMobileReadOnlyFlag() throws { let data = try JSONSerialization.data(withJSONObject: [ "id": "workspace-1", "kind": "primary", @@ -7435,12 +7571,13 @@ final class ADETests: XCTestCase { "name": "Repo", "rootPath": "/repo", "isReadOnlyByDefault": false, + "mobileReadOnly": true, ]) let workspace = try JSONDecoder().decode(FilesWorkspace.self, from: data) - XCTAssertTrue(workspace.mobileReadOnly) - XCTAssertTrue(workspace.readOnlyOnMobile) + XCTAssertEqual(workspace.id, "workspace-1") + XCTAssertFalse(workspace.isReadOnlyByDefault) } func testResolveFilesWorkspaceFallsBackToLaneMatchWhenWorkspaceIdIsStale() { @@ -7565,8 +7702,7 @@ final class ADETests: XCTestCase { laneId: "lane-1", name: "Feature", rootPath: "/repo/.ade/worktrees/feature", - isReadOnlyByDefault: false, - mobileReadOnly: true + isReadOnlyByDefault: false ) ]) try database.cacheDirectorySnapshot( @@ -7611,7 +7747,7 @@ final class ADETests: XCTestCase { ) XCTAssertEqual(database.listWorkspaces().first?.id, "workspace-lane-1") - XCTAssertTrue(database.listWorkspaces().first?.mobileReadOnly == true) + XCTAssertEqual(database.listWorkspaces().first?.isReadOnlyByDefault, false) XCTAssertEqual(database.fetchDirectorySnapshot(workspaceId: "workspace-lane-1", parentPath: "Sources", includeHidden: false)?.first?.path, "Sources/App.swift") XCTAssertEqual(database.fetchFileContentSnapshot(workspaceId: "workspace-lane-1", path: "Sources/App.swift")?.content, "print(\"hi\")") XCTAssertEqual(database.fetchFileDiffSnapshot(workspaceId: "workspace-lane-1", path: "Sources/App.swift", mode: "unstaged")?.modified.text, "print(\"hi\")") @@ -7619,6 +7755,130 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseFileSnapshotsAreScopedByWorkspaceForSamePath() throws { + let database = DatabaseService(baseURL: makeTemporaryDirectory()) + XCTAssertNil(database.initializationError) + let sharedPath = "Sources/App.swift" + + try database.replaceFilesWorkspaces([ + FilesWorkspace( + id: "workspace-a", + kind: "worktree", + laneId: "lane-a", + name: "A", + rootPath: "/repo/.ade/worktrees/a", + isReadOnlyByDefault: false + ), + FilesWorkspace( + id: "workspace-b", + kind: "worktree", + laneId: "lane-b", + name: "B", + rootPath: "/repo/.ade/worktrees/b", + isReadOnlyByDefault: false + ), + ]) + + try database.cacheFileContentSnapshot( + workspaceId: "workspace-a", + path: sharedPath, + blob: SyncFileBlob( + path: sharedPath, + size: 1, + mimeType: nil, + encoding: "utf-8", + isBinary: false, + content: "a", + languageId: "swift" + ) + ) + try database.cacheFileContentSnapshot( + workspaceId: "workspace-b", + path: sharedPath, + blob: SyncFileBlob( + path: sharedPath, + size: 1, + mimeType: nil, + encoding: "utf-8", + isBinary: false, + content: "b", + languageId: "swift" + ) + ) + try database.cacheFileDiffSnapshot( + workspaceId: "workspace-a", + path: sharedPath, + mode: "unstaged", + diff: FileDiff( + path: sharedPath, + mode: "unstaged", + original: DiffSide(exists: true, text: "old-a"), + modified: DiffSide(exists: true, text: "new-a"), + isBinary: false, + language: "swift" + ) + ) + try database.cacheFileDiffSnapshot( + workspaceId: "workspace-b", + path: sharedPath, + mode: "unstaged", + diff: FileDiff( + path: sharedPath, + mode: "unstaged", + original: DiffSide(exists: true, text: "old-b"), + modified: DiffSide(exists: true, text: "new-b"), + isBinary: false, + language: "swift" + ) + ) + try database.cacheFileHistorySnapshot( + workspaceId: "workspace-a", + path: sharedPath, + entries: [ + GitFileHistoryEntry( + commitSha: "aaa", + shortSha: "aaa", + authorName: "A", + authoredAt: "2026-04-11T21:00:00.000Z", + subject: "Change A", + path: sharedPath, + previousPath: nil, + changeType: "modified" + ) + ] + ) + try database.cacheFileHistorySnapshot( + workspaceId: "workspace-b", + path: sharedPath, + entries: [ + GitFileHistoryEntry( + commitSha: "bbb", + shortSha: "bbb", + authorName: "B", + authoredAt: "2026-04-12T21:00:00.000Z", + subject: "Change B", + path: sharedPath, + previousPath: nil, + changeType: "modified" + ) + ] + ) + + XCTAssertEqual(database.fetchFileContentSnapshot(workspaceId: "workspace-a", path: sharedPath)?.content, "a") + XCTAssertEqual(database.fetchFileContentSnapshot(workspaceId: "workspace-b", path: sharedPath)?.content, "b") + XCTAssertEqual( + database.fetchFileDiffSnapshot(workspaceId: "workspace-a", path: sharedPath, mode: "unstaged")?.modified.text, + "new-a" + ) + XCTAssertEqual( + database.fetchFileDiffSnapshot(workspaceId: "workspace-b", path: sharedPath, mode: "unstaged")?.modified.text, + "new-b" + ) + XCTAssertEqual(database.fetchFileHistorySnapshot(workspaceId: "workspace-a", path: sharedPath)?.first?.subject, "Change A") + XCTAssertEqual(database.fetchFileHistorySnapshot(workspaceId: "workspace-b", path: sharedPath)?.first?.subject, "Change B") + database.close() + } + func testAgentChatTranscriptResponseDecodesEntries() throws { let payload: [String: Any] = [ "sessionId": "chat-1", @@ -9879,7 +10139,11 @@ final class ADETests: XCTestCase { payload: .message(message) ) - let rendered = workTimelineRenderEntries(from: [entry], streamingAssistantMessageId: nil) + let rendered = workTimelineRenderEntries( + from: [entry], + streamingAssistantMessageId: nil, + splitAssistantMessageId: message.id + ) XCTAssertEqual(rendered.count, 3) XCTAssertTrue(rendered.allSatisfy { $0.sourceEntryId == entry.id }) @@ -9936,7 +10200,11 @@ final class ADETests: XCTestCase { payload: .message(message) ) - let rendered = workTimelineRenderEntries(from: [entry], streamingAssistantMessageId: nil) + let rendered = workTimelineRenderEntries( + from: [entry], + streamingAssistantMessageId: nil, + splitAssistantMessageId: message.id + ) guard case .assistantControls(let controls) = rendered.last?.payload else { return XCTFail("Expected a controls row after the truncated assistant preview.") } @@ -11427,6 +11695,13 @@ final class ADETests: XCTestCase { XCTAssertNotNil(filesImageData(from: blob)) } + func testWorkChatImagePreviewHelpersCapAndDownsampleAttachmentData() { + let tinyPngBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" + + XCTAssertNil(WorkChatAttachmentImagePreview.base64DecodedImageData(Data(repeating: 7, count: 8).base64EncodedString(), maxBytes: 4)) + XCTAssertNotNil(WorkChatAttachmentImagePreview.image(fromDataUrl: "data:image/png;base64,\(tinyPngBase64)", maxPixelSize: 32)) + } + func testWorkDisplayLeavesCleanRepeatedLettersAloneEvenWithManyDoubles() { // Real text with many legitimate double letters must NOT get collapsed. let natural = "Committee will assess the bookkeeping across all accounts, noting success, progress, commitment." @@ -13114,7 +13389,7 @@ final class ADETests: XCTestCase { } func testWorkPreviewIsWireframeMatchesDesktopIndentationHeuristic() { - XCTAssertTrue(workPreviewIsWireframe("Home\n Primary action\n Secondary action")) + XCTAssertTrue(workPreviewIsWireframe("Name Status\nADE Active")) XCTAssertFalse(workPreviewIsWireframe("Line one\nLine two")) } @@ -13951,6 +14226,18 @@ final class RosterDeltaTests: XCTestCase { XCTAssertEqual(projects.count, 2) } + func testRosterDeltaToleratesDuplicateCurrentProjectIds() { + let current = [project("a", running: 0), project("a", running: 1), project("b")] + let delta = RemoteRosterDeltaPayload(seq: 5, changed: [project("a", running: 2)], removed: nil) + guard case let .applied(projects, seq) = rosterApplyDelta(current: current, currentSeq: 4, delta: delta) else { + return XCTFail("expected applied") + } + + XCTAssertEqual(seq, 5) + XCTAssertEqual(projects.map(\.projectId).sorted(), ["a", "b"]) + XCTAssertEqual(projects.first { $0.projectId == "a" }?.runningCount, 2) + } + func testRosterDeltaRemovesProjects() { let current = [project("a"), project("b")] let delta = RemoteRosterDeltaPayload(seq: 5, changed: nil, removed: ["b"]) diff --git a/apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift b/apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift index 1fb065825..f135bd0c0 100644 --- a/apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift +++ b/apps/ios/ADETests/SyncEnvelopeChunkAssemblerTests.swift @@ -48,6 +48,26 @@ final class SyncEnvelopeChunkAssemblerTests: XCTestCase { XCTAssertNil(assembler.add(chunkId: "", index: 0, total: 1, part: base64("x"))) } + func testDropsOversizedSinglePartAndAllowsChunkIdReuse() { + var assembler = SyncEnvelopeChunkAssembler(maxEnvelopeBytes: 4) + XCTAssertNil(assembler.add(chunkId: "oversized", index: 0, total: 1, part: base64("12345"))) + XCTAssertEqual(assembler.add(chunkId: "oversized", index: 0, total: 1, part: base64("ok")), "ok") + } + + func testDropsChunkSetWhenCumulativeBytesExceedLimit() { + var assembler = SyncEnvelopeChunkAssembler(maxEnvelopeBytes: 6) + XCTAssertNil(assembler.add(chunkId: "bytes", index: 0, total: 2, part: base64("abc"))) + XCTAssertNil(assembler.add(chunkId: "bytes", index: 1, total: 2, part: base64("defg"))) + XCTAssertEqual(assembler.add(chunkId: "bytes", index: 0, total: 1, part: base64("fresh")), "fresh") + } + + func testReplacingPartDoesNotDoubleCountByteBudget() { + var assembler = SyncEnvelopeChunkAssembler(maxEnvelopeBytes: 6) + XCTAssertNil(assembler.add(chunkId: "replace", index: 0, total: 2, part: base64("abcde"))) + XCTAssertNil(assembler.add(chunkId: "replace", index: 0, total: 2, part: base64("a"))) + XCTAssertEqual(assembler.add(chunkId: "replace", index: 1, total: 2, part: base64("bcde")), "abcde") + } + func testResetClearsPartialChunks() { var assembler = SyncEnvelopeChunkAssembler() XCTAssertNil(assembler.add(chunkId: "e", index: 0, total: 2, part: base64("1"))) diff --git a/apps/webhook-relay/README.md b/apps/webhook-relay/README.md index a7c7c6e82..3fb798588 100644 --- a/apps/webhook-relay/README.md +++ b/apps/webhook-relay/README.md @@ -5,9 +5,8 @@ Cloudflare Worker, the Worker verifies the GitHub HMAC signature, writes the event into D1, and ADE desktop/TUI/mobile sync paths poll the newest events with the existing `automations.githubRelay` cursor. -No user repository needs ADE-specific code. The only repo-side step is installing -the GitHub App, or configuring an equivalent GitHub webhook, on the repositories -the user wants ADE to track. +No user repository needs ADE-specific code. The repo-side step is installing the +ADE GitHub App on the repositories the user wants ADE to track. ## Why this shape @@ -16,12 +15,13 @@ the user wants ADE to track. - GitHub webhook writes are idempotent by delivery id. - ADE polls with `after=`, where new cursors are monotonic `seq:` values. Legacy delivery-id cursors still work during migration. -- ADE checks per-repo GitHub App status with a cheap D1 lookup at - `GET /projects/:projectId/github/repos/:owner/:repo/status`. If GitHub App - API credentials are configured, it only calls GitHub when D1 has no installed - state yet or the user explicitly presses Refresh. -- If relay config is missing or the relay fails, ADE keeps using the current - GitHub polling/snapshot path. +- ADE checks per-repo GitHub App status at + `GET /github/repos/:owner/:repo/status`, authenticated by the user's existing + GitHub token. If GitHub App API credentials are configured, it only calls + GitHub when D1 has no installed state yet or the user explicitly presses + Refresh. +- If the hosted relay fails, ADE keeps using the current GitHub polling/snapshot + path. - The event envelope is provider-neutral enough to add Linear later without replacing the ADE cache path. @@ -66,26 +66,46 @@ Set Worker secrets. Do not commit these values: ```sh cd apps/webhook-relay printf '%s' "$GITHUB_WEBHOOK_SECRET" | npx wrangler secret put GITHUB_WEBHOOK_SECRET -printf '%s' "$ADE_GITHUB_RELAY_TOKEN" | npx wrangler secret put RELAY_ACCESS_TOKEN printf '%s' "$GITHUB_APP_ID" | npx wrangler secret put GITHUB_APP_ID printf '%s' "$GITHUB_APP_PRIVATE_KEY" | npx wrangler secret put GITHUB_APP_PRIVATE_KEY npm run deploy ``` `GITHUB_WEBHOOK_SECRET` must match the GitHub App webhook secret. -`ADE_GITHUB_RELAY_TOKEN` is the relay root secret stored in Cloudflare. ADE -clients send a project-scoped `ade_proj_...` token derived from that secret and -`remoteProjectId`; if ADE is configured with an already-derived `ade_proj_...` -token it sends that value as-is. `GITHUB_APP_ID` and `GITHUB_APP_PRIVATE_KEY` are optional but recommended. They let the relay verify a repository installation live through GitHub's App API when webhook state has not arrived yet or the user presses Refresh in Settings. `GITHUB_APP_PRIVATE_KEY` should be the private key PEM downloaded from the GitHub App settings. +Only self-hosted legacy project-token routes need `RELAY_ACCESS_TOKEN`: + +```sh +printf '%s' "$ADE_GITHUB_RELAY_TOKEN" | npx wrangler secret put RELAY_ACCESS_TOKEN +``` + +`ADE_GITHUB_RELAY_TOKEN` is the relay root secret stored in Cloudflare. Legacy +ADE clients send a project-scoped `ade_proj_...` token derived from that secret +and `remoteProjectId`; if ADE is configured with an already-derived +`ade_proj_...` token it sends that value as-is. + ## ADE project config -Put the relay settings in the ADE project's local secret config, not in source: +Normal ADE users should not edit project files for realtime GitHub updates. ADE +uses the hosted relay by default and authenticates relay reads with the GitHub +token the user already configured in Settings. The user-facing setup is: + +1. Sign in to GitHub in ADE. +2. Install the ADE GitHub App for all repositories, or for the selected + repositories that should receive realtime updates. + +If the App is installed for all repositories, every GitHub project opened in ADE +can use the relay automatically. If the App is installed for selected +repositories, Settings shows whether the current repository is selected. + +For self-hosted relay development, or for legacy project-partitioned deployments, +you can still put explicit relay settings in the ADE project's local secret +config, not in source: ```yaml automations: @@ -95,11 +115,12 @@ automations: accessToken: ${env:ADE_GITHUB_RELAY_TOKEN} ``` -`remoteProjectId` is only a relay partition key. It can be a generated UUID or a -stable ADE project slug. +`remoteProjectId` is only a legacy relay partition key. It can be a generated +UUID or a stable ADE project slug. For dev/runtime launches, ADE also accepts env vars instead of -`local.secret.yaml`: +`local.secret.yaml`. Setting these opts the runtime into the legacy +project-token route for self-hosted relays: ```sh ADE_GITHUB_RELAY_API_BASE_URL=https://ade-github-webhook-relay..workers.dev @@ -112,7 +133,7 @@ ADE_GITHUB_RELAY_ACCESS_TOKEN= Create or edit a GitHub App. Use the user-facing name `ADE`: - Webhook URL: - `https://ade-github-webhook-relay..workers.dev/projects//github/webhook` + `https://ade-github-webhook-relay..workers.dev/github/webhook` - Webhook secret: the same `GITHUB_WEBHOOK_SECRET` stored in Cloudflare. - SSL verification: enabled. - Repository permissions: diff --git a/apps/webhook-relay/migrations/0003_github_events_repository.sql b/apps/webhook-relay/migrations/0003_github_events_repository.sql new file mode 100644 index 000000000..1a35fb532 --- /dev/null +++ b/apps/webhook-relay/migrations/0003_github_events_repository.sql @@ -0,0 +1,2 @@ +create index if not exists idx_github_events_repository_received + on github_events(repository_full_name collate nocase, received_at desc, event_id desc); diff --git a/apps/webhook-relay/src/relay.ts b/apps/webhook-relay/src/relay.ts index da5bef087..047fd701d 100644 --- a/apps/webhook-relay/src/relay.ts +++ b/apps/webhook-relay/src/relay.ts @@ -1,7 +1,7 @@ export type RelayEnv = { DB: D1Database; GITHUB_WEBHOOK_SECRET: string; - RELAY_ACCESS_TOKEN: string; + RELAY_ACCESS_TOKEN?: string; EVENT_RETENTION_DAYS?: string; GITHUB_APP_ID?: string; GITHUB_APP_PRIVATE_KEY?: string; @@ -24,6 +24,15 @@ type CursorRow = { event_id: string; }; +type GitHubRepoAccessStatus = + | { + authorized: true; + } + | { + authorized: false; + response: Response; + }; + type AppRepositoryRow = { repository_full_name: string; installation_id: number | null; @@ -258,12 +267,25 @@ function routeProject(pathname: string): { projectId: string; action: "webhook" if (parts[3] === "webhook") return { projectId: decodeURIComponent(parts[1] ?? ""), action: "webhook" }; if (parts[3] === "events") return { projectId: decodeURIComponent(parts[1] ?? ""), action: "events" }; } + if (parts.length === 2 && parts[0] === "github" && parts[1] === "webhook") { + return { projectId: "github-app", action: "webhook" }; + } if (parts.length === 3 && parts[0] === "github" && parts[1] === "webhook") { return { projectId: decodeURIComponent(parts[2] ?? ""), action: "webhook" }; } return null; } +function routeRepoEvents(pathname: string): { owner: string; name: string } | null { + const parts = pathname.split("/").filter(Boolean); + if (parts.length === 5 && parts[0] === "github" && parts[1] === "repos" && parts[4] === "events") { + const owner = decodeURIComponent(parts[2] ?? "").trim(); + const name = decodeURIComponent(parts[3] ?? "").trim(); + if (owner && name) return { owner, name }; + } + return null; +} + function routeRepoStatus(pathname: string): { projectId: string | null; owner: string; name: string } | null { const parts = pathname.split("/").filter(Boolean); if (parts.length === 7 && parts[0] === "projects" && parts[2] === "github" && parts[3] === "repos" && parts[6] === "status") { @@ -307,6 +329,41 @@ async function assertProjectRelayAuthorized(request: Request, env: RelayEnv, pro return null; } +async function assertGitHubRepoAuthorized( + request: Request, + env: RelayEnv, + repo: { owner: string; name: string }, +): Promise { + const token = readBearerToken(request); + if (!token) { + return { + authorized: false, + response: json({ ok: false, error: "GitHub auth token is required" }, { status: 401 }), + }; + } + + const apiBaseUrl = (env.GITHUB_API_BASE_URL?.trim() || "https://api.github.com").replace(/\/+$/, ""); + const response = await fetch( + `${apiBaseUrl}/repos/${encodeURIComponent(repo.owner)}/${encodeURIComponent(repo.name)}`, + { + headers: { + accept: "application/vnd.github+json", + authorization: `Bearer ${token}`, + "user-agent": "ADE GitHub Webhook Relay", + "x-github-api-version": "2022-11-28", + }, + }, + ); + if (response.ok) return { authorized: true }; + + const payload = (await response.json().catch(() => ({}))) as Record; + const message = readString(payload, "message") || `GitHub repo access check failed with HTTP ${response.status}.`; + return { + authorized: false, + response: json({ ok: false, error: message }, { status: response.status === 404 ? 404 : 403 }), + }; +} + function repositoryFullName(payload: Record): string | null { const repository = readNested(payload, "repository"); const fullName = readString(repository, "full_name"); @@ -876,12 +933,96 @@ async function handleListEvents(request: Request, env: RelayEnv, projectId: stri }); } +async function handleListRepoEvents(request: Request, env: RelayEnv, repo: { owner: string; name: string }): Promise { + if (request.method !== "GET") return text("method not allowed", 405); + const auth = await assertGitHubRepoAuthorized(request, env, repo); + if (!auth.authorized) return auth.response; + + const url = new URL(request.url); + const limit = parseLimit(url); + const after = url.searchParams.get("after")?.trim() || ""; + const repoFullName = `${repo.owner}/${repo.name}`.toLowerCase(); + let rows: GitHubEventRow[]; + let cursorExpired = false; + + if (after) { + const sequenceCursor = parseSequenceCursor(after); + if (sequenceCursor != null) { + rows = (await env.DB + .prepare(` + select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, + summary, payload_json, received_at + from github_events + where repository_full_name = ? collate nocase + and rowid > ? + order by rowid desc + limit ? + `) + .bind(repoFullName, sequenceCursor, limit) + .all()).results ?? []; + } else { + const cursor = await env.DB + .prepare("select rowid as event_seq, event_id from github_events where repository_full_name = ? collate nocase and event_id = ? limit 1") + .bind(repoFullName, after) + .first(); + if (cursor) { + rows = (await env.DB + .prepare(` + select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, + summary, payload_json, received_at + from github_events + where repository_full_name = ? collate nocase + and rowid > ? + order by rowid desc + limit ? + `) + .bind(repoFullName, cursor.event_seq, limit) + .all()).results ?? []; + } else { + cursorExpired = true; + rows = (await env.DB + .prepare(` + select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, + summary, payload_json, received_at + from github_events + where repository_full_name = ? collate nocase + order by rowid desc + limit ? + `) + .bind(repoFullName, limit) + .all()).results ?? []; + } + } + } else { + rows = (await env.DB + .prepare(` + select rowid as event_seq, event_id, github_event, github_delivery, repository_full_name, + summary, payload_json, received_at + from github_events + where repository_full_name = ? collate nocase + order by rowid desc + limit ? + `) + .bind(repoFullName, limit) + .all()).results ?? []; + } + + return json({ + events: rows.map(rowToEvent), + nextCursor: nextCursorForRows(rows, after), + cursorExpired, + }); +} + async function handleRepoStatus(request: Request, env: RelayEnv, repo: { projectId: string | null; owner: string; name: string }): Promise { if (request.method !== "GET") return text("method not allowed", 405); - const authError = repo.projectId - ? await assertProjectRelayAuthorized(request, env, repo.projectId) - : assertRelayAuthorized(request, env); - if (authError) return authError; + if (repo.projectId) { + const authError = await assertProjectRelayAuthorized(request, env, repo.projectId); + if (authError) return authError; + } else { + const auth = await assertGitHubRepoAuthorized(request, env, repo); + if (!auth.authorized) return auth.response; + } const forceRefresh = new URL(request.url).searchParams.get("refresh") === "1"; const key = `${repo.owner}/${repo.name}`.toLowerCase(); @@ -1025,6 +1166,9 @@ export async function handleRequest(request: Request, env: RelayEnv): Promise event.project_id === projectId && event.event_id === eventId) ?? null) as T | null; } if (sql.includes("select rowid as event_seq, event_id from github_events")) { - const [projectId, eventId] = values; - const event = this.events.find((entry) => entry.project_id === projectId && entry.event_id === eventId); + const [scope, eventId] = values; + const scopeValue = String(scope); + const repoScoped = sql.includes("repository_full_name") && !sql.includes("project_id = ?"); + const event = repoScoped + ? this.events.find((entry) => entry.repository_full_name?.toLowerCase() === scopeValue && entry.event_id === eventId) + : this.events.find((entry) => entry.project_id === scope && entry.event_id === eventId); return event ? ({ event_seq: event.event_seq, event_id: event.event_id } as T) : null; } if (sql.includes("from github_app_repositories")) { @@ -110,8 +114,11 @@ class FakeD1Database { all(sql: string, values: unknown[]): T[] { if (!sql.includes("from github_events")) return []; - const [projectId] = values; - let rows = this.events.filter((event) => event.project_id === projectId); + const [scope] = values; + const repoScoped = sql.includes("repository_full_name") && !sql.includes("project_id = ?"); + let rows = repoScoped + ? this.events.filter((event) => event.repository_full_name?.toLowerCase() === String(scope)) + : this.events.filter((event) => event.project_id === scope); let limit = Number(values[1]); if (sql.includes("rowid >")) { const [, eventSeq, requestedLimit] = values; @@ -200,6 +207,12 @@ async function projectAuthHeaders(projectId = "project-1"): Promise { + return { + authorization: `Bearer ${token}`, + }; +} + function makeEnv(): RelayEnv & { DB: FakeD1Database } { return { DB: new FakeD1Database(), @@ -243,6 +256,20 @@ async function signedWebhookRequest(body: Record, headers: Reco } describe("webhook relay", () => { + function stubRepoAccess(token = "ghp_repo_token") { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe("https://api.github.com/repos/owner/repo"); + expect(init?.headers).toEqual(expect.objectContaining({ + authorization: `Bearer ${token}`, + "user-agent": "ADE GitHub Webhook Relay", + })); + return new Response(JSON.stringify({ full_name: "owner/repo" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }); + } + it("rejects unsigned GitHub webhook deliveries", async () => { const env = makeEnv(); const response = await handleRequest( @@ -348,6 +375,46 @@ describe("webhook relay", () => { expect(payload.events[0]?.payload.pull_request).toEqual(expect.objectContaining({ number: 43 })); }); + it("lists repo events with the user's GitHub token", async () => { + const env = makeEnv(); + await handleRequest( + await signedWebhookRequest( + { repository: { full_name: "owner/repo" }, pull_request: { number: 42, title: "First" } }, + { "x-github-delivery": "delivery-1" }, + ), + env, + ); + await handleRequest( + await signedWebhookRequest( + { repository: { full_name: "other/repo" }, pull_request: { number: 99, title: "Other" } }, + { "x-github-delivery": "delivery-other" }, + ), + env, + ); + await handleRequest( + await signedWebhookRequest( + { repository: { full_name: "owner/repo" }, pull_request: { number: 43, title: "Second" } }, + { "x-github-delivery": "delivery-2" }, + ), + env, + ); + const fetchMock = stubRepoAccess(); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/events?after=seq:1", { + headers: githubAuthHeaders(), + }), + env, + ); + + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + const payload = await response.json() as { events: Array<{ eventId: string }>; nextCursor: string }; + expect(payload.events.map((event) => event.eventId)).toEqual(["delivery-2"]); + expect(payload.nextCursor).toBe("seq:3"); + }); + it("uses a monotonic cursor so same-timestamp delivery ids cannot be skipped", async () => { const env = makeEnv(); await handleRequest( @@ -405,6 +472,15 @@ describe("webhook relay", () => { expect(response.status).toBe(401); }); + it("requires GitHub repo access for repo-scoped event polling", async () => { + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/events"), + makeEnv(), + ); + + expect(response.status).toBe(401); + }); + it("tracks GitHub App installation state per repository", async () => { const env = makeEnv(); const missing = await handleRequest( @@ -453,6 +529,40 @@ describe("webhook relay", () => { })); }); + it("reports repo installation status through GitHub-token auth", async () => { + const env = makeEnv(); + env.DB.appRepositories.push({ + repository_key: "owner/repo", + repository_full_name: "owner/repo", + owner: "owner", + name: "repo", + installation_id: 123, + repository_selection: "selected", + installed: 1, + last_seen_at: "2026-06-30T00:00:00.000Z", + removed_at: null, + source_event: "installation", + }); + const fetchMock = stubRepoAccess(); + vi.stubGlobal("fetch", fetchMock); + + const status = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/status", { + headers: githubAuthHeaders(), + }), + env, + ); + + expect(status.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(await status.json()).toEqual(expect.objectContaining({ + installed: true, + state: "configured", + installationId: 123, + repositorySelection: "selected", + })); + }); + it("does not treat default install-status deliveries as missing selectable webhook events", async () => { const env = makeEnv(); const pingBody = { diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 3944873e7..40b694f39 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -123,7 +123,7 @@ Automations accept inbound events from four sources (`AutomationIngressSource`): - `local-webhook` — `automationIngressService` opens an HTTP endpoint. - `github-webhook` events verify HMAC-SHA256 via `safeCompareSignature` (timing-safe). Secret read from `automations.githubWebhook.secret`. - `webhook` events are custom inbound webhooks with optional shared-secret verification. -- `github-relay` — polls a GitHub relay (`automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken`) for out-of-band delivery when the desktop app is behind NAT. +- `github-relay` — polls the hosted ADE GitHub relay by repo using the user's existing GitHub token, with the legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` project-token route still available for self-hosted relays. - `linear-relay` — Linear event relay (shared with CTO intake; Linear triggers here are context-only). - `github-polling` — `githubPollingService` polls the GitHub REST API directly for the origin repo and any `extraRepos`, diffing per-poll snapshots to synthesize `github.issue_*` / `github.pr_*` events (opened / edited / labeled / closed / commented, and PR merged). No relay or webhook infra required. Cursor is a `=|=` string stored via `automationService.setIngressCursor({ source: "github-polling" })`; default interval is 30s. @@ -169,7 +169,7 @@ Automations route outputs based on `outputs.disposition`: - **Polling cursor format is sticky.** `githubPollingService.readCursor` must handle three historical shapes: bare `` (first-ever poll, legacy), single `=` (new single-repo), and multi-repo `=|=`. Don't simplify the parser without a migration path. - **Cron sanity-check before installing.** `cron.validate(expr)` plus the 5-field split is the safety net; otherwise `node-cron` throws. - **Webhook secret verification is timing-safe.** Don't refactor `safeCompareSignature` into a plain string compare. -- **Relay polling must respect the access token ref.** `automations.githubRelay.accessToken` is an env ref; resolve via `automationSecretService`, never hard-coded. +- **Legacy relay polling must respect the access token ref.** `automations.githubRelay.accessToken` is an env ref for self-hosted/project-token relays; resolve via `automationSecretService`, never hard-coded. - **Confidence threshold is `0.65` baseline.** Rules that explicitly raise the threshold penalize confidence proportionally — document this in rule descriptions so operators understand scoring. ## Cross-links From 799f807586de3583baaa48bb3164a231941cb8c0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:30:02 -0400 Subject: [PATCH 4/7] Remove file edit-protection gate (desktop + mobile) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files are freely editable with no "Enable editing" step. Removes the FilesWorkbench read-only gate + editOverrides machinery; derives workspace isReadOnlyByDefault as constant false (laneService, iOS Database, browserMock). Keeps the is_edit_protected column and its auto-rebase exclusion filters (`and is_edit_protected = 0`) untouched — this change is scoped to file editing only. FilesWorkbench test flipped to assert edit-protected workspaces are editable immediately. Co-Authored-By: Claude Fable 5 --- .../src/main/services/lanes/laneService.ts | 6 ++- apps/desktop/src/renderer/browserMock.ts | 2 +- .../files/v2/FilesWorkbench.test.tsx | 20 ++++----- .../components/files/v2/FilesWorkbench.tsx | 41 ++++--------------- apps/ios/ADE/Services/Database.swift | 3 +- 5 files changed, 21 insertions(+), 51 deletions(-) diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index ec04cbb88..f12e92d91 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -5417,7 +5417,8 @@ export function createLaneService({ name: row.name, branchRef: row.branch_ref, rootPath: row.worktree_path, - isReadOnlyByDefault: row.is_edit_protected === 1 + // Edit-protection no longer gates file editing; workspaces are always editable. + isReadOnlyByDefault: false })); }, @@ -5439,7 +5440,8 @@ export function createLaneService({ name: row.name, branchRef: row.branch_ref, rootPath: row.worktree_path, - isReadOnlyByDefault: row.is_edit_protected === 1 + // Edit-protection no longer gates file editing; workspaces are always editable. + isReadOnlyByDefault: false }; }, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 13a676cf3..9f323e651 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1037,7 +1037,7 @@ function getBrowserMockFilesWorkspaces(): any[] { branchRef: typeof lane.branchRef === "string" ? lane.branchRef : undefined, rootPath: String(lane.worktreePath ?? MOCK_PROJECT.rootPath), - isReadOnlyByDefault: Boolean(lane.isEditProtected), + isReadOnlyByDefault: false, mobileReadOnly: true, }; }) diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx index 9b44d4b75..003c27e1c 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.test.tsx @@ -165,24 +165,18 @@ describe("FilesWorkbench", () => { await waitFor(() => expect(screen.getByTestId("dirty-count").textContent).toBe("1")); }); - it("lets read-only-by-default workspaces opt into editing for the session", async () => { - const { rerender } = render(); + it("makes edit-protected workspaces editable immediately with no enable step", async () => { + render(); + // workspace-b is isReadOnlyByDefault: true — it must still be freely editable. fireEvent.click(await screen.findByTestId("switch-workspace")); - await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("false")); - fireEvent.click(screen.getByTestId("open-file")); - await waitFor(() => expect(screen.getByTestId("can-edit").textContent).toBe("false")); - - fireEvent.click(screen.getByRole("button", { name: /enable editing/i })); - await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("true")); - expect(screen.getByTestId("can-edit").textContent).toBe("true"); - - testState.appState.project = { rootPath: "/other-repo" }; - rerender(); + fireEvent.click(screen.getByTestId("open-file")); + await waitFor(() => expect(screen.getByTestId("can-edit").textContent).toBe("true")); - await waitFor(() => expect(screen.getByTestId("explorer-can-mutate").textContent).toBe("false")); + // There is no longer any "Enable editing" affordance. + expect(screen.queryByRole("button", { name: /enable editing/i })).toBeNull(); }); it("keeps recent files scoped to the selected lane workspace", async () => { diff --git a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx index bb8220130..e62974a13 100644 --- a/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx +++ b/apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ArrowSquareOut, Copy, FilePlus, FolderPlus, LockOpen, PencilSimple, Trash } from "@phosphor-icons/react"; +import { ArrowSquareOut, Copy, FilePlus, FolderPlus, PencilSimple, Trash } from "@phosphor-icons/react"; import type { FileTreeNode, FilesWorkspace } from "../../../../shared/types"; import { useAppStore } from "../../../state/appStore"; import { createMonacoModelRegistry } from "../monacoModelRegistry"; @@ -68,7 +68,6 @@ const workspacesCacheByProject = new Map(); const rootTreeCacheByKey = new Map(); const readCachedWorkspaces = (projectRoot: string): FilesWorkspace[] => workspacesCacheByProject.get(projectRoot) ?? []; const rootTreeCacheKey = (projectRoot: string, workspaceId: string): string => `${projectRoot}::${workspaceId}`; -const editOverrideKey = (projectRoot: string, workspaceId: string): string => `${projectRoot}::${workspaceId}`; function recentScopeIdForWorkspace(workspace: FilesWorkspace | null | undefined, fallbackLaneId: string | null): string | null { if (!workspace) return fallbackLaneId; @@ -77,12 +76,9 @@ function recentScopeIdForWorkspace(workspace: FilesWorkspace | null | undefined, return workspace.id; } -function canEditWorkspace( - workspace: FilesWorkspace | null | undefined, - editOverrides: ReadonlySet = new Set(), - projectRoot = "", -): boolean { - return workspace != null && (!workspace.isReadOnlyByDefault || editOverrides.has(editOverrideKey(projectRoot, workspace.id))); +function canEditWorkspace(workspace: FilesWorkspace | null | undefined): boolean { + // Any resolved workspace is freely editable — there is no edit-protection gate. + return workspace != null; } function mergeExternalWorkspaces(next: FilesWorkspace[], previous: FilesWorkspace[]): FilesWorkspace[] { @@ -138,19 +134,8 @@ export function FilesWorkbench({ const [workspaceId, setWorkspaceId] = useState(initialWorkspaceId); const workspace = useMemo(() => workspaces.find((w) => w.id === workspaceId) ?? null, [workspaces, workspaceId]); const rootPath = workspace?.rootPath ?? projectRootPath; - const [editOverrides, setEditOverrides] = useState>(() => new Set()); - const workspaceEditOverrideKey = workspace ? editOverrideKey(projectRootPath, workspace.id) : ""; - const canEdit = canEditWorkspace(workspace, editOverrides, projectRootPath); + const canEdit = canEditWorkspace(workspace); const canRevealInFinder = workspace != null && (workspace.kind === "external" || !isRemoteProject); - const showEnableEditing = Boolean(workspace?.isReadOnlyByDefault) && !editOverrides.has(workspaceEditOverrideKey); - const enableEditingForWorkspace = useCallback(() => { - if (!workspace) return; - setEditOverrides((prev) => { - const next = new Set(prev); - next.add(editOverrideKey(projectRootPath, workspace.id)); - return next; - }); - }, [projectRootPath, workspace]); const branch = workspace?.branchRef?.replace("refs/heads/", "") ?? null; const theme: EditorThemeMode = "dark"; const sessionKey = filesProjectSessionKey(projectRootPath); @@ -237,11 +222,11 @@ export function FilesWorkbench({ workspaceId: tab.workspaceId, rootPath: wsRoot, laneId: tab.laneId, - canEdit: canEditWorkspace(ws, editOverrides, projectRootPath), + canEdit: canEditWorkspace(ws), canRevealInFinder: ws != null && (ws.kind === "external" || !isRemoteProject), }; }, - [editOverrides, isRemoteProject, projectRootPath, workspaces], + [isRemoteProject, projectRootPath, workspaces], ); const migratedSessionsRef = useRef(null); @@ -1123,18 +1108,6 @@ export function FilesWorkbench({ {!embedded ? ( ) : null} - {showEnableEditing ? ( - - ) : null}
Date: Wed, 1 Jul 2026 19:43:47 -0400 Subject: [PATCH 5/7] Add webhook negative-authz tests + update internal docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - relay.test.ts: cover token-present-but-denied on both repo-scoped routes (events + status) — asserts 403/404 and no webhook-event leakage to a denied caller. Closes the /quality-flagged coverage gap. - docs: reflect edit-protection removal (files freely editable), webhook relay productization (hosted Worker default, repo-scoped token auth), the mobile chat_subscribe maxBytes clamp + 64-session history eviction, and the new all-projects Hub roster sub-protocol. Co-Authored-By: Claude Fable 5 --- apps/webhook-relay/test/relay.test.ts | 55 ++++++++++++ docs/features/automations/README.md | 8 +- docs/features/files-and-editor/README.md | 14 +-- .../files-and-editor/editor-surfaces.md | 7 +- .../onboarding-and-settings/README.md | 11 ++- docs/features/project-home/README.md | 2 +- docs/features/sync-and-multi-device/README.md | 27 +++++- .../sync-and-multi-device/ios-companion.md | 86 ++++++++++++------- .../sync-and-multi-device/remote-commands.md | 15 ++-- 9 files changed, 173 insertions(+), 52 deletions(-) diff --git a/apps/webhook-relay/test/relay.test.ts b/apps/webhook-relay/test/relay.test.ts index 8c7bae0b4..a4db3ff46 100644 --- a/apps/webhook-relay/test/relay.test.ts +++ b/apps/webhook-relay/test/relay.test.ts @@ -270,6 +270,16 @@ describe("webhook relay", () => { }); } + // A valid GitHub token whose access to the repo is denied by GitHub + // (403 forbidden / 404 not-found-to-this-token). The relay must refuse + // and must not leak stored webhook events. + function stubRepoAccessDenied(githubStatus: 403 | 404 = 403) { + return vi.fn(async () => new Response( + JSON.stringify({ message: "Not Found" }), + { status: githubStatus, headers: { "content-type": "application/json" } }, + )); + } + it("rejects unsigned GitHub webhook deliveries", async () => { const env = makeEnv(); const response = await handleRequest( @@ -415,6 +425,51 @@ describe("webhook relay", () => { expect(payload.nextCursor).toBe("seq:3"); }); + it("refuses repo events when the token is valid but denied access to the repo", async () => { + const env = makeEnv(); + await handleRequest( + await signedWebhookRequest( + { repository: { full_name: "owner/repo" }, pull_request: { number: 42, title: "Secret" } }, + { "x-github-delivery": "delivery-1" }, + ), + env, + ); + const fetchMock = stubRepoAccessDenied(403); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/events", { + headers: githubAuthHeaders("ghp_unauthorized_token"), + }), + env, + ); + + expect(response.status).toBe(403); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = await response.json() as { ok: boolean; events?: unknown }; + expect(body.ok).toBe(false); + // The denied caller must never receive the stored webhook stream. + expect(body.events).toBeUndefined(); + }); + + it("refuses repo status when the token is valid but denied access to the repo", async () => { + const env = makeEnv(); + const fetchMock = stubRepoAccessDenied(404); + vi.stubGlobal("fetch", fetchMock); + + const response = await handleRequest( + new Request("https://relay.example.com/github/repos/owner/repo/status", { + headers: githubAuthHeaders("ghp_unauthorized_token"), + }), + env, + ); + + expect(response.status).toBe(404); + expect(fetchMock).toHaveBeenCalledTimes(1); + const body = await response.json() as { ok: boolean }; + expect(body.ok).toBe(false); + }); + it("uses a monotonic cursor so same-timestamp delivery ids cannot be skipped", async () => { const env = makeEnv(); await handleRequest( diff --git a/docs/features/automations/README.md b/docs/features/automations/README.md index 40b694f39..d0ca26cff 100644 --- a/docs/features/automations/README.md +++ b/docs/features/automations/README.md @@ -22,6 +22,12 @@ These services are loaded by the ADE runtime's project scope (and by the desktop - `githubPollingService.ts` — direct GitHub REST polling for the origin repo plus `extraRepos`. Diffs per-poll snapshots of issues/PRs/comments to emit `github.issue_*` and `github.pr_*` trigger events without requiring a webhook or relay. Cursor format is `=|=` to support multi-repo state in a single stored string; see `readCursor`/`writeCursor` for the legacy-compat parser. - `automationSecretService.ts` — secret resolution for automation actions (env-ref style, same policy as CTO workers). Referenced as `${env:VAR}` in action config; resolved at execution time. +### GitHub relay and App + +- `apps/webhook-relay/` — the hosted GitHub relay: a Cloudflare Worker (`src/index.ts` / `src/relay.ts`) plus D1 migrations. Receives ADE-GitHub-App webhooks, verifies the HMAC signature, stores deliveries idempotently by delivery id, and serves repo-scoped `/github/repos/:owner/:repo/status` and `/events` reads (monotonic `seq:` cursors). Legacy `/projects/:projectId/github/...` project-token routes remain for self-hosted deployments. See `apps/webhook-relay/README.md` for deploy/setup. +- `apps/desktop/src/main/services/github/githubRelayConfig.ts` — resolves the relay base URL and auth mode. Defaults to the hosted Worker (`DEFAULT_GITHUB_RELAY_API_BASE_URL`) with `usesHostedDefault`; `fetchGitHubAppInstallationStatus` calls the repo status route with the user's GitHub token, falling back to the legacy project-token route only when `shouldUseLegacyGitHubRelayProjectRoute` (non-default base URL + project id + access token). +- `apps/desktop/src/main/services/github/githubService.ts` (`getAppInstallationStatus`) and `apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx` — desktop surface for installing / checking "ADE for GitHub" per repo (Settings and onboarding). + ### ADE Actions registry - `apps/desktop/src/main/services/adeActions/registry.ts` — curated allowlist of `(domain, action)` pairs exposed to automation rules as the `ade-action` action type. Each domain maps to a main-process service (`lane`, `git`, `pr`, `issue`, `chat`, `linear_*`, `file`, `pty`, etc.); the allowlist keeps the surface deterministic and audit-able. `listAllowedAdeActionNames` and `isAllowedAdeAction` gate runtime dispatch. @@ -123,7 +129,7 @@ Automations accept inbound events from four sources (`AutomationIngressSource`): - `local-webhook` — `automationIngressService` opens an HTTP endpoint. - `github-webhook` events verify HMAC-SHA256 via `safeCompareSignature` (timing-safe). Secret read from `automations.githubWebhook.secret`. - `webhook` events are custom inbound webhooks with optional shared-secret verification. -- `github-relay` — polls the hosted ADE GitHub relay by repo using the user's existing GitHub token, with the legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` project-token route still available for self-hosted relays. +- `github-relay` — the default hosted path. A Cloudflare Worker (`apps/webhook-relay/`) receives GitHub App webhooks, verifies the GitHub HMAC signature, and writes each delivery into D1. ADE polls **repo-scoped** Worker routes — `GET /github/repos/:owner/:repo/status` for App-installation/webhook state and `GET /github/repos/:owner/:repo/events?after=` for new deliveries — authenticated by the **user's existing GitHub token**. Normal users need no relay token and no `local.secret.yaml`; the only repo-side step is installing the "ADE for GitHub" App. The relay base URL defaults to `DEFAULT_GITHUB_RELAY_API_BASE_URL`; `readGitHubRelayConfig` reports `usesHostedDefault` so the desktop knows to use the token-free repo routes. The legacy `automations.githubRelay.apiBaseUrl` + `remoteProjectId` + `accessToken` **project-token** routes (`/projects/:projectId/github/...`) remain for self-hosted relays — chosen only when a non-default base URL plus project id and access token are all set (`shouldUseLegacyGitHubRelayProjectRoute`). - `linear-relay` — Linear event relay (shared with CTO intake; Linear triggers here are context-only). - `github-polling` — `githubPollingService` polls the GitHub REST API directly for the origin repo and any `extraRepos`, diffing per-poll snapshots to synthesize `github.issue_*` / `github.pr_*` events (opened / edited / labeled / closed / commented, and PR merged). No relay or webhook infra required. Cursor is a `=|=` string stored via `automationService.setIngressCursor({ source: "github-polling" })`; default interval is 30s. diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index 241b7578f..3bf727e76 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -111,7 +111,7 @@ Renderer: - `apps/desktop/src/renderer/components/files/v2/FilesWorkbench.tsx` — Files tab shell: workspace chrome, activity bar, explorer, editor groups, Monaco edit host, diff/conflict surfaces, quick open, text - search, trust warnings, read-only workspace gating, persisted recent-file + search, trust warnings, persisted recent-file pruning, project-level open-tab state across lane/workspace switches, dirty-buffer publishing for agent reads, optional Git-decoration fallback, and file-type viewers. Accepts optional @@ -119,7 +119,7 @@ Renderer: the Work right-edge sidebar. - `apps/desktop/src/renderer/components/files/FilesExplorer.tsx` — virtualized file tree (`@tanstack/react-virtual`), inline rename/create, - explorer search, mutation-disabled create buttons for read-only workspaces, + explorer search, create/rename/delete controls, and context-menu wiring; git status coloring uses helpers from `filePresentation.tsx`. - `apps/desktop/src/renderer/components/files/filePresentation.tsx` — @@ -366,9 +366,13 @@ For deeper detail on the watcher + trust boundary, see from a bound local/remote runtime surfaces to the tab instead of retrying against the desktop main process, which could point at a different host or workspace. -- `FilesWorkspace.isReadOnlyByDefault` is enforced in the renderer as well as - the service layer: Monaco opens read-only, create / rename / delete controls - are disabled, and mutation attempts surface `This workspace is read-only.` +- Files are freely editable. There is no "Enable editing" step and no + per-workspace edit-protection gate: every resolved workspace — including the + primary repo root and lanes whose `is_edit_protected = 1` — opens Monaco in + read/write mode with create / rename / delete controls enabled on desktop and + mobile. The `is_edit_protected` flag still exists but only governs lane + lifecycle (delete / reparent / auto-rebase exclusion), not file editing; + `FilesWorkspace.isReadOnlyByDefault` is now derived as constant `false`. - Workspace switching is navigation, not a discard action. Dirty tabs remain open and published to the dirty-buffer map under their own workspace root until the user saves, closes, renames, deletes, or unloads the tab. diff --git a/docs/features/files-and-editor/editor-surfaces.md b/docs/features/files-and-editor/editor-surfaces.md index b85388261..4b07fdfd9 100644 --- a/docs/features/files-and-editor/editor-surfaces.md +++ b/docs/features/files-and-editor/editor-surfaces.md @@ -175,9 +175,10 @@ Registered through the global keybinding service - **External change plus dirty tab.** File watcher events must not overwrite unsaved Monaco models. Surface the external change and require an explicit user choice. -- **Primary checkout writes.** Read-only and primary-workspace policy is - enforced by the file service and preload boundary; renderer affordances are - only presentation. +- **Primary checkout writes.** The primary repo root and lane worktrees are + freely editable — there is no edit-protection gate on file writes. Path-safety + and trust policy is still enforced by the file service and preload boundary; + renderer affordances are only presentation. - **Large files.** Oversized text opens as read-only streamed content. Do not force large files through the editable Monaco viewer. Media playback has a fixed byte cap so large videos are handed off instead of loaded into diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 14defbdf3..b40e7be94 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -144,7 +144,16 @@ Renderer — settings: `#pr-chat-transcripts`. - `apps/desktop/src/renderer/components/settings/GitHubIntegrationSection.tsx` and `GitHubSection.tsx` — GitHub CLI / PAT auth, scope diagnostics, - and permission guidance. Embedded inside General. + and permission guidance. Embedded inside General. Also hosts the + `GitHubAppInstallPanel` (below) for installing "ADE for GitHub". +- `apps/desktop/src/renderer/components/github/GitHubAppInstallPanel.tsx` + — install / status card for the hosted ADE GitHub App that backs + webhook-relay PR updates. Reads per-repo installation + webhook state via + `window.ade.github.getAppInstallationStatus` (which the desktop resolves + against the hosted relay's `/github/repos/:owner/:repo/status` route using + the user's existing GitHub token — no relay token needed), links out to the + App install / manage pages, and offers a Refresh. Rendered in Settings and, + in a compact `onboarding` variant, during setup. - `apps/desktop/src/renderer/components/settings/LinearIntegrationSection.tsx` and `LinearSection.tsx` — Linear OAuth / API key, workspace status, and GitHub autolink setup. Embedded inside General. diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index 4d9fe6e2d..c20e287e4 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -524,7 +524,7 @@ and uses macOS `sips` as the conversion fallback for SVG / ICO / WebP sources that `nativeImage` cannot decode. The ADE CLI brain uses the same helper for its headless mobile project catalog. The resulting PNG data URL is sent to iOS as `MobileProjectSummary.iconDataUrl`; the iOS -`ProjectHomeView` renders that string as the project tile artwork. +Hub (`HubScreen`) renders that string as the project card artwork. ## Data model diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index a040e42a1..e347cc46c 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -185,7 +185,22 @@ Canonical files (`apps/ade-cli/src/services/sync/`): limiter, and the Tailscale Serve / mDNS publication paths. Runtime kind is one of `desktop-embedded`, `headless`, `remote-stdio`, - `desktop`, `daemon`, or `remote`. + `desktop`, `daemon`, or `remote`. It also owns the all-projects chat + roster push for the mobile Hub: per subscribed peer it tracks + `rosterSubscribed` / `rosterSeq` / a `rosterBaseline` map, debounces + rebuilds (trailing-edge with a hard cadence ceiling), and forces a + 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`. +- `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 + runtime boot — the same cheap cross-project read pattern as + `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). - `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 @@ -193,8 +208,8 @@ Canonical files (`apps/ade-cli/src/services/sync/`): and is handed between hosts on project switch: the new host adopts the open sockets — peer metadata carried over, pairing auth re-validated against the pairing store, changeset cursors recomputed - from the peer's per-site cursor map, chat/terminal subscriptions and - transcript offsets riding the handoff snapshot, and frames buffered + from the peer's per-site cursor map, chat/terminal/roster subscriptions + and transcript offsets riding the handoff snapshot, and frames buffered during the handoff window replayed — so phones survive project switches without reconnecting. Sockets left unowned park with buffered frames and close with code 4002 after a 30 s grace. A @@ -541,6 +556,8 @@ Envelopes are JSON with fields: "terminal_snapshot" | "terminal_data" | "terminal_exit" | "terminal_input" | "terminal_resize" | "terminal_history" | "chat_subscribe" | "chat_unsubscribe" | "chat_event" | + "roster_subscribe" | "roster_unsubscribe" | + "roster_snapshot" | "roster_delta" | "brain_status" | "project_catalog_request" | "project_catalog" | "project_catalog_chunk" | @@ -618,7 +635,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, 64-session LRU). `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 ack also carries `turnActive` from the live agent chat service — snapshots are byte-capped tails, so 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 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 | | 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 | @@ -711,6 +729,7 @@ project scope split. | File access sub-protocol | Implemented | | Terminal stream sub-protocol | Implemented | | Chat stream sub-protocol | Implemented | +| All-projects chat roster sub-protocol (`roster_subscribe`/`snapshot`/`delta`, mobile Hub) | Implemented | | Device registry table | Implemented | | Desktop peer client + manual connect | Implemented | | Sync authority transfer | Implemented | diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index ae982c4ea..569fbea1f 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -57,9 +57,12 @@ apps/ios/ │ │ │ # running-chat badge); the system tab │ │ │ # strip is hidden and individual screens │ │ │ # can hide the custom bar via -│ │ │ # `adeRootTabBarHidden()`; project -│ │ │ # home includes Add project when the -│ │ │ # runtime advertises projectActions +│ │ │ # `adeRootTabBarHidden()`. When no active +│ │ │ # project is selected the root shows the +│ │ │ # Hub (HubScreen, all-projects roster home) +│ │ │ # instead of the tabs; the Hub includes Add +│ │ │ # project when the runtime advertises +│ │ │ # projectActions │ │ ├── RemoteProjectAddSheet.swift # Open/create/clone project flow │ │ │ # backed by runtime-scoped │ │ │ # project action envelopes @@ -107,6 +110,10 @@ apps/ios/ │ │ │ # dictation pill │ │ │ # — `ADEStreamingShimmer.swift` was retired │ │ ├── Cto/ # CtoRootScreen, CtoSessionDestinationView +│ │ ├── Hub/ # HubScreen (all-projects roster home), +│ │ │ # HubComponents (project/lane/chat cards, +│ │ │ # HubNoMachineState), HubComposerDrawer +│ │ │ # (in-place new-chat), HubScreen+ChatNavigation │ │ ├── Lanes/ # LaneDetailScreen, LaneActionsCard, │ │ │ # LaneDetailGitActionsPane (commit / │ │ │ # stage / stash / history / escape @@ -191,8 +198,7 @@ that used to be tangled together: while the phone is happily disconnected or reconnecting. Tint mapping (resolved by `SettingsConnectionPresentation.statusTint`, -`ADEConnectionDot`, `ProjectHomeView.attachedComputerTint`, -`ADERootToolbarControls`, and `SettingsStatusDot`): +`ADEConnectionDot`, `ADERootToolbarControls`, and `SettingsStatusDot`): | Transport | Load | Color | |---|---|---| @@ -211,14 +217,14 @@ older `ADEConnectionPill` and the per-tab "connection notice" banner cards — controllers no longer ship duplicate offline / reconnect / hydrating cards inside each screen body. -`ProjectHomeView` is the exception: with the navigation bar hidden -(brand-mark hero only) it surfaces the same connection state through -an inline "Attached to " banner above the open-project button. -The banner uses the same `SyncConnectionHealth` mapping as -`ADEConnectionDot` (success when connected and not strained, warning -when connecting or strained, danger when unreachable, muted when -disconnected) and routes taps through `syncService.settingsPresented` -to the same Settings sheet the dot opens. +The Hub is the exception: with the navigation bar hidden, its +no-machine / connection-error state renders `HubNoMachineState` +("No machine attached" / "Cannot reach machine") instead of project +cards, using the same `SyncConnectionHealth` mapping as `ADEConnectionDot` +(success when connected and not strained, warning when connecting or +strained, danger when unreachable, muted when disconnected) and routing +taps through `syncService.settingsPresented` to the same Settings sheet +the dot opens. `SettingsConnectionHeader` distinguishes the four states explicitly: @@ -335,8 +341,10 @@ Source: `apps/ios/ADE/Services/SyncService.swift`. (`remoteDbVersionBySite`); `hello_ok` returns the host DB's `serverDbSiteId` and the runtime's current project catalog when the runtime supports project switching. -4. If no active project is selected, show the native project home - instead of hydrating lane/file/PR surfaces against the wrong row. +4. If no active project is selected, show the Hub (all-projects roster + home) instead of hydrating lane/file/PR surfaces against the wrong row. + The Hub subscribes to the roster feed (`roster_subscribe`) to render + every project's chats-by-lane without activating each project. 5. After the active project row exists locally, receive catchup changesets and hydrate lane, file, Work, and PR projections scoped to that project. @@ -386,7 +394,8 @@ Implemented envelope types on iOS: | `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 | -| `envelope_chunk` | Runtime → phone | Slice of an oversized encoded envelope (>720 KB); the phone reassembles by `chunkId`/`index` before normal decode | +| `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) | | `brain_status` | Runtime → phone | Legacy-named cluster authority broadcast | @@ -584,12 +593,28 @@ modifier. `ADEUIKitAppearance.configureTabBar()` (called from appearance so any system surface that still falls through (sheets, push-controllers built from UIKit) matches the SwiftUI chrome. -Before the tabs render, `ProjectHomeView` can take over the root screen -when no active project is selected or the user taps the Projects toolbar -button. It merges the runtime-provided catalog with projects already present -in the local replicated DB, marks cached/unavailable rows, and requests a -fresh bootstrap connection for the selected machine project through -`project_switch_request`. The runtime-provided catalog is local to the +Before the tabs render, the **Hub** (`HubScreen`, in `Views/Hub/`) can take +over the root screen when no active project is selected or the user taps the +Projects toolbar button. The Hub is the app's home surface: it lists every +project on the connected machine, each expandable to its chats grouped by lane +(from the `roster_subscribe` feed — see the sub-protocol table). The active +project's chats come straight from the phone's already-synced local DB +(authoritative + instant) rather than the cross-project roster, so the active +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 +<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` +(awaiting-input + failed sessions). The Hub replaces the old +`ProjectHomeView`'s connected-state layout while preserving its +no-machine / connecting blank states. It still merges the runtime-provided +catalog with projects already present in the local replicated DB, marks +cached/unavailable rows, and requests a fresh bootstrap connection for the +selected machine project through `project_switch_request`. The runtime-provided catalog is local to the paired machine and excludes desktop SSH remote recents, so the phone never tries to switch into another machine's path. Each tile exposes a long-press "Remove from list" action that hides the project locally and sends `project_forget_request` @@ -630,7 +655,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. | -| **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, protected-workspace read-only parity. 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. `mobileReadOnly` on the workspace payload gates mutating file actions on the phone via `ensureMobileFileMutationsAllowed`. | +| **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. | | **CTO** | `brain.head.profile` | `/cto` | CTO snapshot: Chat / Team / Workflows segments, with the mobile workflows screen mirroring the desktop workflow policy/dashboard and preserving the shared glass navigation chrome. Drills into per-worker chat sessions via `CtoSessionDestinationView`. | @@ -647,7 +672,7 @@ Opening or selecting the project again clears those hidden keys. All lane, file, Work, and PR projections are scoped through `Database.currentProjectId()`. The iOS app stores the active project id in `UserDefaults`, mirrors it into `DatabaseService`, and falls back to -the project home if no selected project row has arrived yet. The +the Hub if no selected project row has arrived yet. The machine runtime runs at most one active sync project at a time behind a single brain-level listener on a stable port. When the phone asks the runtime to switch projects, the runtime activates the requested @@ -788,7 +813,7 @@ reflected in the phone's UI on the next descriptor read. | QR pairing payload (v2, address candidates + port) | Implemented | | Project home + machine project switching | Implemented, including Add project actions for browsing/opening existing Git repos, creating local projects, cloning GitHub repos on the paired machine, and removing projects from the list | | Lanes tab | Implemented to live machine parity (with `devicesOpen`, multi-attach, stack canvas, stack-position/base-branch editing in Manage Lane, and template environment progress) | -| Files tab | Implemented with `mobileReadOnly` workspace gate and a unified full-screen name + content search page (`FilesSearchScreen`) | +| Files tab | Implemented with freely-editable workspaces (mobile read-only file gate removed) and a unified full-screen name + content search page (`FilesSearchScreen`) | | Work tab | Implemented; live chat-event push from runtime, subscribed terminal input/resize control with `terminal_unsubscribe` on view disappear, in-app CLI session launcher (`work.startCliSession`), message-to-continue on ended agent CLI rows | | PRs tab | Implemented; driven by `prs.getMobileSnapshot` | | Settings tab (pairing / appearance / diagnostics) | Implemented | @@ -984,8 +1009,9 @@ reflected in the phone's UI on the next descriptor read. re-announces on a 30 s cadence; the runtime prunes stale entries at 60 s. A phone that crashes without sending `lanes.presence.release` will disappear from `devicesOpen` one cycle later, not instantly. -- **`mobileReadOnly` is an additional gate on top of - `isReadOnlyByDefault`.** The iOS app checks both before allowing a - `files.*` mutating command. A workspace that is desktop-writable - may still be read-only from the phone to avoid accidental edits - on a lossy network. +- **Phone file edits are no longer read-only-gated.** The old + `mobileReadOnly` / `isReadOnlyByDefault` write gate was removed on + both the phone and the host, matching the desktop edit-protection + removal, so a desktop-writable workspace is also editable from the + phone. The fields still ride the payload but no longer block `files.*` + mutating commands. diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index ecc2c158c..8f20e3940 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -539,13 +539,14 @@ first fetch instead of returning an empty passive cache. It writes atomically to the lane worktree and that is all. Services that care about post-write side effects (lint, formatters) watch the filesystem independently. -- **Mobile file mutations respect `mobileReadOnly`.** The iOS app - gates mutating file envelopes locally via - `ensureMobileFileMutationsAllowed`, checking - `FilesWorkspace.mobileReadOnly` before sending a `writeText`, - `createFile`, `createDirectory`, `rename`, or `deletePath` request. - The brain's `MOBILE_MUTATING_FILE_ACTIONS` set mirrors this list so - a hostile controller cannot bypass it. +- **Mobile file mutations are no longer read-only-gated.** Files are + freely editable from the phone: the old `mobileReadOnly` / + edit-protection write gate was removed on both sides (the iOS + `ensureMobileFileMutationsAllowed` check and the brain's + `assertWriteAllowed` / `MOBILE_MUTATING_FILE_ACTIONS` enforcement), + matching the desktop edit-protection removal. The `mobileReadOnly` + field still rides the workspace payload but no longer blocks writes. + Path-safety and the external-workspace block below are unchanged. - **External desktop file opens are not mobile-visible.** Desktop `files.openExternalPath` workspaces use `kind: "external"` and `external-local:*` ids. The sync host filters them from mobile From 7942ff17897bcb20949429c0cc40cef98cc00ca2 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:29:11 -0400 Subject: [PATCH 6/7] =?UTF-8?q?ship:=20iteration=201=20=E2=80=94=20fix=20t?= =?UTF-8?q?est-ade-cli=20buildHash=20+=2015=20CodeRabbit=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - stdioRpcDaemon test: buildHash assertion was stale after the lane's `?? computeRuntimeBuildHash()` fallback; assert a computed hash instead of null. Host (CodeRabbit): - rosterBuilder: bound the getIfBooted overlay await (250ms) so a mid-boot scope can't stall the whole roster snapshot (Critical); open project DBs read-only. - githubRelayConfig: document the hosted-relay default (pre-launch item, deferred). iOS (CodeRabbit): - Hub: keep standalone CLI rows visible; key activeRoster to its project so a stale roster isn't merged post-switch; gate chat render on successful activation; thread targetProjectId through composer send/cleanup. - Work: recompute transcriptHasInterruptibleActivity on incremental tail; remove only the matched optimistic echo / resolve only the matched queued steer; preserve envelope ordering on assistant merge; UInt bucketing (no abs Int.min trap); guard cancelled lane-nav task; fall back to live lanes off-root. - RemoteModels: compare resumeMetadata.launch in session Equatable. Co-Authored-By: Claude Fable 5 --- .../src/services/sync/rosterBuilder.ts | 25 ++++++++-- apps/ade-cli/src/stdioRpcDaemon.test.ts | 8 ++- apps/ade-cli/src/types/node-sqlite.d.ts | 2 +- .../main/services/github/githubRelayConfig.ts | 5 ++ apps/ios/ADE/Models/RemoteModels.swift | 1 + apps/ios/ADE/Services/SyncService.swift | 17 +++++-- apps/ios/ADE/Views/Hub/HubComponents.swift | 14 ++++-- .../ios/ADE/Views/Hub/HubComposerDrawer.swift | 13 ++++- .../Views/Hub/HubScreen+ChatNavigation.swift | 3 ++ apps/ios/ADE/Views/Hub/HubScreen.swift | 10 +++- .../Views/Work/WorkChatRichCardViews.swift | 4 +- .../Work/WorkChatSessionView+Actions.swift | 11 +++- .../Work/WorkErrorAndMessageHelpers.swift | 50 ++++++++++++++----- .../Views/Work/WorkRootScreen+Actions.swift | 3 ++ apps/ios/ADE/Views/Work/WorkRootScreen.swift | 7 ++- 15 files changed, 138 insertions(+), 35 deletions(-) diff --git a/apps/ade-cli/src/services/sync/rosterBuilder.ts b/apps/ade-cli/src/services/sync/rosterBuilder.ts index a9d3b0d60..ca67e7eb6 100644 --- a/apps/ade-cli/src/services/sync/rosterBuilder.ts +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -15,12 +15,23 @@ import type { Logger } from "../../../../desktop/src/main/services/logging/logge // cross-project read in recentProjectSummary.ts. The roster opens each // project's `.ade/ade.db` read-only with `node:sqlite` — NO cr-sqlite, NO // runtime boot — so an all-projects feed never has to activate every project. -type DatabaseSyncConstructor = new (dbPath: string, options?: { allowExtension?: boolean }) => DatabaseSyncType; +type DatabaseSyncConstructor = new ( + dbPath: string, + options?: { allowExtension?: boolean; readOnly?: boolean }, +) => DatabaseSyncType; const require = createRequire(path.join(process.cwd(), "ade-runtime.cjs")); const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: DatabaseSyncConstructor }; const PREVIEW_MAX_CHARS = 120; +// getIfBooted may return a promise that is still pending for a mid-boot scope +// (its JSDoc allows "currently-booting"). The roster runs on the brain event +// loop (~1Hz) and fans out across every project with Promise.all, so an +// unbounded await on one slow/stuck boot would stall the whole snapshot for +// every subscribed phone. Cap the overlay wait and degrade to disk-only +// fidelity for that project this cycle. +const ROSTER_BOOT_OVERLAY_TIMEOUT_MS = 250; + // --- Narrow structural inputs (the concrete ProjectRegistry / ---------------- // ProjectScopeRegistry satisfy these; keeping them structural lets the unit // tests seed a project dir + a stub scope registry without a runtime). -------- @@ -192,7 +203,7 @@ function readProjectFromDisk(projectRoot: string, logger?: Pick let db: DatabaseSyncType | null = null; try { - db = new DatabaseSync(dbPath); + db = new DatabaseSync(dbPath, { readOnly: true }); db.exec("PRAGMA busy_timeout = 2000"); if (!hasTable(db, "lanes")) return empty; @@ -314,7 +325,15 @@ async function buildRosterProject( const liveBySessionId = new Map(); let booted = false; try { - const scope = await scopeRegistry.getIfBooted(record.projectId)?.catch(() => null); + const bootedPromise = scopeRegistry.getIfBooted(record.projectId); + const scope = bootedPromise + ? await Promise.race([ + bootedPromise.catch(() => null), + new Promise((resolve) => + setTimeout(() => resolve(null), ROSTER_BOOT_OVERLAY_TIMEOUT_MS), + ), + ]) + : null; const agentChatService = scope?.runtime.agentChatService; if (agentChatService) { booted = true; diff --git a/apps/ade-cli/src/stdioRpcDaemon.test.ts b/apps/ade-cli/src/stdioRpcDaemon.test.ts index f84844151..741481e84 100644 --- a/apps/ade-cli/src/stdioRpcDaemon.test.ts +++ b/apps/ade-cli/src/stdioRpcDaemon.test.ts @@ -404,7 +404,7 @@ describe("ade rpc --stdio daemon bridge", () => { } }, 45_000); - itUnix("accepts a compatible TCP daemon without a build hash", async () => { + itUnix("accepts a compatible TCP daemon and computes a build hash when none is advertised", async () => { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const cliPath = path.join(packageRoot, "src", "cli.ts"); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-stdio-rpc-tcp-build-")); @@ -447,11 +447,15 @@ describe("ade rpc --stdio daemon bridge", () => { expect(initialize).toMatchObject({ runtimeInfo: { version: "2.0.0", - buildHash: null, multiProject: true, pid: tcpDaemon.pid, }, }); + // With no env-advertised build hash, the daemon computes a sha256 of its + // own entrypoint, so buildHash is a truthy string rather than null. + expect( + (initialize as { runtimeInfo?: { buildHash?: string | null } }).runtimeInfo?.buildHash, + ).toBeTruthy(); await expect(proxy.request("shutdown")).resolves.toEqual({}); proxy.closeInput(); diff --git a/apps/ade-cli/src/types/node-sqlite.d.ts b/apps/ade-cli/src/types/node-sqlite.d.ts index 1c1e4c621..eb86ad0b2 100644 --- a/apps/ade-cli/src/types/node-sqlite.d.ts +++ b/apps/ade-cli/src/types/node-sqlite.d.ts @@ -11,7 +11,7 @@ declare module "node:sqlite" { } export class DatabaseSync { - constructor(path: string, options?: { allowExtension?: boolean }); + constructor(path: string, options?: { allowExtension?: boolean; readOnly?: boolean }); close(): void; exec(sql: string): void; prepare(sql: string): StatementSync; diff --git a/apps/desktop/src/main/services/github/githubRelayConfig.ts b/apps/desktop/src/main/services/github/githubRelayConfig.ts index 1029c2c0e..9a8f5b190 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -5,6 +5,11 @@ export const ADE_GITHUB_APP_DISPLAY_NAME = "ADE"; export const ADE_GITHUB_APP_SLUG = "ade-for-github"; export const ADE_GITHUB_APP_INSTALL_URL = `https://github.com/apps/${ADE_GITHUB_APP_SLUG}/installations/new`; export const GITHUB_APP_INSTALLATIONS_URL = "https://github.com/settings/installations"; +// Default hosted GitHub App webhook relay. This is a project-operated Cloudflare +// Worker used for the ADE GitHub App integration during beta; it can be pointed +// at a self-hosted relay via GITHUB_RELAY_API_BASE_REF or the *_API_BASE_ENV_KEYS +// env vars. Replacing this personal default with a first-party/self-hostable +// endpoint is a tracked pre-external-launch item. export const DEFAULT_GITHUB_RELAY_API_BASE_URL = "https://ade-github-webhook-relay.arulsharma1028.workers.dev"; export const GITHUB_RELAY_API_BASE_REF = "automations.githubRelay.apiBaseUrl"; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 6f6997555..791f7f3c8 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -2852,6 +2852,7 @@ struct TerminalSessionSummary: Codable, Identifiable, Equatable { && lhs.resumeMetadata?.targetKind == rhs.resumeMetadata?.targetKind && lhs.resumeMetadata?.targetId == rhs.resumeMetadata?.targetId && lhs.resumeMetadata?.target == rhs.resumeMetadata?.target + && lhs.resumeMetadata?.launch == rhs.resumeMetadata?.launch && lhs.resumeMetadata?.permissionMode == rhs.resumeMetadata?.permissionMode && lhs.chatIdleSinceAt == rhs.chatIdleSinceAt && lhs.chatSessionId == rhs.chatSessionId diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 117eed9c3..5493a4841 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -5154,7 +5154,9 @@ final class SyncService: ObservableObject { deleteBranch: Bool = true, deleteRemoteBranch: Bool = false, remoteName: String = "origin", - force: Bool = false + force: Bool = false, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil ) async throws { _ = try await sendCommand(action: "lanes.delete", args: [ "laneId": laneId, @@ -5162,7 +5164,7 @@ final class SyncService: ObservableObject { "deleteRemoteBranch": deleteRemoteBranch, "remoteName": remoteName, "force": force, - ]) + ], targetProjectId: targetProjectId, targetProjectRootPath: targetProjectRootPath) } func fetchLaneTemplates() async throws -> [LaneTemplate] { @@ -5883,13 +5885,20 @@ final class SyncService: ObservableObject { } @discardableResult - func sendChatMessage(sessionId: String, text: String) async throws -> SyncChatMessageDelivery { + func sendChatMessage( + sessionId: String, + text: String, + targetProjectId: String? = nil, + targetProjectRootPath: String? = nil + ) async throws -> SyncChatMessageDelivery { let response = try await sendCommand( action: "chat.send", args: ["sessionId": sessionId, "text": text], disconnectOnTimeout: false, timeoutMessage: SyncRequestTimeout.chatSendMessage, - timeoutNanoseconds: SyncRequestTimeout.chatSendTimeoutNanoseconds + timeoutNanoseconds: SyncRequestTimeout.chatSendTimeoutNanoseconds, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath ) return syncChatMessageDelivery(from: response) } diff --git a/apps/ios/ADE/Views/Hub/HubComponents.swift b/apps/ios/ADE/Views/Hub/HubComponents.swift index e74bbdc2b..7a43a95ab 100644 --- a/apps/ios/ADE/Views/Hub/HubComponents.swift +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -363,21 +363,25 @@ func buildHubProjectPresentation( let visibleChats = roster.chats.filter { chat in chat.archived != true && laneById[chat.laneId] != nil } - let topLevelChats = visibleChats.filter(\.isChatTool) - let topLevelChatIds = Set(topLevelChats.map(\.id)) - let childRowsByParentId = Dictionary(grouping: visibleChats.filter { chat in + let chatToolIds = Set(visibleChats.filter(\.isChatTool).map(\.id)) + // A row is a child only when it is a non-chat-tool row whose parent is a + // visible chat-tool row. Everything else (including standalone CLI rows that + // have no valid chat parent) must remain a top-level entry so it stays visible. + func isChildRow(_ chat: RemoteRosterChat) -> Bool { guard !chat.isChatTool, let parentId = chat.chatSessionId?.trimmingCharacters(in: .whitespacesAndNewlines), !parentId.isEmpty, parentId != chat.id else { return false } - return topLevelChatIds.contains(parentId) - }, by: { $0.chatSessionId ?? "" }) + return chatToolIds.contains(parentId) + } + let childRowsByParentId = Dictionary(grouping: visibleChats.filter(isChildRow), by: { $0.chatSessionId ?? "" }) .mapValues { chats in chats .sorted { ($0.lastActivityAt ?? "") > ($1.lastActivityAt ?? "") } .map { HubChatRowPresentation.make(chat: $0) } } + let topLevelChats = visibleChats.filter { !isChildRow($0) } let topLevelChatsByLane = Dictionary(grouping: topLevelChats, by: \.laneId) let lanes = roster.lanes.compactMap { lane -> HubLanePresentation? in diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index f1cca0881..8a2dd303b 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -746,7 +746,12 @@ struct HubComposerDrawer: View { targetProjectRootPath: targetProjectRootPath ) sessionId = summary.sessionId - try await syncService.sendChatMessage(sessionId: summary.sessionId, text: opener) + try await syncService.sendChatMessage( + sessionId: summary.sessionId, + text: opener, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) } // Persist the composer + destination so the next New Chat restores both. @@ -775,7 +780,11 @@ struct HubComposerDrawer: View { // The chat never launched into the lane we just minted — clean it up so // an auto-create failure doesn't leave an orphaned empty lane behind. if let createdLaneId { - try? await syncService.deleteLane(createdLaneId) + try? await syncService.deleteLane( + createdLaneId, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) } busy = false return false diff --git a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift index 3cc9b75c9..c7247933f 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -62,6 +62,9 @@ private struct HubChatCover: View { 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 } } diff --git a/apps/ios/ADE/Views/Hub/HubScreen.swift b/apps/ios/ADE/Views/Hub/HubScreen.swift index 72cd2d250..30568659c 100644 --- a/apps/ios/ADE/Views/Hub/HubScreen.swift +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -33,6 +33,10 @@ struct HubScreen: View { // local DB (authoritative + instant), independent of the cross-project roster // feed — so the active card is never stuck on "Loading chats…". @State private var activeRoster: RemoteRosterProject? + // The project id `activeRoster` was built for. `hubPresentationKey` can rebuild + // before `rebuildKey` refreshes `activeRoster` after a project switch, so we + // only overlay the local roster when it still matches the project being rendered. + @State private var activeRosterProjectId: String? @State private var hubProjectPresentations: [HubProjectPresentation] = [] private var isNoMachineBlankState: Bool { @@ -189,6 +193,7 @@ struct HubScreen: View { .task(id: rebuildKey) { guard rebuildKey != nil else { return } activeRoster = syncService.buildActiveProjectLocalRoster() + activeRosterProjectId = syncService.activeProjectId rebuildHubProjectPresentations() } .task(id: hubPresentationKey) { @@ -279,7 +284,10 @@ struct HubScreen: View { /// small subset has hydrated locally and makes ADE's rows appear to vanish. private func rosterEntry(for project: MobileProjectSummary) -> RemoteRosterProject? { let remoteRoster = syncService.rosterProject(for: project) - guard syncService.isActiveProject(project), let activeRoster else { + guard syncService.isActiveProject(project), + activeRosterProjectId == project.id, + let activeRoster + else { return remoteRoster } guard let remoteRoster else { diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index eeba6da24..ebbb3a562 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2071,7 +2071,7 @@ private struct WorkSubagentGlyph: View { private var color: Color { let palette: [Color] = [ADEColor.accent, ADEColor.success, ADEColor.warning, ADEColor.info, ADEColor.danger] - return palette[abs(workStableSubagentHash(id)) % palette.count] + return palette[Int(UInt(bitPattern: workStableSubagentHash(id)) % UInt(palette.count))] } var body: some View { @@ -2108,7 +2108,7 @@ private func workStableSubagentHash(_ value: String) -> Int { } private func workSubagentGlyphBit(id: String, index: Int) -> Bool { - let hash = abs(workStableSubagentHash("\(id):\(index)")) + let hash = UInt(bitPattern: workStableSubagentHash("\(id):\(index)")) return hash % 3 != 0 } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift index 1349fdcf1..bbef54bec 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -126,6 +126,11 @@ private func workSnapshotByApplyingAssistantTextTail( nextSnapshot.latestMessageAssistantId = workIncrementalLatestAssistantId(timeline) nextSnapshot.transcriptIndicatesActiveTurn = true nextSnapshot.transcriptLatestTurnEnded = false + // Keep interruptibility consistent with the full-rebuild path (which derives + // it from the transcript). Otherwise the Stop control can stay hidden while a + // newly-streaming assistant response begins over a previously-idle snapshot. + nextSnapshot.transcriptHasInterruptibleActivity = + WorkActivityIndicator.derivePresentation(from: transcript) != nil nextSnapshot.signature = workIncrementalSnapshotSignature( base: snapshot.signature, transcript: transcript, @@ -391,12 +396,16 @@ private func workIncrementalEchoCount(in timeline: [WorkTimelineEntry]) -> Int { private func workIncrementalRemoveDuplicateEchoes(matching text: String, from timeline: inout [WorkTimelineEntry]) { let normalized = normalizedWorkLocalEchoText(text) guard !normalized.isEmpty else { return } - timeline.removeAll { entry in + // Remove only ONE matching echo: if the user sent the same text twice before + // canonical sync caught up, confirming the first must not drop the second echo. + if let duplicateIndex = timeline.firstIndex(where: { entry in guard entry.id.hasPrefix("echo-"), case .message(let message) = entry.payload, message.role == "user" else { return false } return normalizedWorkLocalEchoText(message.markdown) == normalized + }) { + timeline.remove(at: duplicateIndex) } } diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index 1787b2eaa..f25df7b33 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -906,12 +906,24 @@ func pruneResolvedQueuedSteerEnvelopes(_ transcript: [WorkChatEnvelope]) -> [Wor queuedSteerIdsByText[normalizedText, default: []].insert(steerId) } } else { + // A delivered/inline/failed row graduates at most ONE queued steer. + // Prefer the exact steerId match; only fall back to text (consuming a + // single queued id) so duplicate prompts don't clear multiple pending + // steers at once. + let normalizedText = normalizedQueuedSteerText(text) if let steerId { resolvedSteerIds.insert(steerId) - } - let normalizedText = normalizedQueuedSteerText(text) - if !normalizedText.isEmpty, let matchingQueuedSteerIds = queuedSteerIdsByText[normalizedText] { - resolvedSteerIds.formUnion(matchingQueuedSteerIds) + if !normalizedText.isEmpty { + if queuedSteerIdsByText[normalizedText]?.contains(steerId) == true { + queuedSteerIdsByText[normalizedText]?.remove(steerId) + } else if let consumed = queuedSteerIdsByText[normalizedText]?.first { + resolvedSteerIds.insert(consumed) + queuedSteerIdsByText[normalizedText]?.remove(consumed) + } + } + } else if !normalizedText.isEmpty, let consumed = queuedSteerIdsByText[normalizedText]?.first { + resolvedSteerIds.insert(consumed) + queuedSteerIdsByText[normalizedText]?.remove(consumed) } } case .systemNotice(_, let message, _, _, let steerId): @@ -1010,10 +1022,14 @@ private func mergedWorkChatEnvelope(existing: WorkChatEnvelope, incoming: WorkCh .assistantText(let existingText, let existingTurnId, let existingItemId), .assistantText(let incomingText, let incomingTurnId, let incomingItemId) ): + // Keep the earlier envelope's ordering key so a stable-key assistant message + // doesn't jump to the latest fragment position when the transcript is sorted, + // which would reorder the visible timeline around tool/status events. + let keepExistingOrder = workChatEnvelopeSortPrecedesOrMatches(existing, incoming) return WorkChatEnvelope( sessionId: incoming.sessionId, - timestamp: incoming.timestamp, - sequence: incoming.sequence, + timestamp: keepExistingOrder ? existing.timestamp : incoming.timestamp, + sequence: keepExistingOrder ? existing.sequence : incoming.sequence, event: .assistantText( text: mergeWorkStreamingText(existingText, incomingText), turnId: incomingTurnId ?? existingTurnId, @@ -1473,16 +1489,26 @@ func derivePendingWorkSteers(from transcript: [WorkChatEnvelope]) -> [WorkPendin queuedSteerIdsByText[normalizedText, default: []].insert(steerId) } } else { + // Graduate at most ONE queued steer per delivered row: prefer the exact + // steerId, then fall back to consuming a single text-matched queued id so + // a duplicate prompt doesn't clear multiple pending steers at once. + let normalizedText = normalizedQueuedSteerText(text) if let steerId, deliveryState == "delivered" || deliveryState == "inline" || deliveryState == "failed" { queue.removeValue(forKey: steerId) resolved.insert(steerId) - } - let normalizedText = normalizedQueuedSteerText(text) - if !normalizedText.isEmpty, let matchingQueuedSteerIds = queuedSteerIdsByText[normalizedText] { - for queuedSteerId in matchingQueuedSteerIds { - queue.removeValue(forKey: queuedSteerId) - resolved.insert(queuedSteerId) + if !normalizedText.isEmpty { + if queuedSteerIdsByText[normalizedText]?.contains(steerId) == true { + queuedSteerIdsByText[normalizedText]?.remove(steerId) + } else if let consumed = queuedSteerIdsByText[normalizedText]?.first { + queue.removeValue(forKey: consumed) + resolved.insert(consumed) + queuedSteerIdsByText[normalizedText]?.remove(consumed) + } } + } else if !normalizedText.isEmpty, let consumed = queuedSteerIdsByText[normalizedText]?.first { + queue.removeValue(forKey: consumed) + resolved.insert(consumed) + queuedSteerIdsByText[normalizedText]?.remove(consumed) } } case .systemNotice(_, let message, _, _, let steerId): diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift index b956f104f..581b9cfe8 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen+Actions.swift @@ -383,6 +383,9 @@ extension WorkRootScreen { // Let the context menu dismiss, the tab switch complete, and the by-lane // presentation render before asking the List to reveal the lane header. try? await Task.sleep(for: .milliseconds(650)) + // `try? await Task.sleep` returns (not throws) on cancellation, so a cancelled + // handler could still scroll and clear the request — bail out explicitly. + guard !Task.isCancelled else { return } guard syncService.requestedWorkLaneNavigation?.id == request.id else { return } withAnimation(.snappy) { diff --git a/apps/ios/ADE/Views/Work/WorkRootScreen.swift b/apps/ios/ADE/Views/Work/WorkRootScreen.swift index 8976667a1..3ea9cd091 100644 --- a/apps/ios/ADE/Views/Work/WorkRootScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkRootScreen.swift @@ -602,7 +602,10 @@ struct WorkRootScreen: View { transitionNamespace: routeTransitionNamespace, isLive: isLive, navigationChrome: .pushedDetail, - lanes: workOrderedLanes + // `sessionPresentationTaskKey` goes nil once a screen is pushed off the + // root, so `workOrderedLanes` stops refreshing here — fall back to the + // live `lanes` when the presentation-derived order isn't available. + lanes: workOrderedLanes.isEmpty ? lanes : workOrderedLanes ) .equatable() .id(route.openId) @@ -611,7 +614,7 @@ struct WorkRootScreen: View { } .navigationDestination(for: WorkNewChatRoute.self) { route in WorkNewChatScreen( - lanes: workOrderedLanes, + lanes: workOrderedLanes.isEmpty ? lanes : workOrderedLanes, preferredLaneId: route.preferredLaneId, onStarted: { summary, opener in let sessionId = summary.sessionId From a9f1f802b2f7f1024668080c7cac3bb664597d2d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:58:53 -0400 Subject: [PATCH 7/7] =?UTF-8?q?ship:=20iteration=202=20=E2=80=94=20sweep?= =?UTF-8?q?=20AgentChatSessionSummary=20Equatable=20(Codex=20P2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compare the four remaining fields the manual == skipped — cursorModeSnapshot, cursorConfigValues, computerUse, completion — so a host-side change to any of them (e.g. Cursor mode-only refresh) triggers the iOS session header/settings to update instead of latching stale. Co-Authored-By: Claude Fable 5 --- apps/ios/ADE/Models/RemoteModels.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 791f7f3c8..ee7db2e5f 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -754,6 +754,10 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.opencodePermissionMode == rhs.opencodePermissionMode && lhs.droidPermissionMode == rhs.droidPermissionMode && lhs.cursorModeId == rhs.cursorModeId + && lhs.cursorModeSnapshot == rhs.cursorModeSnapshot + && lhs.cursorConfigValues == rhs.cursorConfigValues + && lhs.computerUse == rhs.computerUse + && lhs.completion == rhs.completion && lhs.identityKey == rhs.identityKey && lhs.surface == rhs.surface && lhs.automationId == rhs.automationId