diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index 1f0a588a4..87e493bcb 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 @@ -1078,6 +1079,7 @@ export async function createAdeRuntime(args: { automationService, prService: headlessLinearServices.prService, secretService: automationSecretService, + githubService: headlessLinearServices.githubService, listRules: () => projectConfigService.get().effective.automations ?? [], }) : null; @@ -1202,6 +1204,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/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/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..ca67e7eb6 --- /dev/null +++ b/apps/ade-cli/src/services/sync/rosterBuilder.ts @@ -0,0 +1,432 @@ +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; 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). -------- + +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, { readOnly: true }); + 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 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; + 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..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"; @@ -2009,3 +2123,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..a48d9770f 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, @@ -36,6 +37,7 @@ import type { CreateProjectInput, SyncEnvelope, SyncChatEventPayload, + SyncChatSubscribePayload, SyncChatSubscribeSnapshotPayload, SyncChatUnsubscribePayload, SyncFileBlob, @@ -54,6 +56,10 @@ import type { SyncProjectForgetResultPayload, SyncProjectSwitchRequestPayload, SyncProjectSwitchResultPayload, + SyncRosterProject, + SyncRosterSnapshotPayload, + SyncRosterDeltaPayload, + SyncRosterSubscribePayload, ListMyGitHubReposInput, ListMyGitHubReposResult, ProjectBrowseInput, @@ -179,6 +185,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"; @@ -193,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": @@ -268,6 +284,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 +438,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 +512,7 @@ type SyncHostServiceArgs = { compressionThresholdBytes?: number; deviceRegistryService?: DeviceRegistryService; projectCatalogProvider?: SyncProjectCatalogProvider; + rosterProvider?: SyncRosterProvider; onStateChanged?: () => void; remoteCommandService?: SyncRemoteCommandService; remoteCommandExecutor?: Pick; @@ -1532,6 +1567,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 +1725,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 +1771,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 +1894,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 +1922,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 +2707,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 +3160,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 { @@ -3129,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), @@ -3143,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); @@ -3164,7 +3386,6 @@ export function createSyncHostService(args: SyncHostServiceArgs) { try { assertMobileExternalWorkspaceBlocked(peer, payload); - assertFileMutationAllowed(peer, payload); let result: | FilesWorkspace[] | FileTreeNode[] @@ -3426,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; @@ -3454,6 +3667,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, @@ -4069,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); @@ -4123,14 +4339,11 @@ 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, + maxBytes, + }) ?? null; + const events = history?.events ?? []; const transcriptSize = session?.transcriptPath && fs.existsSync(session.transcriptPath) ? fs.statSync(session.transcriptPath).size : 0; @@ -4138,11 +4351,14 @@ export function createSyncHostService(args: SyncHostServiceArgs) { const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, capturedAt: nowIso(), - truncated: transcriptSize > maxBytes, + truncated: history?.truncated ?? (transcriptSize > maxBytes), events, ...(await resolveLiveStatusFields()), }; sendRequired(peer, "chat_subscribe", snapshot, envelope.requestId); + for (const event of events) { + rememberChatEventSent(peer, event); + } break; } case "chat_unsubscribe": { @@ -4155,6 +4371,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 +4618,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { clearInterval(pollTimer); clearInterval(heartbeatTimer); clearInterval(brainStatusTimer); + stopRosterSafetyPoll(); + clearRosterFlushTimers(); unpublishLanDiscovery(); try { await unpublishTailnetDiscovery(); @@ -4439,6 +4665,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..e0d277335 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 @@ -1195,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; @@ -2283,6 +2312,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 +2351,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."); @@ -2892,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/ade-cli/src/services/sync/syncService.ts b/apps/ade-cli/src/services/sync/syncService.ts index 3deb0fbd8..f23404574 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.*` @@ -217,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; @@ -722,6 +728,7 @@ export function createSyncService(args: SyncServiceArgs) { runtimeVersion: args.appVersion ?? "", deviceRegistryService, projectCatalogProvider: args.projectCatalogProvider, + rosterProvider: args.rosterProvider, remoteCommandService, remoteCommandExecutor: args.remoteCommandExecutor, onStateChanged: () => { @@ -1006,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/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/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 e10ecb558..0a67db126 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -6001,6 +6001,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", + }), + ]); + }); }); // -------------------------------------------------------------------------- @@ -12135,6 +12204,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("readTranscript", () => { @@ -12538,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 2af2f0519..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; @@ -5695,6 +5706,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) ?? []; @@ -6434,9 +6452,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); @@ -6451,7 +6476,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; @@ -6471,7 +6496,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, @@ -6499,7 +6524,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, @@ -6508,10 +6533,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 = ({ @@ -6688,6 +6713,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(); @@ -6697,6 +6723,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; @@ -6708,11 +6736,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 @@ -6734,7 +6770,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 = { @@ -6747,10 +6788,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(); @@ -6762,9 +6805,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 @@ -6774,6 +6819,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); }; @@ -7042,6 +7089,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 @@ -7098,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) { @@ -7125,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 @@ -7152,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..9a8f5b190 100644 --- a/apps/desktop/src/main/services/github/githubRelayConfig.ts +++ b/apps/desktop/src/main/services/github/githubRelayConfig.ts @@ -5,6 +5,12 @@ 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"; export const GITHUB_RELAY_PROJECT_REF = "automations.githubRelay.remoteProjectId"; @@ -21,8 +27,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 +45,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 +59,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 +78,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 +161,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 +171,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 +181,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/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/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/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 6674eae04..fe3b710f8 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", @@ -109,6 +110,7 @@ const IOS_REMOTE_COMMAND_ACTIONS = [ "cto.getLinearIssueComments", "cto.runLinearSyncNow", "cto.saveAgent", + "prs.getForLane", "prs.createFromLane", "prs.createQueue", "prs.land", @@ -218,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({}), @@ -1047,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/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/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}
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/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..103482e92 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" @@ -838,6 +928,7 @@ export type SyncRemoteCommandAction = | "conflicts.getBatchAssessment" | "prs.list" | "prs.refresh" + | "prs.getForLane" | "prs.getDetail" | "prs.getStatus" | "prs.getChecks" @@ -1003,6 +1094,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 +1157,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..37c8ccb94 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -46,6 +46,8 @@ 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 */; }; E10000000000000000000053 /* LaneDetailGitActionsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = D10000000000000000000053 /* LaneDetailGitActionsPane.swift */; }; @@ -88,6 +90,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 +244,8 @@ 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 = ""; }; D10000000000000000000053 /* LaneDetailGitActionsPane.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = LaneDetailGitActionsPane.swift; path = ADE/Views/Lanes/LaneDetailGitActionsPane.swift; sourceTree = ""; }; @@ -292,6 +301,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 +491,7 @@ isa = PBXGroup; children = ( 02B9E655310A24835B5CFC3B /* Components */, + HB00000000000000000000F1 /* Hub */, D20000000000000000000041 /* Files */, A10000000000000000000001 /* Lanes */, D10000000000000000000021 /* Work */, @@ -491,6 +506,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 +638,8 @@ D1000000000000000000003C /* WorkSessionGrouping.swift */, D1000000000000000000002C /* WorkChatHeaderAndMessageViews.swift */, D1000000000000000000002D /* WorkChatRichCardViews.swift */, + D10000000000000000000055 /* WorkChatPrViews.swift */, + D10000000000000000000054 /* WorkPlanComposerViews.swift */, D10000000000000000000050 /* WorkChatAttachmentTray.swift */, D1000000000000000000002E /* WorkChatComposerAndInputViews.swift */, D1000000000000000000002F /* WorkArtifactTerminalViews.swift */, @@ -756,6 +784,7 @@ isa = PBXGroup; children = ( 483C5F1818BAE74B19B84617 /* RemoteModels.swift */, + D2000000000000000000004F /* RemoteRosterModels.swift */, ); name = Models; sourceTree = ""; @@ -988,6 +1017,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 +1118,8 @@ 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 */, E1000000000000000000002E /* WorkChatComposerAndInputViews.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 0263392a4..cc075b9a3 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 } @@ -85,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() @@ -152,383 +163,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..ee7db2e5f 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -731,6 +731,57 @@ 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.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 + && 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 +1915,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 +1959,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 +2024,9 @@ extension AgentChatEvent { case stepNumber case items case taskId + case agentId + case agentType + case parentToolUseId case background case lastToolName case question @@ -2129,6 +2201,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 +2211,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 +2223,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 +2443,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 { @@ -2586,11 +2669,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, @@ -2599,8 +2677,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 @@ -2609,7 +2686,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { self.branchRef = branchRef self.rootPath = rootPath self.isReadOnlyByDefault = isReadOnlyByDefault - self.mobileReadOnly = mobileReadOnly } private enum CodingKeys: String, CodingKey { @@ -2620,7 +2696,6 @@ struct FilesWorkspace: Codable, Identifiable, Equatable { case branchRef case rootPath case isReadOnlyByDefault - case mobileReadOnly } init(from decoder: Decoder) throws { @@ -2632,7 +2707,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 } } @@ -2754,6 +2828,43 @@ 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?.launch == rhs.resumeMetadata?.launch + && 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..77bd8b18d --- /dev/null +++ b/apps/ios/ADE/Models/RemoteRosterModels.swift @@ -0,0 +1,252 @@ +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 = [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) +} + +// 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/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 17d9b0af3..b5468f8bf 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") @@ -1285,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 """ @@ -1296,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 @@ -1320,8 +1366,8 @@ final class DatabaseService { laneId: lane.id, name: lane.name, rootPath: lane.attachedRootPath ?? lane.worktreePath, - isReadOnlyByDefault: lane.isEditProtected, - mobileReadOnly: true + // Edit-protection no longer gates file editing; workspaces are always editable. + isReadOnlyByDefault: false ) } } @@ -1391,15 +1437,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 @@ -1413,8 +1458,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") @@ -1619,6 +1663,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 +1684,92 @@ 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 } + 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, + 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 = ? 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 + } + + 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 { @@ -3469,8 +3545,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) } @@ -3487,6 +3563,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: @@ -4101,9 +4187,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 00ba167ea..5493a4841 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -271,12 +271,15 @@ 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. 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 @@ -394,15 +397,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) @@ -410,7 +417,7 @@ func syncConnectPortCandidates(primaryPort: Int, addresses: [String]) -> [Int] { .filter { seen.insert($0).inserted } } -struct SyncConnectionEndpointAttempt: Equatable { +struct SyncConnectionEndpointAttempt: Equatable, Hashable { var address: String var port: Int } @@ -431,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 @@ -486,6 +510,7 @@ struct SyncPreprocessedEnvelope { } private let maxUncompressedSyncEnvelopeBytes = 25 * 1024 * 1024 +private let maxChunkedSyncEnvelopeBytes = 32 * 1024 * 1024 func syncPreprocessIncoming( _ text: String, @@ -574,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() @@ -599,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 @@ -607,9 +665,10 @@ struct SyncEnvelopeChunkAssembler { buffers.removeValue(forKey: chunkId) arrivalOrder.removeAll { $0 == chunkId } var data = Data() + data.reserveCapacity(buffer.decodedBytes) for partIndex in 0..? @Published var settingsPresented = false @Published var projectHomePresented = true @Published var attentionDrawerPresented = false + @Published var requestedWorkLaneNavigation: WorkLaneNavigationRequest? @Published var requestedWorkSessionNavigation: WorkSessionNavigationRequest? @Published var requestedFilesNavigation: FilesNavigationRequest? @Published var requestedLaneNavigation: LaneNavigationRequest? @@ -1370,6 +1486,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() @@ -1388,6 +1509,7 @@ final class SyncService: ObservableObject { private var autoReconnectAwaitingLiveDiscovery = false /// Prevents overlapping `reconnectIfPossible` runs from stacking TCP/WebSocket attempts. private var reconnectConnectInFlight = false + private var reconnectConnectInFlightGeneration: UInt64? private var bonjourDiscoveredHosts: [DiscoveredSyncHost] = [] private var tailnetDiscoveredHosts: [DiscoveredSyncHost] = [] private var lastNetworkPathSnapshot: SyncNetworkPathSnapshot? @@ -1441,6 +1563,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 +1689,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 +2151,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 +2195,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 +2285,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 +2559,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 +2640,8 @@ final class SyncService: ObservableObject { reconnectTask?.cancel() networkPathReconnectTask?.cancel() pendingOperationFlushTask?.cancel() + outboundCursorPersistTask?.cancel() + remoteCursorProfilePersistTask?.cancel() lanePresenceHeartbeatTask?.cancel() terminalBufferRevisionTask?.cancel() chatEventRevisionTask?.cancel() @@ -2503,13 +2671,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) { @@ -2980,7 +3182,7 @@ final class SyncService: ObservableObject { syncConnectLog.info("ADE_SYNC_TRACE reconnect user override cancels in-flight attempt") beginConnectAttempt() teardownSocket(reason: "Reconnect restarted.") - reconnectConnectInFlight = false + clearReconnectConnectInFlight() } } // Background retries (slow heartbeat, network-path task, discovery @@ -3020,12 +3222,10 @@ final class SyncService: ObservableObject { syncConnectLog.info("reconnect skipped: connect already in flight") return } - reconnectConnectInFlight = true let connectAttemptGeneration = beginConnectAttempt() + markReconnectConnectInFlight(connectAttemptGeneration) defer { - if self.connectAttemptGeneration == connectAttemptGeneration { - reconnectConnectInFlight = false - } + clearReconnectConnectInFlight(connectAttemptGeneration) } publishReconnectStarted(profile: profile) do { @@ -3437,7 +3637,7 @@ final class SyncService: ObservableObject { setAutoReconnectPausedByUser(true) } allowAutoReconnect = false - reconnectConnectInFlight = false + clearReconnectConnectInFlight() cancelReconnectLoop() teardownSocket(closeCode: .normalClosure) connectionState = .disconnected @@ -3875,6 +4075,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) } @@ -3964,6 +4168,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) } @@ -4124,9 +4336,20 @@ final class SyncService: ObservableObject { // MARK: - PR detail warm cache + /// Bounds the warm cache; full PR detail snapshots would otherwise grow + /// insert-only for the process lifetime. + private static let prDetailCacheMaxEntries = 24 + /// Store a fully-loaded detail entry for `prId`. Stamps `loadedAt` so the /// freshness gate can decide whether a later revision bump should refetch. func storePrDetailWarmEntry(_ entry: PrDetailWarmEntry, for prId: String) { + // Bounded-growth insurance: evict the oldest entry (by loadedAt) before a + // brand-new key pushes the cache past the cap. + if prDetailCache[prId] == nil, + prDetailCache.count >= Self.prDetailCacheMaxEntries, + let oldest = prDetailCache.min(by: { $0.value.loadedAt < $1.value.loadedAt })?.key { + prDetailCache.removeValue(forKey: oldest) + } prDetailCache[prId] = entry } @@ -4181,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 { @@ -4237,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, @@ -4246,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, @@ -4255,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, @@ -4263,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, @@ -4272,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, @@ -4603,6 +4822,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 +4908,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 +4937,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 +4957,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 +4983,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 } @@ -4906,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, @@ -4914,7 +5164,7 @@ final class SyncService: ObservableObject { "deleteRemoteBranch": deleteRemoteBranch, "remoteName": remoteName, "force": force, - ]) + ], targetProjectId: targetProjectId, targetProjectRootPath: targetProjectRootPath) } func fetchLaneTemplates() async throws -> [LaneTemplate] { @@ -5215,9 +5465,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", @@ -5246,6 +5503,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, @@ -5261,7 +5521,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 @@ -5372,7 +5634,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 +5692,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 +5775,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,14 +5839,66 @@ 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 { + 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) } @@ -6039,8 +6432,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 +6461,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 +6832,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() { @@ -6438,6 +6933,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 } @@ -6798,11 +7306,23 @@ 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, addresses: addresses) + let portCandidates = syncConnectPortCandidates( + primaryPort: primaryPort, + addresses: addresses, + 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)]" ) @@ -6823,7 +7343,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() } @@ -6949,7 +7473,7 @@ final class SyncService: ObservableObject { reconnectTask = nil networkPathReconnectTask?.cancel() networkPathReconnectTask = nil - reconnectConnectInFlight = false + clearReconnectConnectInFlight() autoReconnectAwaitingLiveDiscovery = false let machineName = syncTrimmedNonEmptyName(profile?.hostName) ?? syncTrimmedNonEmptyName(hostName) @@ -7027,6 +7551,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 +8160,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 +8213,7 @@ final class SyncService: ObservableObject { startInitialHydrationTask(for: connectionGeneration) restoreTerminalSubscriptions() restoreChatEventSubscriptions() + subscribeRosterIfNeeded() } private func failPendingRequests(with error: Error) { @@ -7886,17 +8414,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 +8484,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 +8541,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 +8631,7 @@ final class SyncService: ObservableObject { pendingOutboundChangeset = nil clearPendingOutboundChangesetForActiveProject() advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) - lastSyncAt = Date() + markSyncActivity(force: true) lastError = nil return } @@ -8127,7 +8660,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 +8683,7 @@ final class SyncService: ObservableObject { persistPendingOutboundChangesetForActiveProject(pending) } else { advanceOutboundCursorForActiveProject(to: pending.payload.toDbVersion) - lastSyncAt = Date() + markSyncActivity(force: true) } } @@ -8161,7 +8694,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 +8849,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 +9040,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 +9113,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 +9133,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 +9255,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 +9283,8 @@ final class SyncService: ObservableObject { commandId: requestId, action: action, args: args, - projectId: self.activeProjectId, - projectRootPath: self.activeProjectRootPath + projectId: resolvedProjectId, + projectRootPath: resolvedProjectRootPath ) ) } @@ -8737,7 +9296,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 +9309,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 +9320,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 +9342,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] } @@ -8835,30 +9411,29 @@ 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) + let sessionId = envelope.sessionId + if let last = chatEventEnvelopesBySession[sessionId]?.last { + if canAppendChatEvent(envelope, after: last) { + // 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 } + chatEventEnvelopesBySession[sessionId] = insertChatEventEnvelope(envelope, into: events) + } } else { - events = deduplicatedChatEventHistory(events + [envelope]) + chatEventEnvelopesBySession[sessionId, default: []].append(envelope) } - chatEventEnvelopesBySession[envelope.sessionId] = events - chatEventRevisionsBySession[envelope.sessionId, default: 0] += 1 - lastSyncAt = Date() + pruneChatEventHistoryCacheIfNeeded(preserving: [sessionId]) + chatEventRevisionsBySession[sessionId, default: 0] += 1 + markSyncActivity() updateChatTurnActiveHintFromEvent(envelope) markChatEventsChanged() } @@ -8867,8 +9442,9 @@ 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 - lastSyncAt = Date() + markSyncActivity() markChatEventsChanged(immediate: true) } @@ -8877,39 +9453,176 @@ 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 - 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] { @@ -8917,6 +9630,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)) @@ -9002,6 +9746,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 @@ -9366,7 +10112,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 +10123,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 +10169,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 +10186,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 +10245,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 +10271,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 +10358,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: @@ -10008,33 +10792,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 @@ -10100,3 +10884,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/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/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/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 new file mode 100644 index 000000000..7a43a95ab --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubComponents.swift @@ -0,0 +1,973 @@ +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 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 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 + 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 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) { + // 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 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) + } + } + } + + private var header: some View { + HStack(spacing: 11) { + 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) + .accessibilityHidden(true) + } + + 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 onViewInWork: () -> Void + let onViewInLanes: () -> 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) + .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 { + VStack(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) + } + } + } + + 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. + 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) + + 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()) + } + .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 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..8a2dd303b --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -0,0 +1,968 @@ +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 + try await syncService.sendChatMessage( + sessionId: summary.sessionId, + text: opener, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + } + + // 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, + targetProjectId: targetProjectId, + targetProjectRootPath: targetProjectRootPath + ) + } + 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..c7247933f --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubScreen+ChatNavigation.swift @@ -0,0 +1,103 @@ +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, + forceFreshTranscriptOnOpen: true, + lanes: target.lane.map { [$0.asLaneSummary()] } ?? [] + ) + .id(target.id) + } else { + HubChatActivatingView(projectName: target.project.displayName, onClose: onClose) + } + } + } + .task { + // Activate the chat's project (keeping the hub) so transcript sync targets + // the right project, then render the chat. No-op when already active. + if syncService.isActiveProject(target.project) { + ready = true + return + } + await syncService.openProjectForHubChat(target.project) + // Only render the chat once the project switch actually landed; otherwise + // the cover would open against the wrong active project (failed/offline switch). + guard syncService.isActiveProject(target.project) else { return } + ready = true + } + } +} + +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..30568659c --- /dev/null +++ b/apps/ios/ADE/Views/Hub/HubScreen.swift @@ -0,0 +1,544 @@ +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? + // 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 { + 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) + }, + 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) } + ) + .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) { + 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(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() + activeRosterProjectId = syncService.activeProjectId + 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), + activeRosterProjectId == project.id, + 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 id = UUID().uuidString + let project: MobileProjectSummary + let lane: RemoteRosterLane? + let chat: RemoteRosterChat +} + +// 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/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/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 d6a350753..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 @@ -569,6 +572,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)) @@ -632,6 +636,7 @@ struct PRsTabView: View { .disabled(prsStatus.phase == .hydrating) Button { + createInitialLaneId = nil createPresented = true } label: { ZStack { @@ -659,9 +664,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") } } @@ -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/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/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/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..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 @@ -122,21 +292,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 +327,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 +356,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 +372,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 +450,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 +489,8 @@ struct WorkChatMessageBubble: View { return workAssistantMessagePreview( message.markdown, lineBudget: assistantLineBudget, - characterBudget: workAssistantMessageCharacterBudget(forLineBudget: assistantLineBudget) + characterBudget: workAssistantMessageCharacterBudget(forLineBudget: assistantLineBudget), + anchor: .head ) } @@ -380,6 +549,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 +589,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 = 128 +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 +644,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 ) } @@ -454,25 +661,63 @@ 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, - 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 + ? max(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 +759,94 @@ 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) + let steppedBudget = workAssistantMessageInitialCharacterBudget + (extraSteps * workAssistantMessageCharacterBudgetStep) + return max(steppedBudget, lineBudget * 96) +} + +func workAssistantMessageLineCount(_ text: String) -> Int { text.reduce(1) { count, character in character == "\n" ? count + 1 : count } @@ -526,7 +854,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 +866,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 +962,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 +1004,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/WorkChatPrViews.swift b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift new file mode 100644 index 000000000..e38e1bf9d --- /dev/null +++ b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift @@ -0,0 +1,527 @@ +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 laneColor: Color? + let canCreate: Bool + let createBlockedReason: String? + let isRefreshing: Bool + let errorMessage: String? + let onRefresh: () -> Void + let onCreate: () -> Void + let onOpenPrsTab: () -> Void + let onOpenGitHub: () -> Void + + private var sheetTitle: String { + guard let tag else { return "Pull request" } + return "PR #\(tag.githubPrNumber) \(lanePrStateLabel(tag.state))" + } + + 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 additions: Int { + pr?.additions ?? summary?.additions ?? 0 + } + + private var deletions: Int { + pr?.deletions ?? summary?.deletions ?? 0 + } + + var body: some View { + VStack(spacing: 0) { + topBar + + ScrollView { + if let tag { + existingPrContent(tag) + } else { + emptyPrContent + } + } + .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)) + } + } + .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 { + 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 + ) + + WorkChatPrBranchFlowCard( + headBranch: branches.head, + baseBranch: branches.base, + tint: branchTint + ) + + HStack(spacing: 10) { + WorkChatPrChangesMetricCard(additions: additions, deletions: deletions) + WorkChatPrChecksMetricCard(status: checksStatus) + } + + if let errorMessage, !errorMessage.isEmpty { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + } + + 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: 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) + .font(.footnote) + .foregroundStyle(ADEColor.warning) + } + + 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 { + Text(errorMessage) + .font(.footnote) + .foregroundStyle(ADEColor.danger) + } + } + .padding(.horizontal, 18) + .padding(.top, 6) + .padding(.bottom, 18) + } +} + +private struct WorkChatPrSummaryHeader: View { + let title: String + let updatedText: String + let symbol: String + let tint: Color + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbol) + .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 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) + } + .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 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) + } + if let summary { + return (summary.headBranch, summary.baseBranch) + } + let head = tag.headBranch.trimmingCharacters(in: .whitespacesAndNewlines) + 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", "passed", "success": + return "checkmark.circle.fill" + case "failing", "failed", "failure", "error": + return "xmark.circle.fill" + case "pending", "queued", "running", "in_progress": + return "clock.fill" + default: + return "circle" + } +} + +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 + } +} + +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: " ") + } +} diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 5c3bfc073..ebbb3a562 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[Int(UInt(bitPattern: workStableSubagentHash(id)) % UInt(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 = UInt(bitPattern: 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..bbef54bec 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Actions.swift @@ -2,82 +2,788 @@ 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..