diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index a9a7a69c3..b050276e0 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -290,6 +290,7 @@ ade --socket ios-sim apps --text ade --socket ios-sim launch --target target-id --text ade --socket ios-sim preview-match --source apps/ios/ADE/Views/Home.swift --line 42 --text ade --socket ios-sim preview-ensure --source apps/ios/ADE/Views/Home.swift --line 42 --text +ade --socket ios-sim preview-current --text ade --socket ios-sim preview-render --source apps/ios/ADE/Views/Home.swift --index 0 --text ade --socket app-control launch --command "npm run dev" --text ade --socket app-control focus --text diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index 14f38fbe4..b9d14052a 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -1,3 +1,4 @@ +import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -18,7 +19,9 @@ import { renderLaneGraph, resolveAdeCodeModulePath, resolveRoots, + runCli, shouldAutoRegisterProjectForPlan, + shouldBlockManualMachineRuntimeSpawn, shouldEnforceMachineRuntimeBuildCompatibility, shouldAttemptDesktopSocketConnection, summarizeExecution, @@ -31,6 +34,8 @@ type ResolveRootsOptions = Parameters[0]; process.env.ADE_ENABLE_AUTOMATIONS = "1"; process.env.ADE_ENABLE_MACOS_VM = "1"; +const crdtHostIt = process.platform === "darwin" ? it : it.skip; + function withEnv(updates: Record, run: () => T): T { const previous = new Map(); for (const key of Object.keys(updates)) { @@ -80,6 +85,35 @@ function expectExecutePlan( return plan; } +function writeSyncHostSingletonLock(args: { + lockPath: string; + pid: number; + port: number; + packageChannel: string | null; + adeHome: string; +}): void { + const now = "2026-06-11T00:00:00.000Z"; + fs.mkdirSync(path.dirname(args.lockPath), { recursive: true }); + fs.writeFileSync(args.lockPath, `${JSON.stringify({ + version: 1, + owner: { + id: "other-channel-brain", + pid: args.pid, + port: args.port, + appName: args.packageChannel === "beta" ? "ADE Beta" : "ADE", + packageChannel: args.packageChannel, + adeHome: args.adeHome, + serviceName: args.packageChannel === "beta" ? "com.ade.runtime.beta" : "com.ade.runtime", + socketPath: path.join(args.adeHome, "sock", "ade.sock"), + projectRoot: "/Users/admin/Projects/ADE", + commandLine: null, + quitCommand: `ADE_HOME='${args.adeHome}' ade brain stop --text`, + createdAt: now, + updatedAt: now, + }, + }, null, 2)}\n`, "utf8"); +} + describe("ADE CLI", () => { it("parses global options without stealing command flags", () => { const parsed = parseCliArgs([ @@ -330,6 +364,62 @@ describe("ADE CLI", () => { }); }); + crdtHostIt("serve fails instead of exiting successfully when another channel owns mobile sync", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-serve-conflict-")); + const projectRoot = path.join(adeHome, "project"); + const lockPath = path.join(adeHome, "sync-host-lock.json"); + const socketPath = path.join(adeHome, "sock", "ade.sock"); + fs.mkdirSync(projectRoot, { recursive: true }); + const originalEnv = { + ADE_HOME: process.env.ADE_HOME, + ADE_PROJECT_ROOT: process.env.ADE_PROJECT_ROOT, + ADE_PACKAGE_CHANNEL: process.env.ADE_PACKAGE_CHANNEL, + ADE_SYNC_HOST_LOCK_PATH: process.env.ADE_SYNC_HOST_LOCK_PATH, + ADE_SYNC_HOST_SINGLETON_TEST_MODE: process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE, + }; + const ownerProcess = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000);"], { + stdio: "ignore", + }); + ownerProcess.on("error", () => {}); + ownerProcess.unref(); + if (!ownerProcess.pid) { + throw new Error("Failed to start fake sync-host owner process."); + } + + try { + process.env.ADE_HOME = adeHome; + process.env.ADE_PROJECT_ROOT = projectRoot; + delete process.env.ADE_PACKAGE_CHANNEL; + process.env.ADE_SYNC_HOST_LOCK_PATH = lockPath; + process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE = "1"; + writeSyncHostSingletonLock({ + lockPath, + pid: ownerProcess.pid, + port: 8801, + packageChannel: "beta", + adeHome: path.join(os.homedir(), ".ade-beta"), + }); + + await expect(runCli(["serve", "--socket", socketPath])).rejects.toThrow( + "ADE brain refusing to run without mobile sync.", + ); + expect(fs.existsSync(socketPath)).toBe(false); + } finally { + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_PROJECT_ROOT === undefined) delete process.env.ADE_PROJECT_ROOT; + else process.env.ADE_PROJECT_ROOT = originalEnv.ADE_PROJECT_ROOT; + if (originalEnv.ADE_PACKAGE_CHANNEL === undefined) delete process.env.ADE_PACKAGE_CHANNEL; + else process.env.ADE_PACKAGE_CHANNEL = originalEnv.ADE_PACKAGE_CHANNEL; + if (originalEnv.ADE_SYNC_HOST_LOCK_PATH === undefined) delete process.env.ADE_SYNC_HOST_LOCK_PATH; + else process.env.ADE_SYNC_HOST_LOCK_PATH = originalEnv.ADE_SYNC_HOST_LOCK_PATH; + if (originalEnv.ADE_SYNC_HOST_SINGLETON_TEST_MODE === undefined) delete process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE; + else process.env.ADE_SYNC_HOST_SINGLETON_TEST_MODE = originalEnv.ADE_SYNC_HOST_SINGLETON_TEST_MODE; + ownerProcess.kill("SIGKILL"); + fs.rmSync(adeHome, { recursive: true, force: true }); + } + }); + it("recognizes the hidden PTY host worker entrypoint", () => { expect(buildCliPlan(["__ade-pty-host-worker"])).toEqual({ kind: "pty-host-worker", @@ -345,6 +435,19 @@ describe("ADE CLI", () => { expect(isEphemeralRuntimeSocketPath("tcp://127.0.0.1:8765")).toBe(false); }); + it("blocks manual service-socket runtime spawn when service mutation is disabled", () => { + expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(true); + expect(shouldBlockManualMachineRuntimeSpawn("/Users/example/.ade-beta/sock/ade.sock", {})).toBe(false); + expect(shouldBlockManualMachineRuntimeSpawn("tcp://127.0.0.1:9999", { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(false); + expect(shouldBlockManualMachineRuntimeSpawn(path.join(os.tmpdir(), "ade-code-test", "ade.sock"), { + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", + })).toBe(false); + }); + it("parses runtime idle expiry with a minimum clamp", () => { expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "30000" } as NodeJS.ProcessEnv)).toBe(30_000); expect(readRuntimeIdleExitMs({ ADE_RUNTIME_IDLE_EXIT_MS: "100" } as NodeJS.ProcessEnv)).toBe(5_000); @@ -4229,8 +4332,10 @@ describe("ADE CLI", () => { it("formats preview-match and preview-ensure text as Preview Lab output", () => { const matchPlan = expectExecutePlan(buildCliPlan(["ios-sim", "preview-match", "--source", "Views/HomeView.swift"])); const ensurePlan = expectExecutePlan(buildCliPlan(["ios-sim", "preview-ensure"])); + const currentPlan = expectExecutePlan(buildCliPlan(["ios-sim", "preview-current"])); expect(inferFormatter(matchPlan)).toBe("ios-sim-preview"); expect(inferFormatter(ensurePlan)).toBe("ios-sim-preview"); + expect(inferFormatter(currentPlan)).toBe("ios-sim-preview"); const output = formatOutput({ status: "missing-preview", @@ -4249,6 +4354,62 @@ describe("ADE CLI", () => { expect(output).toContain("ADE iOS Preview match"); expect(output).toMatch(/status\s+missing-preview/); expect(output).toMatch(/suggested file\s+apps\/ios\/ADE\/Views\/HomePreviews\.swift/); + + const currentOutput = formatOutput({ + ok: false, + match: { + status: "no-context", + confidence: "none", + target: null, + selectedSourceFile: null, + selectedSourceLine: null, + reason: "Select a source-backed simulator element first.", + }, + target: null, + render: null, + error: "Select a source-backed simulator element first.", + }, { + text: true, + pretty: false, + } as any, "ios-sim-preview"); + expect(currentOutput).toContain("ADE iOS Preview current"); + expect(currentOutput).toMatch(/status\s+no-context/); + }); + + it("ios-sim preview-current renders the currently selected simulator preview", () => { + const plan = expectExecutePlan(buildCliPlan([ + "ios-sim", + "preview-current", + "--source", + "Views/HomeView.swift", + "--line", + "44", + "--label", + "Settings", + "--component-id", + "settings-row", + "--tab", + "tab-1", + "--timeout", + "30", + "--project-root", + "/tmp/app", + ])); + expect(plan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "ios_simulator", + action: "renderCurrentPreview", + args: { + projectRoot: "/tmp/app", + sourceFile: "Views/HomeView.swift", + sourceLine: 44, + elementLabel: "Settings", + componentId: "settings-row", + tabIdentifier: "tab-1", + timeoutSec: 30, + }, + }, + }); }); it("ios-sim preview-render requires a source file and forwards render options", () => { diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 9f5d889e9..225422e63 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -790,6 +790,26 @@ const IOS_SIMULATOR_SUBCOMMAND_HELP: Record = { --tab, --tab-identifier Xcode window tab from preview-status. --timeout Render timeout, 5-240 seconds; default 120. --project-root ADE project root. +`, + "preview-current": `${ADE_BANNER} + iOS Simulator: preview-current + + Resolves and renders the Preview Lab target for the current simulator + selection. Run "select" first, or pass --source/--line explicitly. + Aliases: current-preview, preview-open-current, open-current-preview. + + $ ade --socket ios-sim select --x 120 --y 420 --text + $ ade --socket ios-sim preview-current --text + $ ade --socket ios-sim preview-current --source apps/ios/ADE/Views/Home.swift --line 42 --text + + Flags: + --source, --file

Optional Swift source file; defaults to last selected element. + --line Optional source line; defaults to last selected element. + --label Visible element label used for a suggested preview title. + --component-id ADEInspector component id used for a suggested preview. + --tab, --tab-identifier Xcode window tab from preview-status. + --timeout Render timeout, 5-240 seconds; default 120. + --project-root ADE project root. `, "preview-open": `${ADE_BANNER} iOS Simulator: preview-open @@ -917,6 +937,10 @@ const IOS_SIMULATOR_HELP_ALIASES: Record = { "preview-workspace": "preview-ensure", "render-preview": "preview-render", preview: "preview-render", + "current-preview": "preview-current", + "preview-open-current": "preview-current", + "open-current-preview": "preview-current", + "render-current-preview": "preview-current", "open-preview-workspace": "preview-open", "open-xcode": "preview-open", "start-stream": "stream-start", @@ -1387,6 +1411,7 @@ const HELP_BY_COMMAND: Record = { $ ade ios-sim previews --source --text List nearby #Preview definitions $ ade ios-sim preview-match --source Resolve best Preview Lab match $ ade ios-sim preview-ensure --text Open/wait for Xcode Preview Lab + $ ade ios-sim preview-current --text Render preview for the selected simulator UI $ ade ios-sim preview-render --source Render a SwiftUI preview through Xcode MCP Live view: @@ -7030,6 +7055,34 @@ function buildIosSimulatorPlan(args: string[]): CliPlan { ], }; } + if ( + sub === "preview-current" || + sub === "current-preview" || + sub === "preview-open-current" || + sub === "open-current-preview" || + sub === "render-current-preview" + ) { + return { + kind: "execute", + label: "iOS simulator current preview render", + steps: [ + actionStep( + "result", + "ios_simulator", + "renderCurrentPreview", + collectGenericObjectArgs(args, { + projectRoot: readValue(args, ["--project-root", "--root"]), + sourceFile: readValue(args, ["--source", "--file"]), + sourceLine: readNumberOption(args, ["--line"]), + elementLabel: readValue(args, ["--label"]), + componentId: readValue(args, ["--component-id", "--component"]), + tabIdentifier: readValue(args, ["--tab", "--tab-identifier"]), + timeoutSec: readNumberOption(args, ["--timeout"], 120), + }), + ), + ], + }; + } if ( sub === "preview-open" || sub === "open-preview-workspace" || @@ -12621,6 +12674,22 @@ function shouldRepairMachineRuntimeServiceBeforeSpawn( && !isEphemeralRuntimeSocketPath(socketPath); } +export function shouldBlockManualMachineRuntimeSpawn( + socketPath: string, + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL === "1" + && !socketPath.startsWith("tcp://") + && !isAdeRuntimeNamedPipePath(socketPath) + && !isEphemeralRuntimeSocketPath(socketPath); +} + +function manualMachineRuntimeSpawnBlockedError(socketPath: string): Error { + return new Error( + `ADE runtime is unavailable at ${socketPath}, and ADE_DISABLE_RUNTIME_SERVICE_INSTALL=1 forbids starting a manual replacement for this service-managed socket.`, + ); +} + async function repairMachineRuntimeServiceConnection(args: { socketPath: string; options: GlobalOptions; @@ -12771,6 +12840,10 @@ async function connectMachineRuntimeDaemon( client.close(); throw selfShutdownBlock; } + if (shouldBlockManualMachineRuntimeSpawn(socketPath)) { + client.close(); + throw manualMachineRuntimeSpawnBlockedError(socketPath); + } await shutdownMachineRuntimeDaemon(client); const repaired = await repairServiceConnection(); if (repaired) return repaired; @@ -12816,6 +12889,9 @@ async function connectMachineRuntimeDaemon( if (!allowSpawn) throw firstError; const repaired = await repairServiceConnection(); if (repaired) return repaired; + if (shouldBlockManualMachineRuntimeSpawn(socketPath)) { + throw manualMachineRuntimeSpawnBlockedError(socketPath); + } const spawned = await spawnMachineRuntimeDaemon(socketPath, options); if (!spawned) throw firstError; try { @@ -13462,6 +13538,15 @@ async function runServe( disposeScopesOnDispose: false, onShutdown: finish, }); + const startSyncHost = () => (preferredSyncProjectId + ? scopeRegistry.switchSyncHost(preferredSyncProjectId) + : scopeRegistry.resolveActiveSyncHost()); + const disposeServeResources = async () => { + await scopeRegistry.disposeAll(); + if (sharedSyncListener) { + await sharedSyncListener.close().catch(() => {}); + } + }; const listen = async ( server: net.Server, @@ -13486,6 +13571,38 @@ async function runServe( }); }; + if (syncEnabled) { + try { + const [{ runSyncHostStartupLoop }, { getRuntimeServiceMainPid }] = await Promise.all([ + import("./services/sync/syncHostStartupLoop"), + import("./serviceManager"), + ]); + await runSyncHostStartupLoop({ + startSyncHost, + isDone: () => done, + log: (message) => process.stderr.write(`${message}\n`), + getServiceMainPid: getRuntimeServiceMainPid, + }); + } catch (error: unknown) { + // Cross-channel conflict (another build's live brain owns mobile sync): + // real builds never run sync-less, so fail before publishing ade.sock. + const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); + const message = error instanceof Error ? error.message : String(error); + if (error instanceof SyncHostSingletonConflictError) { + await disposeServeResources(); + throw new CliExecutionError("ADE brain refusing to run without mobile sync.", { + cause: message, + socketPath, + nextAction: + "Stop the other ADE brain that owns mobile sync, then start this build again.", + }); + } + process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); + await disposeServeResources(); + throw error; + } + } + fs.mkdirSync(layout.adeDir, { recursive: true, mode: 0o700 }); if (!isAdeRuntimeNamedPipePath(socketPath)) { fs.mkdirSync(path.dirname(socketPath), { recursive: true, mode: 0o700 }); @@ -13511,37 +13628,6 @@ async function runServe( tcpUrl = `tcp://127.0.0.1:${port}`; } - if (syncEnabled) { - const startSyncHost = () => (preferredSyncProjectId - ? scopeRegistry.switchSyncHost(preferredSyncProjectId) - : scopeRegistry.resolveActiveSyncHost()); - void (async () => { - const [{ runSyncHostStartupLoop }, { getRuntimeServiceMainPid }] = await Promise.all([ - import("./services/sync/syncHostStartupLoop"), - import("./serviceManager"), - ]); - await runSyncHostStartupLoop({ - startSyncHost, - isDone: () => done, - log: (message) => process.stderr.write(`${message}\n`), - getServiceMainPid: getRuntimeServiceMainPid, - }); - })().catch(async (error: unknown) => { - // Cross-channel conflict (another build's live brain owns mobile sync): - // real builds never run sync-less, so fail the brain instead of coming - // up half-alive. The message carries the exact quit command. - const { SyncHostSingletonConflictError } = await import("./services/sync/syncHostSingleton"); - const message = error instanceof Error ? error.message : String(error); - if (error instanceof SyncHostSingletonConflictError) { - process.stderr.write(`ADE brain refusing to run without mobile sync.\n${message}\n`); - process.exitCode = 1; - finish(); - return; - } - process.stderr.write(`ADE brain sync host startup loop failed: ${message}\n`); - }); - } - process.stderr.write( `ADE brain listening on ${socketPath}${tcpUrl ? ` and ${tcpUrl}` : ""}\n`, ); @@ -13575,12 +13661,7 @@ async function runServe( for (const state of states) { stopHeadlessRpcServer(state); } - await scopeRegistry.disposeAll(); - // The brain is exiting: the shared listener (and any peers the last host - // service handed back to it) must close now — no successor will adopt them. - if (sharedSyncListener) { - await sharedSyncListener.close().catch(() => {}); - } + await disposeServeResources(); if (!isAdeRuntimeNamedPipePath(socketPath)) { try { fs.unlinkSync(socketPath); @@ -14560,6 +14641,36 @@ function formatIosSimPreview(value: unknown): string { ); } const record = isRecord(value) ? value : {}; + if (isRecord(record.match)) { + const match = record.match; + const target = isRecord(record.target) + ? record.target + : isRecord(match.target) + ? match.target + : null; + const render = isRecord(record.render) ? record.render : null; + return renderKeyValues("ADE iOS Preview current", [ + ["ok", record.ok], + ["status", match.status], + ["confidence", match.confidence], + [ + "selected", + match.selectedSourceFile + ? `${match.selectedSourceFile}${match.selectedSourceLine ? `:${match.selectedSourceLine}` : ""}` + : null, + ], + [ + "target", + target + ? `${target.title ?? "Preview"} · ${target.sourceFilePath ?? target.sourceFile ?? "unknown"}` + : null, + ], + ["snapshot", render?.previewSnapshotPath], + ["rendered", render?.renderedAt], + ["reason", match.reason], + ["error", record.error ?? render?.error], + ]); + } if (typeof record.status === "string" && "confidence" in record) { const target = isRecord(record.target) ? record.target : null; return renderKeyValues("ADE iOS Preview match", [ @@ -15556,6 +15667,7 @@ function inferFormatter( label === "ios simulator previews" || label === "ios simulator preview match" || label === "ios simulator preview workspace" || + label === "ios simulator current preview render" || label === "ios simulator preview render" || label === "ios simulator preview open" ) diff --git a/apps/ade-cli/src/serviceManager/common.test.ts b/apps/ade-cli/src/serviceManager/common.test.ts index 870716506..1525aabe3 100644 --- a/apps/ade-cli/src/serviceManager/common.test.ts +++ b/apps/ade-cli/src/serviceManager/common.test.ts @@ -274,6 +274,13 @@ describe("isStaleChannelServeCommandLine", () => { )).toBe(true); }); + it("matches old ADE CLI builds when they explicitly serve this channel socket", () => { + expect(isStaleChannelServeCommandLine( + `/Applications/ADE.app/Contents/MacOS/ADE /Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs serve --socket ${primarySocketPath}`, + opts, + )).toBe(true); + }); + it("ignores isolated, installer, and foreign-socket runtimes", () => { expect(isStaleChannelServeCommandLine(`${electron} ${cliScriptPath} serve --no-sync`, opts)).toBe(false); expect(isStaleChannelServeCommandLine(`${electron} ${cliScriptPath} serve --install-service`, opts)).toBe(false); @@ -285,6 +292,10 @@ describe("isStaleChannelServeCommandLine", () => { `${electron} ${cliScriptPath} serve --socket /Users/example/.ade-beta/sock/i-0c362cb4.sock --no-sync`, opts, )).toBe(false); + expect(isStaleChannelServeCommandLine( + `/Applications/ADE.app/Contents/MacOS/ADE /Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs serve --socket ${primarySocketPath} --no-sync`, + opts, + )).toBe(false); }); it("ignores other binaries and non-serve commands", () => { diff --git a/apps/ade-cli/src/serviceManager/common.ts b/apps/ade-cli/src/serviceManager/common.ts index d2496379a..4121be976 100644 --- a/apps/ade-cli/src/serviceManager/common.ts +++ b/apps/ade-cli/src/serviceManager/common.ts @@ -258,15 +258,25 @@ export function isStaleChannelServeCommandLine( ): boolean { const line = commandLine.trim(); if (!line || !opts.cliScriptPath) return false; + const socketMatch = line.match(/--socket(?:=|\s+)(\S+)/); + const explicitPrimarySocket = socketMatch + ? path.resolve(socketMatch[1]) === path.resolve(opts.primarySocketPath) + : false; const cliIndex = line.indexOf(opts.cliScriptPath); - if (cliIndex < 0) return false; - const tail = line.slice(cliIndex + opts.cliScriptPath.length); + const alternateCliMatch = line.match( + /\b(?:ade-cli[\\/](?:bin[\\/]ade|cli\.cjs)|apps[\\/]ade-cli[\\/]dist[\\/]cli\.cjs|cli\.cjs)(?=\s+serve(?:\s|$))/, + ); + const tail = cliIndex >= 0 + ? line.slice(cliIndex + opts.cliScriptPath.length) + : alternateCliMatch?.index != null + ? line.slice(alternateCliMatch.index + alternateCliMatch[0].length) + : ""; if (!/^\s+serve(?:\s|$)/.test(tail)) return false; if (/--(?:install-service|uninstall-service|service-status|no-sync)\b/.test(tail)) return false; - const socketMatch = tail.match(/--socket(?:=|\s+)(\S+)/); - if (socketMatch && path.resolve(socketMatch[1]) !== path.resolve(opts.primarySocketPath)) { + if (socketMatch && !explicitPrimarySocket) { return false; } + if (cliIndex < 0 && !explicitPrimarySocket) return false; return true; } diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 8c494b9fa..b6c1a8ea4 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -6,7 +6,9 @@ function makePayload(action: string, args: Record = {}): SyncCo return { commandId: "cmd-1", action, args }; } -function createService() { +function createService(options?: { + agentChatService?: Record; +}) { const ptyService = { resumeSession: vi.fn().mockResolvedValue({ sessionId: "session-1", @@ -20,6 +22,7 @@ function createService() { ptyService, sessionService: {}, fileService: {}, + ...(options?.agentChatService ? { agentChatService: options.agentChatService } : {}), logger: { debug: vi.fn(), warn: vi.fn(), error: vi.fn(), info: vi.fn() }, } as any); return { service, ptyService }; @@ -75,4 +78,41 @@ describe("createSyncRemoteCommandService", () => { sessionId: "session-1", }); }); + + it("routes the canonical chat history page command to the chat service", async () => { + const getChatEventHistoryPage = vi.fn().mockReturnValue({ + sessionId: "chat-1", + events: [], + startOffset: 128, + hasMore: true, + sessionFound: true, + }); + const { service } = createService({ + agentChatService: { getChatEventHistoryPage }, + }); + + expect(service.getDescriptor("chat.getChatEventHistoryPage")).toEqual({ + action: "chat.getChatEventHistoryPage", + scope: "project", + policy: { viewerAllowed: true }, + }); + + const result = await service.execute(makePayload("chat.getChatEventHistoryPage", { + sessionId: "chat-1", + beforeOffset: 4096, + maxBytes: 65_536, + })); + + expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-1", { + beforeOffset: 4096, + maxBytes: 65_536, + }); + expect(result).toEqual({ + sessionId: "chat-1", + events: [], + startOffset: 128, + hasMore: true, + sessionFound: true, + }); + }); }); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 687106efb..1f79224b0 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2380,12 +2380,9 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio nextCursor: hasMore ? String(oldestReturnedIndex) : null, }; }); - // Byte-offset transcript pagination for chat event envelopes (scroll-back - // beyond the hydrated tail). Mirrors the desktop `chat.getChatEventHistoryPage` - // action; cursor protocol lives on agentChatService.getChatEventHistoryPage. - register("agentChat.getEventHistoryPage", { viewerAllowed: true }, async (payload) => { + const getChatEventHistoryPage = async (payload: Record) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); - const sessionId = requireString(payload.sessionId, "agentChat.getEventHistoryPage requires sessionId."); + const sessionId = requireString(payload.sessionId, "chat.getChatEventHistoryPage requires sessionId."); const beforeOffset = typeof payload.beforeOffset === "number" && Number.isFinite(payload.beforeOffset) ? payload.beforeOffset : 0; @@ -2396,7 +2393,13 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio beforeOffset, ...(maxBytes != null ? { maxBytes } : {}), }); - }); + }; + // Byte-offset transcript pagination for chat event envelopes (scroll-back + // beyond the hydrated tail). The canonical action mirrors the desktop/TUI + // ADE action surface; the legacy agentChat.* name remains for older mobile + // clients that learned the first sync-only spelling. + register("chat.getChatEventHistoryPage", { viewerAllowed: true }, getChatEventHistoryPage); + register("agentChat.getEventHistoryPage", { viewerAllowed: true }, getChatEventHistoryPage); register("chat.create", { viewerAllowed: true, queueable: true }, async (payload) => { const agentChatService = requireService(args.agentChatService, "Agent chat service not available."); const parsed = parseAgentChatCreateArgs(payload); @@ -2956,14 +2959,21 @@ function registerPrAndDeeplinkRemoteCommands({ args, register }: RemoteCommandRe if (prId) refreshArgs = { prId }; else if (prIds.length > 0) refreshArgs = { prIds }; await args.prService.refresh(refreshArgs); - const prs = await args.prService.listAll(); + const allPrs = await args.prService.listAll(); + const requestedPrIds = new Set(prId ? [prId] : prIds); + const prs = requestedPrIds.size > 0 ? allPrs.filter((pr) => requestedPrIds.has(pr.id)) : allPrs; let refreshedCount = prs.length; if (prId) refreshedCount = 1; else if (prIds.length > 0) refreshedCount = prIds.length; + const snapshots = prId + ? args.prService.listSnapshots({ prId }).filter((snapshot) => requestedPrIds.has(snapshot.prId)) + : requestedPrIds.size > 0 + ? args.prService.listSnapshots().filter((snapshot) => requestedPrIds.has(snapshot.prId)) + : args.prService.listSnapshots(); return { refreshedCount, prs, - snapshots: args.prService.listSnapshots(), + snapshots, }; }); // iOS "Send to your Mac" deeplink bounce. Mobile cannot natively open a diff --git a/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md index 7dd0953c4..70630e5e5 100644 --- a/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-ios-simulator/SKILL.md @@ -65,15 +65,19 @@ ade --socket ios-sim preview-status --text ade --socket ios-sim previews --source --text ade --socket ios-sim preview-match --source --line --text ade --socket ios-sim preview-ensure --source --line --text +ade --socket ios-sim preview-current --text ade --socket ios-sim preview-render --source --index --text ``` -Start with `preview-match` when the selected simulator element exposes Swift source context; pass its `sourceFile` and optional `sourceLine`, plus `--label` / `--component-id` only as hints for naming a missing-preview suggestion. If it returns a usable target, render that target before changing code. Use `preview-ensure` when Xcode Preview Lab is not ready; it opens this lane's iOS project in Xcode and waits for MCP readiness. +To bridge the current simulator screen into Preview Lab, first select a source-backed element (`ade --socket ios-sim select --x --y --text`) or pass an explicit `--source` / `--line`, then run `ade --socket ios-sim preview-current --text`. That one command resolves the best nearby preview, opens/waits for Xcode when needed, renders through Xcode MCP, and brings the ADE Preview drawer forward. + +Use `preview-match` when you only need the target decision without rendering. The selected simulator element's `sourceFile` and optional `sourceLine` bias matching; `--label` / `--component-id` are only hints for naming a missing-preview suggestion. Use `preview-ensure` when Xcode Preview Lab is not ready; it opens this lane's iOS project in Xcode and waits for MCP readiness. Add or refine a preview only when no useful nearby preview exists or the match is too far from the selected element. Preview fixtures must not require live sync, keychain, network, push, sockets, or production databases. ## Gotchas - Do not create symlink projects, fake schemes, or repo-layout shims as the first fix for app detection. Re-run `ade --socket ios-sim apps --text` and report the selected project, scheme, and build output. +- If `preview-current` or `preview-match` returns `no-context`, do not guess the screen from stale code. Run `ade --socket ios-sim snapshot --text`, select a source-backed element, or pass an explicit source file/line. - If no simulator/session/snapshot exists, report the exact blocker instead of guessing the screen. - When you own the simulator session and the task no longer needs it, run `ade --socket ios-sim shutdown --text`. diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index ba5f778b4..a4ad29f63 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -3306,6 +3306,7 @@ app.whenReady().then(async () => { inspectPoint: "inspect", launch: "interact", openPreviewWorkspace: "preview", + renderCurrentPreview: "preview", renderPreview: "preview", selectPoint: "inspect", startStream: "interact", @@ -3359,6 +3360,11 @@ app.whenReady().then(async () => { requestIosSimulatorDrawerOpen("renderPreview", arg, result); return result; }, + renderCurrentPreview: async (arg?: Parameters[0]) => { + const result = await iosSimulatorService.renderCurrentPreview(arg); + requestIosSimulatorDrawerOpen("renderCurrentPreview", arg, result); + return result; + }, selectPoint: async (arg: Parameters[0]) => { const result = await iosSimulatorService.selectPoint(arg); requestIosSimulatorDrawerOpen("selectPoint", arg, result); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 379d21468..79500c3bd 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -68,8 +68,10 @@ describe("isAllowedAdeAction", () => { it("exposes iOS Preview Lab matching and workspace readiness to generic actions", () => { expect(isAllowedAdeAction("ios_simulator", "resolvePreviewMatch")).toBe(true); expect(isAllowedAdeAction("ios_simulator", "ensurePreviewWorkspace")).toBe(true); + expect(isAllowedAdeAction("ios_simulator", "renderCurrentPreview")).toBe(true); expect(isCtoOnlyAdeAction("ios_simulator", "resolvePreviewMatch")).toBe(false); expect(isCtoOnlyAdeAction("ios_simulator", "ensurePreviewWorkspace")).toBe(false); + expect(isCtoOnlyAdeAction("ios_simulator", "renderCurrentPreview")).toBe(false); }); it("exposes subagent transcript reads through the chat runtime action surface", () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index f4dee4e12..956e3e6c5 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -689,7 +689,7 @@ export const ADE_ACTION_ALLOWLIST: Partial ({ cursorSdkCloudRequests: [] as Array<{ type: string; payload: Record }>, cursorSdkCloudResponses: new Map(), cursorSendPromptGate: null as Promise | null, + cursorSendPromptError: null as unknown, droidAcquireCalls: [] as Array>, droidNewSessionCalls: [] as Array>, droidPromptCalls: [] as Array>, @@ -601,6 +602,33 @@ vi.mock("../../../shared/chatTranscript", () => ({ })); vi.mock("./cursorSdkPool", () => ({ + sanitizeCursorSdkWorkerBaseEnv: vi.fn((baseEnv: NodeJS.ProcessEnv) => { + const env = { ...baseEnv }; + delete env.CURSOR_API_KEY; + delete env.CURSOR_AUTH_TOKEN; + delete env.ADE_HOME; + delete env.ADE_PACKAGE_CHANNEL; + delete env.ADE_RUNTIME_SOCKET_PATH; + delete env.ADE_RPC_SOCKET_PATH; + delete env.ADE_DESKTOP_BRIDGE_SOCKET_PATH; + delete env.ADE_RUNTIME_BUILD_HASH; + delete env.ADE_RUNTIME_PARENT_PID; + delete env.ADE_RUNTIME_IDLE_EXIT_MS; + delete env.ADE_CLI_ENTRY_PATH; + delete env.ADE_CLI_JS; + delete env.ADE_CLI_INSTALL_NAME; + delete env.ADE_DEFAULT_ROLE; + delete env.ADE_DESKTOP_APP_NAME; + delete env.ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION; + delete env.ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL; + delete env.ELECTRON_RUN_AS_NODE; + return env; + }), + isCursorSdkPooledAlive: vi.fn((pooled: any) => + pooled?.process?.exitCode == null + && !pooled?.process?.killed + && pooled?.process?.connected !== false + ), acquireCursorSdkConnection: vi.fn(async (args: Record) => { mockState.cursorSdkAcquireCalls.push(args); const pooled: any = { @@ -654,6 +682,7 @@ vi.mock("./cursorSdkPool", () => ({ sendPrompt: vi.fn(async (payload: Record) => { mockState.cursorSdkSendCalls.push(payload); if (mockState.cursorSendPromptGate) await mockState.cursorSendPromptGate; + if (mockState.cursorSendPromptError) throw mockState.cursorSendPromptError; return { id: "cursor-sdk-run-1", status: "finished" }; }), updatePolicy: vi.fn(async (policy: Record) => { @@ -1485,6 +1514,7 @@ beforeEach(() => { mockState.cursorSdkCloudRequests = []; mockState.cursorSdkCloudResponses = new Map(); mockState.cursorSendPromptGate = null; + mockState.cursorSendPromptError = null; mockState.droidAcquireCalls = []; mockState.droidNewSessionCalls = []; mockState.droidPromptCalls = []; @@ -3884,6 +3914,52 @@ describe("createAgentChatService", () => { expect(spawnArgs).not.toContain("browser_use"); expect(spawnArgs).not.toContain("computer_use"); }); + + it("passes raw CLI access env to the Cursor SDK pool for worker sanitization", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const getAdeCliAgentEnv = vi.fn(() => ({ + PATH: "/Applications/ADE Beta.app/Contents/Resources/ade-cli/bin:/usr/bin", + ADE_PACKAGE_CHANNEL: "beta", + ADE_HOME: "/Users/admin/.ade-beta", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_CLI_PATH: "/Applications/ADE Beta.app/Contents/Resources/ade-cli/bin/ade-beta", + ADE_CLI_BIN_DIR: "/Applications/ADE Beta.app/Contents/Resources/ade-cli/bin", + ADE_CLI_ENTRY_PATH: "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ADE_CLI_JS: "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ADE_CLI_INSTALL_NAME: "ade-beta", + })); + + const { service } = createService({ getAdeCliAgentEnv }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Run locally.", + }, { awaitDispatch: true }); + + expect(getAdeCliAgentEnv).toHaveBeenCalled(); + const baseEnv = mockState.cursorSdkAcquireCalls.at(-1)?.baseEnv as NodeJS.ProcessEnv | undefined; + expect(baseEnv).toEqual(expect.objectContaining({ + ADE_CLI_PATH: "/Applications/ADE Beta.app/Contents/Resources/ade-cli/bin/ade-beta", + ADE_CLI_BIN_DIR: "/Applications/ADE Beta.app/Contents/Resources/ade-cli/bin", + ADE_PACKAGE_CHANNEL: "beta", + ADE_HOME: "/Users/admin/.ade-beta", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_CLI_ENTRY_PATH: "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ADE_CLI_JS: "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ADE_CLI_INSTALL_NAME: "ade-beta", + ADE_CHAT_SESSION_ID: session.id, + ADE_LANE_ID: "lane-1", + ADE_PROJECT_ROOT: tmpRoot, + })); + }); }); // -------------------------------------------------------------------------- @@ -6876,6 +6952,68 @@ describe("createAgentChatService", () => { } }); + it("surfaces Cursor SDK HTTP/2 backoff failures as rate limits", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + mockState.cursorSendPromptError = new Error( + "Cursor SDK send failed: Cursor rate limited this request: [internal] Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM", + ); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Trigger Cursor backoff.", + }, { awaitDispatch: true }); + + const errorEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "error" && event.sessionId === session.id, + ); + expect(errorEvent.event.message).toContain("Rate limited by Cursor"); + expect(errorEvent.event.errorInfo).toMatchObject({ + category: "rate_limit", + provider: "Cursor", + }); + expect(errorEvent.event.detail).toContain("NGHTTP2_ENHANCE_YOUR_CALM"); + }); + + it("reacquires Cursor SDK workers that exited before a follow-up turn", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "First Cursor turn.", + }); + const firstPooled = mockState.cursorSdkPooled; + firstPooled.process.exitCode = 1; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Follow-up after worker exit.", + }); + + expect(mockState.cursorSdkAcquireCalls).toHaveLength(2); + expect(firstPooled.sendPrompt).toHaveBeenCalledTimes(1); + expect(mockState.cursorSdkPooled).not.toBe(firstPooled); + expect(mockState.cursorSdkPooled.sendPrompt).toHaveBeenCalledTimes(1); + }); + it("reports active Droid SDK turns so project switching does not close the chat runtime", async () => { const events: AgentChatEventEnvelope[] = []; let finishTurn = () => {}; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index fa3b33339..f456f8f46 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -300,6 +300,7 @@ import { inspectLocalProvider } from "../ai/localModelDiscovery"; import { resolveDroidExecutable } from "../ai/droidExecutable"; import { acquireCursorSdkConnection, + isCursorSdkPooledAlive, releaseCursorSdkConnection, resolveCursorSdkUserHome, runCursorSdkCloudRequest, @@ -2820,12 +2821,19 @@ function classifyProviderHostError( if ( statusCode === 429 || combinedLower.includes("rate limit") + || combinedLower.includes("rate_limited") || combinedLower.includes("429") || combinedLower.includes("too many requests") + || combinedLower.includes("enhance_your_calm") + || combinedLower.includes("enhance your calm") + || combinedLower.includes("resource exhausted") + || combinedLower.includes("slow down") + || combinedLower.includes("back off") ) { + const rateLimitDetail = rawDetail || rawMessage; return { message: `Rate limited by ${providerLabel}. The runtime should recover automatically, but you may want to retry with a different model.`, - ...(rawDetail ? { detail: rawDetail } : {}), + ...(rateLimitDetail ? { detail: rateLimitDetail } : {}), errorInfo: { category: "rate_limit", provider: providerLabel, model: modelDisplayName }, }; } @@ -3151,6 +3159,10 @@ function isCursorSdkAgentBusyError(error: unknown): boolean { || message.includes("already running another task"); } +function isCursorSdkRuntimeProcessAlive(runtime: CursorRuntime): boolean { + return isCursorSdkPooledAlive(runtime.sdk); +} + function classifyCursorSdkChatError( error: unknown, args: { cloud?: boolean; modelDisplayName?: string | null } = {}, @@ -19720,14 +19732,25 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "cursor") { const existing = managed.runtime; if (existing.poolKey === poolKey) { - existing.sdkPolicy = policy; - existing.currentModeId = displayModeId; - existing.currentModelId = launchModelSdkId; - wireCursorSdkBridgeHandlers(managed, existing); - syncCursorModeSnapshot(managed, existing); - return existing; + if (!isCursorSdkRuntimeProcessAlive(existing)) { + logger.warn("agent_chat.cursor_sdk_dead_runtime_reacquire", { + sessionId: managed.session.id, + poolKey, + exitCode: existing.sdk.process.exitCode, + killed: existing.sdk.process.killed, + connected: existing.sdk.process.connected, + }); + teardownRuntime(managed, "handle_close"); + } else { + existing.sdkPolicy = policy; + existing.currentModeId = displayModeId; + existing.currentModelId = launchModelSdkId; + wireCursorSdkBridgeHandlers(managed, existing); + syncCursorModeSnapshot(managed, existing); + return existing; + } } - teardownRuntime(managed, "handle_close"); + if (managed.runtime?.kind === "cursor") teardownRuntime(managed, "handle_close"); } else if (managed.runtime) { teardownRuntime(managed, "handle_close"); } @@ -19774,6 +19797,7 @@ export function createAgentChatService(args: { poolKey, projectRoot, workspacePath: managed.laneWorktreePath, + baseEnv: buildAgentRuntimeEnv(managed), modelSdkId: launchModelSdkId, ...(launchModelParams?.length ? { modelParams: launchModelParams } : {}), apiKey, diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts index dc551e37f..b499edf42 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts @@ -184,6 +184,41 @@ describe("Cursor SDK event mapper", () => { }]); }); + it("uses Cursor SDK error detail when local status fails", () => { + expect(mapCursorSdkMessageToChatEvents({ + type: "status", + status: "ERROR", + error: { message: "Tool execution aborted" }, + }, mapperMeta())).toEqual([{ + type: "error", + message: "Tool execution aborted", + turnId: "turn-1", + }]); + }); + + it("keeps the generic Cursor SDK failure only when no detail is present", () => { + expect(mapCursorSdkMessageToChatEvents({ + type: "status", + status: "ERROR", + }, mapperMeta())).toEqual([{ + type: "error", + message: "Cursor SDK run failed.", + turnId: "turn-1", + }]); + }); + + it("does not stringify unknown Cursor SDK error objects into chat", () => { + expect(mapCursorSdkMessageToChatEvents({ + type: "status", + status: "ERROR", + error: { token: "secret-ish" }, + }, mapperMeta())).toEqual([{ + type: "error", + message: "Cursor SDK run failed.", + turnId: "turn-1", + }]); + }); + it("uses the provided task status map for task lifecycle transitions", () => { const taskStatusMap = new Map(); const started = mapCursorSdkMessageToChatEvents({ diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts index 2d3a6cf4a..cf367c6a1 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts @@ -15,6 +15,39 @@ function readString(value: unknown): string | null { return text.length ? text : null; } +function summarizeUnknown(value: unknown): string | null { + const direct = readString(value); + if (direct) return direct; + const record = asRecord(value); + if (record) { + const nested = + readString(record.message) + ?? readString(record.detail) + ?? readString(record.error) + ?? readString(record.reason) + ?? readString(record.description); + if (nested) return nested; + } + if (value == null) return null; + return typeof value === "number" || typeof value === "boolean" ? String(value) : null; +} + +function readStatusDetail(record: SdkMessageRecord): string | null { + for (const value of [ + record.message, + record.detail, + record.error, + record.reason, + record.description, + asRecord(record.data)?.message, + asRecord(record.data)?.error, + ]) { + const text = summarizeUnknown(value)?.trim(); + if (text) return text; + } + return null; +} + function readNumber(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } @@ -242,7 +275,7 @@ export function mapCursorSdkMessageToChatEvents( } case "status": { const statusText = readString(record.status); - const detail = readString(record.message); + const detail = readStatusDetail(record); if (runtime === "cloud") { const cloudStatus = normalizeCloudStatus(statusText); if (!cloudStatus) { diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts index ac210fa2a..be4a70de7 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -6,11 +7,13 @@ import { acquireCursorSdkConnection, buildCursorSdkPaths, buildCursorSdkWorkerEnv, + isCursorSdkPooledAlive, releaseCursorSdkConnection, resolveCursorSdkUserHome, } from "./cursorSdkPool"; const forkMock = vi.hoisted(() => vi.fn()); +const tempDirs: string[] = []; vi.mock("node:child_process", () => ({ fork: (...args: unknown[]) => forkMock(...args), @@ -76,10 +79,53 @@ class ExitingBeforeInitChild extends EventEmitter { } } +class ExitingWithStderrBeforeInitChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + exitCode: number | null = null; + killed = false; + connected = true; + + send(message: { type?: string }): boolean { + if (message.type === "init") { + queueMicrotask(() => { + this.stderr.emit( + "data", + [ + "ConnectError: [internal] Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM", + " rawMessage: 'Stream closed with error code NGHTTP2_ENHANCE_YOUR_CALM'", + "Node.js v26.0.0", + ].join("\n"), + ); + this.exitCode = 1; + this.connected = false; + this.emit("exit", 1, null); + }); + return true; + } + return true; + } + + kill(signal?: NodeJS.Signals): boolean { + this.killed = true; + this.emit("exit", null, signal ?? "SIGTERM"); + return true; + } +} + afterEach(() => { forkMock.mockReset(); + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } }); +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + describe("Cursor SDK pool paths", () => { it("uses the real user home while keeping ADE runtime state under the project cache", () => { const projectRoot = path.join(os.tmpdir(), "ade-project"); @@ -101,12 +147,36 @@ describe("Cursor SDK pool paths", () => { } }); - it("builds a worker environment with real HOME parity and ADE socket metadata", () => { + it("builds a worker environment with real HOME parity and no ADE brain ownership metadata", () => { + const cliRoot = makeTempDir("ade-cli-current-"); + const cliBinDir = path.join(cliRoot, "bin"); + const cliEntry = path.join(cliRoot, "cli.cjs"); + fs.mkdirSync(cliBinDir, { recursive: true }); + const adeCommand = path.join(cliBinDir, process.platform === "win32" ? "ade.cmd" : "ade"); + fs.writeFileSync(adeCommand, ""); + fs.writeFileSync(cliEntry, ""); const env = buildCursorSdkWorkerEnv({ baseEnv: { HOME: "/synthetic", USERPROFILE: "/synthetic-profile", PATH: "/bin", + ADE_CLI_ENTRY_PATH: cliEntry, + ADE_CLI_BIN_DIR: cliBinDir, + ADE_HOME: "/Users/admin/.ade-beta", + ADE_PACKAGE_CHANNEL: "beta", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_RPC_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_DESKTOP_BRIDGE_SOCKET_PATH: "/Users/admin/.ade-beta/sock/desktop-bridge.sock", + ADE_RUNTIME_BUILD_HASH: "old-build", + ADE_RUNTIME_PARENT_PID: "1234", + ADE_RUNTIME_IDLE_EXIT_MS: "300000", + ADE_CLI_JS: "/Applications/ADE.app/Contents/Resources/ade-cli/cli.cjs", + ADE_CLI_INSTALL_NAME: "ade-beta", + ADE_DEFAULT_ROLE: "cto", + ADE_DESKTOP_APP_NAME: "ADE Beta", + ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION: "1", + ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL: "1", + ELECTRON_RUN_AS_NODE: "1", CURSOR_API_KEY: "cursor-secret", CURSOR_AUTH_TOKEN: "cursor-token", }, @@ -121,12 +191,68 @@ describe("Cursor SDK pool paths", () => { expect(env.USERPROFILE).toBe("/Users/admin"); expect(env.CURSOR_API_KEY).toBeUndefined(); expect(env.CURSOR_AUTH_TOKEN).toBeUndefined(); + expect(env.ADE_HOME).toBeUndefined(); + expect(env.ADE_PACKAGE_CHANNEL).toBeUndefined(); + expect(env.ADE_RUNTIME_SOCKET_PATH).toBeUndefined(); + expect(env.ADE_RPC_SOCKET_PATH).toBeUndefined(); + expect(env.ADE_DESKTOP_BRIDGE_SOCKET_PATH).toBeUndefined(); + expect(env.ADE_RUNTIME_BUILD_HASH).toBeUndefined(); + expect(env.ADE_RUNTIME_PARENT_PID).toBeUndefined(); + expect(env.ADE_RUNTIME_IDLE_EXIT_MS).toBeUndefined(); + expect(env.ADE_CLI_JS).toBeUndefined(); + expect(env.ADE_CLI_INSTALL_NAME).toBeUndefined(); + expect(env.ADE_DEFAULT_ROLE).toBeUndefined(); + expect(env.ADE_DESKTOP_APP_NAME).toBeUndefined(); + expect(env.ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION).toBeUndefined(); + expect(env.ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL).toBeUndefined(); + expect(env.ELECTRON_RUN_AS_NODE).toBeUndefined(); + expect(env.ADE_CLI_ENTRY_PATH).toBeUndefined(); + expect(env.ADE_DISABLE_RUNTIME_SERVICE_INSTALL).toBe("1"); + expect(env.ADE_CLI_BIN_DIR).toBe(cliBinDir); + expect(env.ADE_CLI_PATH).toBe(adeCommand); + expect(env.PATH?.split(path.delimiter)[0]).toBe(cliBinDir); expect(env.ADE_CURSOR_SDK_SOCKET).toBe("/tmp/ade-cursor-sdk/socket.sock"); expect(env.ADE_CURSOR_SDK_LANE_ROOT).toBe("/repo/.ade/worktrees/lane"); expect(env.ADE_CURSOR_SDK_SESSION_ID).toBe("session-1"); expect(env.ADE_CURSOR_SDK_STATE_ROOT).toBe("/repo/.ade/cache/cursor-sdk/hash/state"); }); + it("normalizes stale ADE CLI metadata to the current command bin dir without exposing CLI internals", () => { + const stableRoot = makeTempDir("ade-cli-stable-"); + const betaRoot = makeTempDir("ade-cli-beta-"); + const stableEntry = path.join(stableRoot, "cli.cjs"); + const betaBinDir = path.join(betaRoot, "bin"); + const betaCommand = path.join(betaBinDir, process.platform === "win32" ? "ade-beta.cmd" : "ade-beta"); + fs.mkdirSync(betaBinDir, { recursive: true }); + fs.writeFileSync(stableEntry, ""); + fs.writeFileSync(betaCommand, ""); + + const env = buildCursorSdkWorkerEnv({ + baseEnv: { + PATH: "/usr/bin", + ADE_PACKAGE_CHANNEL: "beta", + ADE_HOME: "/Users/admin/.ade-beta", + ADE_RUNTIME_SOCKET_PATH: "/Users/admin/.ade-beta/sock/ade.sock", + ADE_CLI_ENTRY_PATH: stableEntry, + ADE_CLI_BIN_DIR: betaBinDir, + ADE_CLI_PATH: betaCommand, + }, + userHomeDir: "/Users/admin", + stateRoot: "/repo/.ade/cache/cursor-sdk/hash/state", + socketPath: "/tmp/ade-cursor-sdk/socket.sock", + workspacePath: "/repo/.ade/worktrees/lane", + sessionId: "session-1", + }); + + expect(env.ADE_CLI_ENTRY_PATH).toBeUndefined(); + expect(env.ADE_PACKAGE_CHANNEL).toBeUndefined(); + expect(env.ADE_HOME).toBeUndefined(); + expect(env.ADE_RUNTIME_SOCKET_PATH).toBeUndefined(); + expect(env.ADE_CLI_BIN_DIR).toBe(betaBinDir); + expect(env.ADE_CLI_PATH).toBe(betaCommand); + expect(env.PATH?.split(path.delimiter)[0]).toBe(betaBinDir); + }); + it("prefers HOME on POSIX and USERPROFILE on Windows when resolving the Cursor user home", () => { const resolved = resolveCursorSdkUserHome({ HOME: "/posix-home", @@ -170,6 +296,42 @@ describe("Cursor SDK pool paths", () => { expect(child.disposeCount).toBe(1); }); + it("does not reuse a worker whose IPC channel has closed", async () => { + const firstChild = new FakeSdkChild(); + const secondChild = new FakeSdkChild(); + forkMock + .mockReturnValueOnce(firstChild) + .mockReturnValueOnce(secondChild); + const poolKey = `test-disconnected:${Date.now()}:${Math.random()}`; + const args = { + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { + chatMode: "agent" as const, + approvalPolicy: "on-request" as const, + sandbox: "ade" as const, + force: false, + hardGuards: true, + }, + }; + + const first = await acquireCursorSdkConnection(args); + expect(isCursorSdkPooledAlive(first.pooled)).toBe(true); + (firstChild as unknown as { connected: boolean }).connected = false; + expect(isCursorSdkPooledAlive(first.pooled)).toBe(false); + + const second = await acquireCursorSdkConnection(args); + expect(second.pooled).not.toBe(first.pooled); + expect(second.generation).not.toBe(first.generation); + expect(firstChild.killed).toBe(true); + expect(forkMock).toHaveBeenCalledTimes(2); + + releaseCursorSdkConnection(poolKey, second.generation); + }); + it("rejects initialization instead of throwing when the worker IPC channel closes", async () => { forkMock.mockReturnValue(new ExitingBeforeInitChild()); const poolKey = `test-exit:${Date.now()}:${Math.random()}`; @@ -189,4 +351,24 @@ describe("Cursor SDK pool paths", () => { }, })).rejects.toThrow("Cursor SDK worker exited (1)."); }); + + it("includes recent worker stderr when a Cursor SDK worker exits", async () => { + forkMock.mockReturnValue(new ExitingWithStderrBeforeInitChild()); + const poolKey = `test-exit-stderr:${Date.now()}:${Math.random()}`; + + await expect(acquireCursorSdkConnection({ + poolKey, + projectRoot: path.join(os.tmpdir(), "ade-project"), + workspacePath: path.join(os.tmpdir(), "ade-workspace"), + modelSdkId: "cursor-model", + sessionId: "session-1", + policy: { + chatMode: "agent" as const, + approvalPolicy: "on-request" as const, + sandbox: "ade" as const, + force: false, + hardGuards: true, + }, + })).rejects.toThrow(/NGHTTP2_ENHANCE_YOUR_CALM/); + }); }); diff --git a/apps/desktop/src/main/services/chat/cursorSdkPool.ts b/apps/desktop/src/main/services/chat/cursorSdkPool.ts index 7a3bc099d..427e4676f 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkPool.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkPool.ts @@ -84,6 +84,26 @@ const pools = new Map(); const pendingInits = new Map>(); const STALE_INIT_RETRY_LIMIT = 2; +const CURSOR_SDK_WORKER_ENV_DENYLIST = [ + "CURSOR_API_KEY", + "CURSOR_AUTH_TOKEN", + "ADE_HOME", + "ADE_PACKAGE_CHANNEL", + "ADE_RUNTIME_SOCKET_PATH", + "ADE_RPC_SOCKET_PATH", + "ADE_DESKTOP_BRIDGE_SOCKET_PATH", + "ADE_RUNTIME_BUILD_HASH", + "ADE_RUNTIME_PARENT_PID", + "ADE_RUNTIME_IDLE_EXIT_MS", + "ADE_CLI_ENTRY_PATH", + "ADE_CLI_JS", + "ADE_CLI_INSTALL_NAME", + "ADE_DEFAULT_ROLE", + "ADE_DESKTOP_APP_NAME", + "ADE_ALLOW_RUNTIME_SERVICE_SELF_MUTATION", + "ADE_ALLOW_LOCAL_RELEASE_SERVICE_INSTALL", + "ELECTRON_RUN_AS_NODE", +] as const; const moduleDir = typeof __dirname === "string" ? __dirname @@ -113,13 +133,139 @@ function socketPathFor(poolKey: string): string { return path.join(os.tmpdir(), `ade-cursor-sdk-${userPart}`, name, "hook.sock"); } -function sanitizeEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +export function sanitizeCursorSdkWorkerBaseEnv(base: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env = { ...base }; - delete env.CURSOR_API_KEY; - delete env.CURSOR_AUTH_TOKEN; + for (const key of CURSOR_SDK_WORKER_ENV_DENYLIST) { + delete env[key]; + } return env; } +export function isCursorSdkPooledAlive(pooled: CursorSdkPooled): boolean { + return pooled.process.exitCode == null + && !pooled.process.killed + && pooled.process.connected !== false; +} + +function pathEnvKey(env: NodeJS.ProcessEnv): string { + if (process.platform !== "win32") return "PATH"; + if (env.PATH !== undefined) return "PATH"; + if (env.Path !== undefined) return "Path"; + return "PATH"; +} + +function prependPathDir(env: NodeJS.ProcessEnv, dir: string | null | undefined): void { + if (!dir?.trim()) return; + try { + if (!fs.statSync(dir).isDirectory()) return; + } catch { + return; + } + const key = pathEnvKey(env); + const current = env[key]?.trim(); + const parts = current ? current.split(path.delimiter) : []; + if (parts.some((part) => path.resolve(part) === path.resolve(dir))) return; + env[key] = current ? `${dir}${path.delimiter}${current}` : dir; +} + +function prependPathList(existing: string | undefined, root: string | null): string | undefined { + if (!root) return existing; + try { + if (!fs.statSync(root).isDirectory()) return existing; + } catch { + return existing; + } + const parts = (existing ?? "").split(path.delimiter).filter(Boolean); + if (parts.some((part) => path.resolve(part) === path.resolve(root))) return existing; + return [root, ...parts].join(path.delimiter); +} + +function existingFilePath(candidate: string | null | undefined): string | null { + const trimmed = candidate?.trim(); + if (!trimmed) return null; + try { + const resolved = path.resolve(trimmed); + return fs.statSync(resolved).isFile() ? resolved : null; + } catch { + return null; + } +} + +function existingDirPath(candidate: string | null | undefined): string | null { + const trimmed = candidate?.trim(); + if (!trimmed) return null; + try { + const resolved = path.resolve(trimmed); + return fs.statSync(resolved).isDirectory() ? resolved : null; + } catch { + return null; + } +} + +function commandFileName(name: string): string { + return process.platform === "win32" ? `${name}.cmd` : name; +} + +function adeCommandNameCandidates(env: NodeJS.ProcessEnv): string[] { + const names = [ + env.ADE_CLI_PATH ? path.basename(env.ADE_CLI_PATH, process.platform === "win32" ? ".cmd" : "") : "", + env.ADE_CLI_INSTALL_NAME, + env.ADE_PACKAGE_CHANNEL === "alpha" || env.ADE_PACKAGE_CHANNEL === "beta" + ? `ade-${env.ADE_PACKAGE_CHANNEL}` + : "", + "ade", + "ade-dev", + ]; + return Array.from(new Set(names.map((name) => name?.trim()).filter((name): name is string => Boolean(name)))); +} + +function findAdeCommandInBinDir(binDir: string | null, env: NodeJS.ProcessEnv): string | null { + if (!binDir) return null; + for (const name of adeCommandNameCandidates(env)) { + const candidate = existingFilePath(path.join(binDir, commandFileName(name))); + if (!candidate) continue; + return candidate; + } + return null; +} + +function inferAdeCliBinDirFromEntry(cliEntry: string | null): string | null { + if (!cliEntry) return null; + return existingDirPath(path.join(path.dirname(cliEntry), "bin")); +} + +function inferAdeCliEntryFromBinDir(binDir: string | null): string | null { + if (!binDir) return null; + return existingFilePath(path.resolve(binDir, "..", "cli.cjs")); +} + +function applyCurrentAdeCliEnv( + env: NodeJS.ProcessEnv, + sourceEnv: NodeJS.ProcessEnv = env, +): void { + const envCliEntry = existingFilePath(sourceEnv.ADE_CLI_ENTRY_PATH ?? env.ADE_CLI_ENTRY_PATH); + const argvCliEntry = existingFilePath(typeof process.argv[1] === "string" ? process.argv[1] : null); + const binDir = existingDirPath(sourceEnv.ADE_CLI_BIN_DIR ?? env.ADE_CLI_BIN_DIR) + ?? inferAdeCliBinDirFromEntry(envCliEntry) + ?? inferAdeCliBinDirFromEntry(argvCliEntry); + if (binDir) { + env.ADE_CLI_BIN_DIR = binDir; + prependPathDir(env, binDir); + const commandPath = findAdeCommandInBinDir(binDir, sourceEnv) + ?? findAdeCommandInBinDir(binDir, env); + if (commandPath) env.ADE_CLI_PATH = commandPath; + } + const cliEntry = inferAdeCliEntryFromBinDir(binDir) ?? envCliEntry ?? argvCliEntry; + if (cliEntry) env.ADE_CLI_ENTRY_PATH = cliEntry; + else delete env.ADE_CLI_ENTRY_PATH; + const bundledSkillsRoot = binDir + ? path.resolve(binDir, "..", "..", "agent-skills") + : cliEntry + ? path.resolve(path.dirname(cliEntry), "..", "agent-skills") + : null; + env.ADE_AGENT_SKILLS_DIRS = prependPathList(env.ADE_AGENT_SKILLS_DIRS, bundledSkillsRoot); +} + function ensurePrivateDirectory(dir: string): void { fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); let fd: number | null = null; @@ -183,21 +329,27 @@ export function buildCursorSdkWorkerEnv(args: { workspacePath: string; sessionId: string; }): NodeJS.ProcessEnv { - return { - ...sanitizeEnv(args.baseEnv ?? process.env), + const baseEnv = args.baseEnv ?? process.env; + const env: NodeJS.ProcessEnv = { + ...sanitizeCursorSdkWorkerBaseEnv(baseEnv), HOME: args.userHomeDir, USERPROFILE: args.userHomeDir, + ADE_DISABLE_RUNTIME_SERVICE_INSTALL: "1", ADE_CURSOR_SDK_SOCKET: args.socketPath, ADE_CURSOR_SDK_LANE_ROOT: args.workspacePath, ADE_CURSOR_SDK_SESSION_ID: args.sessionId, ADE_CURSOR_SDK_STATE_ROOT: args.stateRoot, }; + applyCurrentAdeCliEnv(env, baseEnv); + delete env.ADE_CLI_ENTRY_PATH; + return env; } export async function acquireCursorSdkConnection(args: { poolKey: string; projectRoot: string; workspacePath: string; + baseEnv?: NodeJS.ProcessEnv; modelSdkId: string; modelParams?: CursorSdkModelParameterValue[]; apiKey?: string | null; @@ -211,12 +363,13 @@ export async function acquireCursorSdkConnection(args: { }): Promise<{ pooled: CursorSdkPooled; generation: number }> { for (let staleInitRetries = 0; ; staleInitRetries += 1) { const existing = pools.get(args.poolKey); - if (existing && existing.pooled.process.exitCode == null && !existing.pooled.process.killed) { + if (existing && isCursorSdkPooledAlive(existing.pooled)) { existing.ref += 1; return { pooled: existing.pooled, generation: existing.generation }; } if (existing) { pools.delete(args.poolKey); + existing.pooled.dispose(); cleanupCursorSdkRuntimePaths(existing); } @@ -232,9 +385,7 @@ export async function acquireCursorSdkConnection(args: { const pooled = await init; const entry = pools.get(args.poolKey); - const live = entry?.pooled === pooled - && pooled.process.exitCode == null - && !pooled.process.killed; + const live = entry?.pooled === pooled && isCursorSdkPooledAlive(pooled); if (!entry || !live) { if (initOwner) { throw new Error("Cursor SDK worker was disposed during initialization."); @@ -258,6 +409,7 @@ async function createCursorSdkConnection(args: Parameters ( error instanceof Error ? error : new Error(String(error)) ); + let lastStderr = ""; + const rememberStderr = (text: string): void => { + const trimmed = text.trim(); + if (!trimmed) return; + lastStderr = `${lastStderr}\n${trimmed}`.trim().slice(-4000); + }; + const summarizeStderr = (): string | null => { + const lines = lastStderr + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + if (!lines.length) return null; + const meaningful = [ + lines.find((line) => /^(ConnectError|Error|TypeError|ReferenceError|SyntaxError):/.test(line)), + lines.find((line) => line.includes("NGHTTP2_ENHANCE_YOUR_CALM")), + lines.find((line) => line.includes("rawMessage:")), + lines.find((line) => line.startsWith("Node.js ")), + ].filter((line): line is string => Boolean(line)); + const unique = Array.from(new Set(meaningful)); + return (unique.length ? unique : lines.slice(0, 3)).join(" "); + }; + const workerExitedError = (code: number | null, signal: NodeJS.Signals | null): Error => { + const exitStatus = code ?? signal ?? "unknown"; + const detail = summarizeStderr(); + return new Error(detail + ? `Cursor SDK worker exited (${exitStatus}). ${detail}` + : `Cursor SDK worker exited (${exitStatus}).`); + }; const sendWorkerMessage = ( message: CursorSdkWorkerRequest, onError?: (error: Error) => void, @@ -317,6 +497,7 @@ async function createCursorSdkConnection(args: Parameters { const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk); + rememberStderr(text); if (text.trim()) args.logger?.warn("agent_chat.cursor_sdk_worker_stderr", { text: text.trim() }); }); @@ -352,7 +533,11 @@ async function createCursorSdkConnection(args: Parameters { for (const [, waiter] of pending) waiter.reject(new Error("Cursor SDK worker disposed.")); pending.clear(); - sendWorkerMessage({ type: "dispose", requestId: randomUUID() } as CursorSdkWorkerRequest); + const sent = sendWorkerMessage({ type: "dispose", requestId: randomUUID() } as CursorSdkWorkerRequest); + if (!sent && child.exitCode == null && !child.killed) { + child.kill("SIGTERM"); + return; + } setTimeout(() => { if (child.exitCode == null && !child.killed) child.kill("SIGTERM"); }, 800).unref(); @@ -480,7 +665,7 @@ async function createCursorSdkConnection(args: Parameters { - rejectPending(new Error(`Cursor SDK worker exited (${code ?? signal ?? "unknown"}).`)); + rejectPending(workerExitedError(code, signal)); cleanupPoolEntry(pooled); }); diff --git a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts index 33facf0c5..06f35e0c9 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkWorker.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkWorker.ts @@ -41,6 +41,9 @@ const hookWaiters = new Map void>() const cloudRuns = new Map(); let cloudModelsCache: { at: number; keyHash: string; models: SDKModel[] } | null = null; const CLOUD_MODEL_VALIDATION_TTL_MS = 120_000; +const activeRequests = new Map(); +const reportedRequests = new Set(); +let unhandledExitScheduled = false; function post(message: CursorSdkWorkerResponse): void { if (process.send) { @@ -122,6 +125,18 @@ function isAgentBusyError(error: unknown): boolean { || message.includes("active run in progress"); } +function isCursorSdkBackoffError(error: unknown): boolean { + const message = errorMessage(error).toLowerCase(); + return message.includes("enhance_your_calm") + || message.includes("enhance your calm") + || message.includes("too many requests") + || message.includes("rate limit") + || message.includes("rate_limited") + || message.includes("resource exhausted") + || message.includes("slow down") + || message.includes("back off"); +} + function classifyWorkerError(error: unknown): { error: string; errorCode?: string } { if (isAgentBusyError(error)) { const detail = errorMessage(error); @@ -133,6 +148,15 @@ function classifyWorkerError(error: unknown): { error: string; errorCode?: strin errorCode: "agent_busy", }; } + if (isCursorSdkBackoffError(error)) { + const detail = errorMessage(error); + return { + error: detail + ? `Cursor rate limited this request: ${detail}` + : "Cursor rate limited this request. Wait a moment and retry.", + errorCode: "rate_limited", + }; + } const code = errorCode(error); return { error: errorMessage(error), @@ -806,6 +830,36 @@ async function dispatch(req: CursorSdkWorkerRequest): Promise { } } +function reportRequestFailure(requestId: string, error: unknown): void { + if (reportedRequests.has(requestId)) return; + reportedRequests.add(requestId); + post({ type: "response", requestId, ok: false, ...classifyWorkerError(error) }); +} + +function scheduleExitAfterUnhandled(): void { + if (unhandledExitScheduled) return; + unhandledExitScheduled = true; + setTimeout(() => { + void dispose().finally(() => process.exit(1)); + }, 20).unref(); +} + +function handleUnhandledWorkerError(error: unknown, origin: "unhandledRejection" | "uncaughtException"): void { + post({ + type: "log", + level: "warn", + message: "Cursor SDK worker caught an unhandled SDK failure.", + detail: { origin, error: errorMessage(error) }, + }); + const activeRequest = Array.from(activeRequests.entries()) + .reverse() + .find(([, type]) => type !== "hook_response" && type !== "dispose"); + if (activeRequest) { + reportRequestFailure(activeRequest[0], error); + } + scheduleExitAfterUnhandled(); +} + process.on("message", (raw: unknown) => { const req = raw as CursorSdkWorkerRequest; if (!req || typeof req !== "object" || !("type" in req)) return; @@ -814,15 +868,29 @@ process.on("message", (raw: unknown) => { await dispatch(req); return; } + activeRequests.set(req.requestId, req.type); try { const result = await dispatch(req); - post({ type: "response", requestId: req.requestId, ok: true, result }); + if (!reportedRequests.has(req.requestId)) { + post({ type: "response", requestId: req.requestId, ok: true, result }); + } } catch (error) { - post({ type: "response", requestId: req.requestId, ok: false, ...classifyWorkerError(error) }); + reportRequestFailure(req.requestId, error); + } finally { + activeRequests.delete(req.requestId); + reportedRequests.delete(req.requestId); } })(); }); +process.on("unhandledRejection", (error) => { + handleUnhandledWorkerError(error, "unhandledRejection"); +}); + +process.on("uncaughtException", (error) => { + handleUnhandledWorkerError(error, "uncaughtException"); +}); + for (const signal of ["SIGINT", "SIGTERM"] as const) { process.on(signal, () => { void dispose().finally(() => process.exit(0)); diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts index 2a6d844af..cfe57a699 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.test.ts @@ -614,6 +614,32 @@ describe("iosSimulatorService Xcode preview parsing", () => { } }); + it("returns an actionable no-context result for current preview rendering", async () => { + const projectRoot = fs.mkdtempSync(`${os.tmpdir()}/ade-ios-preview-current-no-context-`); + const service = createIosSimulatorService({ + projectRoot, + logger: noopLogger, + }); + + try { + const result = await service.renderCurrentPreview(); + + expect(result).toMatchObject({ + ok: false, + target: null, + render: null, + match: { + status: "no-context", + confidence: "none", + }, + }); + expect(result.error).toMatch(/select --x --y /); + } finally { + service.dispose(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + } + }); + it("resolves an exact Preview Lab match for the selected Swift file", async () => { const projectRoot = fs.mkdtempSync(`${os.tmpdir()}/ade-ios-preview-match-`); const iosDir = path.join(projectRoot, "apps", "ios", "ADE", "Views"); diff --git a/apps/desktop/src/main/services/ios/iosSimulatorService.ts b/apps/desktop/src/main/services/ios/iosSimulatorService.ts index 767f292ba..c0b8c89d8 100644 --- a/apps/desktop/src/main/services/ios/iosSimulatorService.ts +++ b/apps/desktop/src/main/services/ios/iosSimulatorService.ts @@ -23,6 +23,8 @@ import type { IosSimulatorPreviewMatch, IosSimulatorPreviewTarget, IosSimulatorPreviewWindow, + IosSimulatorRenderCurrentPreviewArgs, + IosSimulatorRenderCurrentPreviewResult, IosSimulatorRenderPreviewArgs, IosSimulatorRenderPreviewResult, IosSimulatorEventPayload, @@ -2550,7 +2552,26 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { .slice(0, 50); }; - const resolvePreviewMatch = async (previewArgs: IosSimulatorListPreviewsArgs = {}): Promise => { + const previewArgsWithLastSelection = ( + previewArgs: IosSimulatorListPreviewsArgs = {}, + ): IosSimulatorListPreviewsArgs => { + const hasSource = typeof previewArgs.sourceFile === "string" && previewArgs.sourceFile.trim().length > 0; + if (hasSource || !lastSelectedItem?.sourceFile) return previewArgs; + const metadata = isRecord(lastSelectedItem.metadata) ? lastSelectedItem.metadata : {}; + const label = typeof metadata.label === "string" && metadata.label.trim() + ? metadata.label + : null; + return { + ...previewArgs, + sourceFile: lastSelectedItem.sourceFile, + sourceLine: previewArgs.sourceLine ?? lastSelectedItem.sourceLine, + elementLabel: previewArgs.elementLabel ?? label, + componentId: previewArgs.componentId ?? lastSelectedItem.componentId, + }; + }; + + const resolvePreviewMatch = async (rawPreviewArgs: IosSimulatorListPreviewsArgs = {}): Promise => { + const previewArgs = previewArgsWithLastSelection(rawPreviewArgs); const projectRoot = resolveProjectRoot(previewArgs.projectRoot); const rawSourceFile = previewArgs.sourceFile?.trim() ?? ""; const selectedFile = resolveSwiftSourceFile(projectRoot, previewArgs.sourceFile); @@ -2565,7 +2586,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { status: "no-context", target: null, confidence: "none", - reason: "Select an inspectable simulator element before opening a screen in Preview Lab.", + reason: "Select a source-backed simulator element first (`ade --socket ios-sim select --x --y --text`) or pass `--source --line ` before opening the screen in Preview Lab.", selectedSourceFile: null, selectedSourceLine, ...suggestion, @@ -2620,6 +2641,37 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { }; }; + const renderCurrentPreview = async ( + previewArgs: IosSimulatorRenderCurrentPreviewArgs = {}, + ): Promise => { + const match = await resolvePreviewMatch(previewArgs); + const target = match.target; + if (!target) { + return { + ok: false, + match, + target: null, + render: null, + error: match.reason, + }; + } + + const render = await renderPreview({ + projectRoot: previewArgs.projectRoot, + sourceFilePath: target.sourceFilePath, + previewDefinitionIndexInFile: target.previewDefinitionIndexInFile, + tabIdentifier: previewArgs.tabIdentifier, + timeoutSec: previewArgs.timeoutSec, + }); + return { + ok: render.ok, + match, + target, + render, + error: render.error, + }; + }; + const ensurePreviewWorkspace = async ( ensureArgs: IosSimulatorEnsurePreviewWorkspaceArgs = {}, ): Promise => { @@ -3678,6 +3730,7 @@ export function createIosSimulatorService(args: CreateIosSimulatorServiceArgs) { listPreviewTargets, resolvePreviewMatch, ensurePreviewWorkspace, + renderCurrentPreview, renderPreview, openPreviewWorkspace, startStream, diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts index 3e803ce81..38f979eae 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.test.ts @@ -72,9 +72,13 @@ describe("ipcInvokeTimeoutMs", () => { it("extends iOS Preview Lab matching and workspace readiness timeouts", () => { expect(ipcInvokeTimeoutMs(IPC.iosSimulatorResolvePreviewMatch)).toBe(2 * 60_000); expect(ipcInvokeTimeoutMs(IPC.iosSimulatorEnsurePreviewWorkspace)).toBe(2 * 60_000); + expect(ipcInvokeTimeoutMs(IPC.iosSimulatorRenderCurrentPreview)).toBe(2 * 60_000); expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ request: { domain: "ios_simulator", action: "ensurePreviewWorkspace", args: {} }, }])).toBe(2 * 60_000); + expect(ipcInvokeTimeoutMs(IPC.localRuntimeCallAction, [{ + request: { domain: "ios_simulator", action: "renderCurrentPreview", args: {} }, + }])).toBe(2 * 60_000); expect(ipcInvokeTimeoutMs(IPC.remoteRuntimeCallAction, [{ id: "target-1", projectId: "project-1", diff --git a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts index a4dc8794f..c140635f4 100644 --- a/apps/desktop/src/main/services/ipc/ipcTimeouts.ts +++ b/apps/desktop/src/main/services/ipc/ipcTimeouts.ts @@ -30,6 +30,7 @@ const RUNTIME_ACTION_CHANNEL: Record> = { ios_simulator: { resolvePreviewMatch: IPC.iosSimulatorResolvePreviewMatch, ensurePreviewWorkspace: IPC.iosSimulatorEnsurePreviewWorkspace, + renderCurrentPreview: IPC.iosSimulatorRenderCurrentPreview, }, }; @@ -127,6 +128,7 @@ export function ipcInvokeTimeoutMs(channel: string, args: readonly unknown[] = [ case IPC.iosSimulatorListPreviewTargets: case IPC.iosSimulatorResolvePreviewMatch: case IPC.iosSimulatorEnsurePreviewWorkspace: + case IPC.iosSimulatorRenderCurrentPreview: case IPC.iosSimulatorRenderPreview: return 2 * 60_000; case IPC.iosSimulatorOpenPreviewWorkspace: diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 1531cd870..d96e606a0 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -7056,6 +7056,9 @@ export function registerIpc({ ipcMain.handle(IPC.iosSimulatorEnsurePreviewWorkspace, async (_event, arg = {}) => ensureIosSimulator().ensurePreviewWorkspace(arg)); + ipcMain.handle(IPC.iosSimulatorRenderCurrentPreview, async (_event, arg = {}) => + ensureIosSimulator().renderCurrentPreview(arg)); + ipcMain.handle(IPC.iosSimulatorRenderPreview, async (_event, arg) => ensureIosSimulator().renderPreview(arg)); ipcMain.handle(IPC.iosSimulatorOpenPreviewWorkspace, async (_event, arg = {}) => diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 2a660958d..b4a443180 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -1787,6 +1787,89 @@ describe("local runtime connection pool", () => { })); }); + it("does not spawn a primary sync runtime when service repair is configured but unavailable", async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never, { + preferServiceRepair: true, + }); + const internals = pool as unknown as { + createConnection: () => Promise; + tryConnect: (socketPath: string) => Promise; + tryRepairServiceConnection: (socketPath: string, reason: "missing") => Promise; + spawnRuntime: (socketPath: string) => ChildProcess; + }; + const tryConnect = vi.spyOn(internals, "tryConnect").mockResolvedValue(null); + const tryRepair = vi.spyOn(internals, "tryRepairServiceConnection").mockResolvedValue(null); + const spawnRuntime = vi.spyOn(internals, "spawnRuntime"); + + await expect(internals.createConnection()).rejects.toThrow( + /refusing to spawn an app-owned sync-enabled brain/i, + ); + + expect(tryConnect).toHaveBeenCalled(); + expect(tryRepair).toHaveBeenCalled(); + expect(spawnRuntime).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "local_runtime.service_repair_fallback_blocked", + expect.objectContaining({ socketPath: expect.any(String) }), + ); + }); + + it("does not spawn a primary sync runtime when service repair is not configured", async () => { + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-primary-block-")); + const originalEnv = { + ADE_HOME: process.env.ADE_HOME, + ADE_RUNTIME_SOCKET_PATH: process.env.ADE_RUNTIME_SOCKET_PATH, + }; + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never); + const internals = pool as unknown as { + createConnection: () => Promise; + tryConnect: (socketPath: string) => Promise; + tryRepairServiceConnection: (socketPath: string, reason: "missing") => Promise; + spawnRuntime: (socketPath: string) => ChildProcess; + }; + const tryConnect = vi.spyOn(internals, "tryConnect").mockResolvedValue(null); + const tryRepair = vi.spyOn(internals, "tryRepairServiceConnection").mockResolvedValue(null); + const spawnRuntime = vi.spyOn(internals, "spawnRuntime"); + + try { + process.env.ADE_HOME = adeHome; + delete process.env.ADE_RUNTIME_SOCKET_PATH; + + await expect(internals.createConnection()).rejects.toThrow( + /refusing to spawn an app-owned brain on a primary channel socket/i, + ); + + expect(tryConnect).toHaveBeenCalledWith(path.join(adeHome, "sock", "ade.sock")); + expect(tryRepair).toHaveBeenCalled(); + expect(spawnRuntime).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + "local_runtime.primary_runtime_spawn_blocked", + expect.objectContaining({ + socketPath: path.join(adeHome, "sock", "ade.sock"), + preferServiceRepair: false, + }), + ); + } finally { + if (originalEnv.ADE_HOME === undefined) delete process.env.ADE_HOME; + else process.env.ADE_HOME = originalEnv.ADE_HOME; + if (originalEnv.ADE_RUNTIME_SOCKET_PATH === undefined) delete process.env.ADE_RUNTIME_SOCKET_PATH; + else process.env.ADE_RUNTIME_SOCKET_PATH = originalEnv.ADE_RUNTIME_SOCKET_PATH; + removeTempDir(adeHome); + } + }); + it("routes local sync calls through the project-scoped runtime RPC", async () => { const call = vi.fn().mockResolvedValue({ mode: "standalone", diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index eccc49133..5b053ce9e 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -2,6 +2,7 @@ import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import fs from "node:fs"; import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { app } from "electron"; import { isAdeRuntimeNamedPipePath } from "../../../shared/adeRuntimeIpc"; @@ -78,6 +79,36 @@ const COALESCED_LOCAL_RUNTIME_ACTIONS = new Set([ "tiling_tree.get", ]); +function normalizeComparableSocketPath(socketPath: string): string { + return socketPath.startsWith("tcp://") || isAdeRuntimeNamedPipePath(socketPath) + ? socketPath + : path.resolve(socketPath); +} + +function defaultChannelRuntimeSocketPaths(): Set { + return new Set([".ade", ".ade-alpha", ".ade-beta"].map((homeName) => + path.join(os.homedir(), homeName, "sock", "ade.sock") + ).map(normalizeComparableSocketPath)); +} + +function isPrimaryMachineRuntimeSocketPath( + socketPath: string, + layoutSocketPath: string, +): boolean { + const normalizedSocketPath = normalizeComparableSocketPath(socketPath); + if (normalizedSocketPath === normalizeComparableSocketPath(layoutSocketPath)) { + return true; + } + return defaultChannelRuntimeSocketPaths().has(normalizedSocketPath); +} + +function primaryRuntimeSpawnBlockedMessage(socketPath: string): string { + return ( + `ADE runtime is unavailable at ${socketPath}; refusing to spawn an app-owned brain ` + + "on a primary channel socket. Start or repair the ADE background service instead." + ); +} + function stableActionValue(value: unknown): unknown { if (!value || typeof value !== "object") return value; if (Array.isArray(value)) return value.map(stableActionValue); @@ -1219,6 +1250,23 @@ export class LocalRuntimeConnectionPool { throw new Error(releaseBuildBlock.message); } + if (isPrimaryMachineRuntimeSocketPath(socketPath, layout.socketPath)) { + const message = this.options.preferServiceRepair + ? `ADE service repair did not restore the runtime endpoint at ${socketPath}; ` + + "refusing to spawn an app-owned sync-enabled brain on the primary service socket." + : primaryRuntimeSpawnBlockedMessage(socketPath); + this.logger.warn(this.options.preferServiceRepair + ? "local_runtime.service_repair_fallback_blocked" + : "local_runtime.primary_runtime_spawn_blocked", { + socketPath, + message, + serviceState: this.serviceInstallStatus.state, + serviceMessage: this.serviceInstallStatus.message, + preferServiceRepair: this.options.preferServiceRepair === true, + }); + throw new Error(message); + } + const child = this.spawnRuntime(socketPath); try { await waitForSocket(socketPath); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 5aa7fcb4b..66a28af5c 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -3131,10 +3131,28 @@ describe("createSyncRemoteCommandService", () => { describe("execute — prs.refresh", () => { it("refreshes single PR by prId", async () => { - prService.listAll.mockResolvedValue([{ id: "pr-1" }]); + prService.listAll.mockResolvedValue([{ id: "pr-1" }, { id: "pr-2" }]); + prService.listSnapshots.mockReturnValue([{ prId: "pr-1" }, { prId: "pr-2" }]); const result = await service.execute(makePayload("prs.refresh", { prId: "pr-1" })); expect(prService.refresh).toHaveBeenCalledWith({ prId: "pr-1" }); - expect(result).toEqual(expect.objectContaining({ refreshedCount: 1 })); + expect(prService.listSnapshots).toHaveBeenCalledWith({ prId: "pr-1" }); + expect(result).toEqual(expect.objectContaining({ + refreshedCount: 1, + prs: [{ id: "pr-1" }], + snapshots: [{ prId: "pr-1" }], + })); + }); + + it("scopes multi-PR refresh payloads by prIds", async () => { + prService.listAll.mockResolvedValue([{ id: "pr-1" }, { id: "pr-2" }, { id: "pr-3" }]); + prService.listSnapshots.mockReturnValue([{ prId: "pr-1" }, { prId: "pr-2" }, { prId: "pr-3" }]); + const result = await service.execute(makePayload("prs.refresh", { prIds: ["pr-1", "pr-3"] })); + expect(prService.refresh).toHaveBeenCalledWith({ prIds: ["pr-1", "pr-3"] }); + expect(result).toEqual(expect.objectContaining({ + refreshedCount: 2, + prs: [{ id: "pr-1" }, { id: "pr-3" }], + snapshots: [{ prId: "pr-1" }, { prId: "pr-3" }], + })); }); it("refreshes all PRs when no prId or prIds given", async () => { diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 9624c6043..d41525708 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -563,6 +563,8 @@ import type { IosSimulatorPreviewCapability, IosSimulatorPreviewMatch, IosSimulatorPreviewTarget, + IosSimulatorRenderCurrentPreviewArgs, + IosSimulatorRenderCurrentPreviewResult, IosSimulatorRenderPreviewArgs, IosSimulatorRenderPreviewResult, IosScreenSnapshot, @@ -1493,6 +1495,9 @@ declare global { ensurePreviewWorkspace: ( args?: IosSimulatorEnsurePreviewWorkspaceArgs, ) => Promise; + renderCurrentPreview: ( + args?: IosSimulatorRenderCurrentPreviewArgs, + ) => Promise; renderPreview: ( args: IosSimulatorRenderPreviewArgs, ) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0f7279cc1..47b1ebf3a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -576,6 +576,8 @@ import type { IosSimulatorPreviewCapability, IosSimulatorPreviewMatch, IosSimulatorPreviewTarget, + IosSimulatorRenderCurrentPreviewArgs, + IosSimulatorRenderCurrentPreviewResult, IosSimulatorRenderPreviewArgs, IosSimulatorRenderPreviewResult, IosScreenSnapshot, @@ -5708,6 +5710,15 @@ contextBridge.exposeInMainWorld("ade", { { args }, () => ipcRenderer.invoke(IPC.iosSimulatorEnsurePreviewWorkspace, args), ), + renderCurrentPreview: async ( + args: IosSimulatorRenderCurrentPreviewArgs = {}, + ): Promise => + callProjectRuntimeActionOr( + "ios_simulator", + "renderCurrentPreview", + { args }, + () => ipcRenderer.invoke(IPC.iosSimulatorRenderCurrentPreview, args), + ), renderPreview: async ( args: IosSimulatorRenderPreviewArgs, ): Promise => diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 409a32159..30d90884d 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4806,6 +4806,23 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { capability: BROWSER_MOCK_PREVIEW_CAPABILITY_UNSUPPORTED, error: "Browser preview cannot manage Xcode.", } as any), + renderCurrentPreview: resolvedArg({ + ok: false, + match: { + status: "no-context", + target: null, + confidence: "none", + reason: "Browser preview has no iOS simulator context.", + selectedSourceFile: null, + selectedSourceLine: null, + suggestedTitle: null, + suggestedSourceFile: null, + suggestedSourceFilePath: null, + }, + target: null, + render: null, + error: "Browser preview cannot manage Xcode.", + } as any), renderPreview: resolvedArg({} as any), openPreviewWorkspace: resolved({ ok: true as const, path: "/tmp" }), startStream: resolvedArg({ streaming: false, streamUrl: null } as any), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index ac9bba45c..adeaf3eca 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -6099,6 +6099,50 @@ describe("mergeChatHistorySnapshot", () => { expect(merged[1]).toBe(second); }); + it("preserves already-paged older rows when a fresh bounded tail snapshot overlaps", () => { + const older = envelope("2026-04-30T23:10:00.000Z", 1002, "already paged older"); + const firstTail = envelope("2026-04-30T23:14:47.751Z", 1003, "tail first"); + const secondTail = envelope("2026-04-30T23:19:57.083Z", 1004, "tail second"); + const parsedFirstTail = envelope("2026-04-30T23:14:47.751Z", 1003, "tail first"); + const parsedSecondTail = envelope("2026-04-30T23:19:57.083Z", 1004, "tail second refreshed"); + + const merged = mergeChatHistorySnapshot( + [parsedFirstTail, parsedSecondTail], + [older, firstTail, secondTail], + ); + + expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ + "already paged older", + "tail first", + "tail second refreshed", + ]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(firstTail); + }); + + it("preserves already-paged older rows when only a later tail row overlaps", () => { + const older = envelope("2026-04-30T23:10:00.000Z", 1001, "already paged older"); + const firstTail = envelope("2026-04-30T23:14:47.751Z", 1002, "tail first"); + const secondTail = envelope("2026-04-30T23:19:57.083Z", 1003, "tail second"); + const recoveredBeforeOverlap = envelope("2026-04-30T23:14:40.000Z", 1004, "recovered before overlap"); + const parsedSecondTail = envelope("2026-04-30T23:19:57.083Z", 1003, "tail second"); + + const merged = mergeChatHistorySnapshot( + [recoveredBeforeOverlap, parsedSecondTail], + [older, firstTail, secondTail], + ); + + expect(merged.map((entry) => entry.event.type === "text" ? entry.event.text : "")).toEqual([ + "already paged older", + "tail first", + "recovered before overlap", + "tail second", + ]); + expect(merged[0]).toBe(older); + expect(merged[1]).toBe(firstTail); + expect(merged[3]).toBe(secondTail); + }); + it("reuses existing snapshot entries while appending newly recovered events", () => { const first = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); const parsedFirst = envelope("2026-04-30T23:14:47.751Z", 1003, "first"); @@ -6224,6 +6268,26 @@ describe("mergeOlderChatHistoryPageWithCap", () => { ]); expect(merged.hitResidentCap).toBe(true); }); + + it("keeps prepended history when the loaded snapshot has reached the initial hydration cap", () => { + const older = envelope("2026-06-10T08:59:00.000Z", 0, "older-page"); + const existing = Array.from({ length: 20_000 }, (_, index) => + envelope( + new Date(Date.UTC(2026, 5, 10, 9, 0, index % 60)).toISOString(), + index + 1, + `loaded-${index}`, + )); + + const merged = mergeOlderChatHistoryPageWithCap({ + older: [older], + existing, + maxEvents: 60_000, + }); + + expect(merged.hitResidentCap).toBe(false); + expect(merged.events).toHaveLength(existing.length + 1); + expect(merged.events[0]).toBe(older); + }); }); describe("advanceOlderHistoryCursor", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 15ccb912a..e6b8ceefa 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -622,6 +622,7 @@ function buildSubagentEventHistory(args: { const CHAT_HISTORY_READ_MAX_BYTES = 2_000_000; const MAX_RETAINED_CHAT_SESSION_HISTORIES = 6; const MAX_SELECTED_CHAT_SESSION_EVENTS = 20_000; +const MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS = 60_000; const MAX_BACKGROUND_CHAT_SESSION_EVENTS = 1_000; const EMPTY_DRAFT_LAUNCH_JOBS: DraftLaunchJob[] = []; @@ -1032,9 +1033,10 @@ function writeAgentChatSessionViewCache( events: AgentChatEventEnvelope[], derived = deriveRuntimeState(events), historyCursor: number | null = null, + maxEvents = MAX_SELECTED_CHAT_SESSION_EVENTS, ): void { if (!AGENT_CHAT_VIEW_CACHE_ENABLED) return; - const trimmed = trimChatEventHistory(events, MAX_SELECTED_CHAT_SESSION_EVENTS); + const trimmed = trimChatEventHistory(events, maxEvents); agentChatSessionViewCacheBySessionId.delete(sessionId); agentChatSessionViewCacheBySessionId.set(sessionId, { events: trimmed, @@ -1738,9 +1740,12 @@ export function mergeChatHistorySnapshot( if (!parsed.length) return existing; const existingByKey = new Map(); - for (const entry of existing) { + const existingIndexByKey = new Map(); + for (let index = 0; index < existing.length; index += 1) { + const entry = existing[index]!; const key = chatEventDedupKey(entry); if (!existingByKey.has(key)) existingByKey.set(key, entry); + if (!existingIndexByKey.has(key)) existingIndexByKey.set(key, index); } const parsedKeys = new Set(); const normalizedParsed = parsed.map((entry) => { @@ -1748,6 +1753,13 @@ export function mergeChatHistorySnapshot( parsedKeys.add(key); return existingByKey.get(key) ?? entry; }); + let firstOverlapIndex = -1; + for (const entry of parsed) { + const index = existingIndexByKey.get(chatEventDedupKey(entry)) ?? -1; + if (index >= 0 && (firstOverlapIndex < 0 || index < firstOverlapIndex)) { + firstOverlapIndex = index; + } + } const lastParsedKey = chatEventDedupKey(parsed[parsed.length - 1]!); let overlapIndex = -1; for (let index = existing.length - 1; index >= 0; index -= 1) { @@ -1768,7 +1780,12 @@ export function mergeChatHistorySnapshot( return entry.timestamp > parsed[parsed.length - 1]!.timestamp; }); const tail = tailCandidates.filter((entry) => !parsedKeys.has(chatEventDedupKey(entry))); - const merged = tail.length ? [...normalizedParsed, ...tail] : normalizedParsed; + const olderPrefix = firstOverlapIndex > 0 + ? existing.slice(0, firstOverlapIndex).filter((entry) => !parsedKeys.has(chatEventDedupKey(entry))) + : []; + const merged = olderPrefix.length || tail.length + ? [...olderPrefix, ...normalizedParsed, ...tail] + : normalizedParsed; if (merged.length === existing.length && merged.every((entry, index) => entry === existing[index])) { return existing; } @@ -5166,11 +5183,11 @@ export function AgentChatPane({ if (nextCursor <= 0) break; beforeOffset = nextCursor; } + const maxEvents = sessionId === selectedSessionIdRef.current || sessionId === lockSessionId + ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS + : MAX_BACKGROUND_CHAT_SESSION_EVENTS; if (olderEvents.length) { const existing = eventsBySessionRef.current[sessionId] ?? []; - const maxEvents = sessionId === selectedSessionIdRef.current || sessionId === lockSessionId - ? MAX_SELECTED_CHAT_SESSION_EVENTS - : MAX_BACKGROUND_CHAT_SESSION_EVENTS; const { events: merged, hitResidentCap } = mergeOlderChatHistoryPageWithCap({ older: olderEvents, existing, @@ -5181,13 +5198,14 @@ export function AgentChatPane({ eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); } - writeAgentChatSessionViewCache(sessionId, merged, undefined, nextCursor); + writeAgentChatSessionViewCache(sessionId, merged, undefined, nextCursor, maxEvents); } else { writeAgentChatSessionViewCache( sessionId, eventsBySessionRef.current[sessionId] ?? [], undefined, nextCursor, + maxEvents, ); } applyOlderHistoryCursor(sessionId, nextCursor); @@ -5754,7 +5772,7 @@ export function AgentChatPane({ const updated = trimChatEventHistory( [...sessionEvents, envelope], sessionId === selectedSessionIdRef.current || sessionId === lockSessionId - ? MAX_SELECTED_CHAT_SESSION_EVENTS + ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS : MAX_BACKGROUND_CHAT_SESSION_EVENTS, ); if (next === eventsBySessionRef.current) { @@ -5775,7 +5793,16 @@ export function AgentChatPane({ const pendingSteerPatch: Record = {}; for (const sessionId of touchedSessionIds) { const derived = deriveRuntimeState(next[sessionId] ?? []); - writeAgentChatSessionViewCache(sessionId, next[sessionId] ?? [], derived, olderHistoryCursorRef.current[sessionId] ?? null); + const maxEvents = sessionId === selectedSessionIdRef.current || sessionId === lockSessionId + ? MAX_SELECTED_CHAT_SESSION_RESIDENT_EVENTS + : MAX_BACKGROUND_CHAT_SESSION_EVENTS; + writeAgentChatSessionViewCache( + sessionId, + next[sessionId] ?? [], + derived, + olderHistoryCursorRef.current[sessionId] ?? null, + maxEvents, + ); activePatch[sessionId] = derived.turnActive; pendingInputPatch[sessionId] = derived.pendingInputs; pendingSteerPatch[sessionId] = derived.pendingSteers; diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx index 6093ae7b9..dcd243fdc 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.test.tsx @@ -221,6 +221,21 @@ function installIosSimulatorApi(options: { suggestedSourceFile: "ContentPreviews.swift", suggestedSourceFilePath: "apps/ios/ContentPreviews.swift", }); + const renderPreviewResult = { + ok: true, + target: { + sourceFilePath: previewTarget.sourceFilePath, + previewDefinitionIndexInFile: previewTarget.previewDefinitionIndexInFile, + tabIdentifier: previewCapability.selectedWindow?.tabIdentifier ?? null, + }, + previewSnapshotPath: ".ade/artifacts/preview.png", + dataUrl: "data:image/png;base64,preview", + width: 390, + height: 844, + renderedAt: "2026-04-29T00:00:00.000Z", + capability: options.previewCapability ?? previewCapability, + error: null, + }; const api = { getStatus: vi.fn().mockResolvedValue(options.status ?? activeStatus), listDevices: vi.fn().mockResolvedValue([device]), @@ -291,21 +306,14 @@ function installIosSimulatorApi(options: { capability: options.previewCapability ?? previewCapability, error: null, }), - renderPreview: vi.fn().mockResolvedValue({ - ok: true, - target: { - sourceFilePath: previewTarget.sourceFilePath, - previewDefinitionIndexInFile: previewTarget.previewDefinitionIndexInFile, - tabIdentifier: previewCapability.selectedWindow?.tabIdentifier ?? null, - }, - previewSnapshotPath: ".ade/artifacts/preview.png", - dataUrl: "data:image/png;base64,preview", - width: 390, - height: 844, - renderedAt: "2026-04-29T00:00:00.000Z", - capability: options.previewCapability ?? previewCapability, - error: null, + renderCurrentPreview: vi.fn().mockResolvedValue({ + ok: Boolean(effectivePreviewMatch.target), + match: effectivePreviewMatch, + target: effectivePreviewMatch.target, + render: effectivePreviewMatch.target ? renderPreviewResult : null, + error: effectivePreviewMatch.target ? null : effectivePreviewMatch.reason, }), + renderPreview: vi.fn().mockResolvedValue(renderPreviewResult), openPreviewWorkspace: vi.fn(), tap: vi.fn().mockResolvedValue(undefined), typeText: vi.fn().mockResolvedValue(undefined), @@ -817,18 +825,16 @@ describe("ChatIosSimulatorPanel", () => { fireEvent.click(await screen.findByRole("button", { name: /Open in preview|Find preview|Create preview/ })); expect(await screen.findByText("ContentView.swift:12")).toBeTruthy(); - expect(api.resolvePreviewMatch).toHaveBeenCalledWith({ + expect(api.renderCurrentPreview).toHaveBeenCalledWith({ projectRoot: "/tmp/project", sourceFile: inspectElement.sourceFile, sourceLine: inspectElement.sourceLine, elementLabel: inspectElement.label, componentId: inspectElement.componentId, + tabIdentifier: null, + timeoutSec: 120, }); - expect(api.renderPreview).toHaveBeenCalledWith(expect.objectContaining({ - projectRoot: "/tmp/project", - sourceFilePath: previewTarget.sourceFilePath, - previewDefinitionIndexInFile: previewTarget.previewDefinitionIndexInFile, - })); + expect(api.renderPreview).not.toHaveBeenCalled(); }); it("treats Swift #fileID source paths as the same Preview Lab match", async () => { @@ -929,8 +935,8 @@ describe("ChatIosSimulatorPanel", () => { screenElements: [inspectElement, settingsElement], previewTargets: [], }); - api.resolvePreviewMatch.mockImplementation(async (args?: { sourceFile?: string | null }) => ( - args?.sourceFile === "SettingsView.swift" + api.renderCurrentPreview.mockImplementation(async (args?: { sourceFile?: string | null }) => { + const match = args?.sourceFile === "SettingsView.swift" ? settingsMatch : { status: "missing-preview", @@ -942,8 +948,15 @@ describe("ChatIosSimulatorPanel", () => { suggestedTitle: "Continue Preview", suggestedSourceFile: "ContentPreviews.swift", suggestedSourceFilePath: "apps/ios/ContentPreviews.swift", - } - )); + }; + return { + ok: false, + match, + target: null, + render: null, + error: match.reason, + }; + }); render( { fireEvent.click(await screen.findByRole("button", { name: "Create preview" })); await waitFor(() => { - expect(api.resolvePreviewMatch).toHaveBeenCalledWith({ + expect(api.renderCurrentPreview).toHaveBeenCalledWith({ projectRoot: "/tmp/project", sourceFile: inspectElement.sourceFile, sourceLine: inspectElement.sourceLine, elementLabel: inspectElement.label, componentId: inspectElement.componentId, + tabIdentifier: null, + timeoutSec: 120, }); expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("ContentView.swift:12")); expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("Continue Preview")); @@ -1043,7 +1058,7 @@ describe("ChatIosSimulatorPanel", () => { await waitFor(() => { expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("No renderable #Preview was found")); - expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("ade ios-sim preview-match")); + expect(onInsertDraft).toHaveBeenCalledWith(expect.stringContaining("ade --socket ios-sim preview-current")); expect(api.renderPreview).not.toHaveBeenCalled(); }); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx index bbeed6f40..2e6b8e01e 100644 --- a/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatIosSimulatorPanel.tsx @@ -288,6 +288,7 @@ function previewBridgeActionForSelection( match: IosSimulatorPreviewMatch | null, element: IosScreenElement | null, ): PreviewBridgeAction { + if (!element && match?.status === "matched" && match.target) return "open"; if (!element) return "create"; if (!previewMatchBelongsToElement(match, element)) return "find"; if (match?.status === "matched" && match.target) return "open"; @@ -1673,58 +1674,60 @@ export function ChatIosSimulatorPanel({ const element = inspectBridgeElement; const elementSource = element?.sourceFile ?? null; const elementSourceLine = element?.sourceLine ?? null; - let match = previewMatch; - if (!previewMatchBelongsToElement(match, element)) { - setPreviewRefreshing(true); - setMessage("Finding a Preview Lab target for the frozen simulator frame..."); - try { - match = await window.ade.iosSimulator.resolvePreviewMatch({ - projectRoot, - sourceFile: elementSource, - sourceLine: elementSourceLine, - elementLabel: element ? elementLabel(element) : null, - componentId: element?.componentId ?? null, - }); - setPreviewMatch(match); - const matchedTarget = match.target; - if (matchedTarget) { - setPreviewTargets((current) => ( - current.some((target) => target.id === matchedTarget.id) - ? current - : [matchedTarget, ...current] - )); - } - } catch (error) { - setMessage(error instanceof Error ? error.message : String(error)); + setPreviewRefreshing(true); + setMessage("Opening the current simulator selection in Preview Lab..."); + try { + const current = await window.ade.iosSimulator.renderCurrentPreview({ + projectRoot, + sourceFile: elementSource, + sourceLine: elementSourceLine, + elementLabel: element ? elementLabel(element) : null, + componentId: element?.componentId ?? null, + tabIdentifier: previewCapability?.selectedWindow?.tabIdentifier ?? null, + timeoutSec: 120, + }); + const match = current.match; + setPreviewMatch(match); + const matchingTarget = current.target; + if (matchingTarget) { + setPreviewTargets((targets) => ( + targets.some((target) => target.id === matchingTarget.id) + ? targets + : [matchingTarget, ...targets] + )); + } + if (current.render?.capability) { + setPreviewCapability(current.render.capability); + } + if (current.render) { + setPreviewResult(current.render); + } + if (matchingTarget && current.render) { + setMode("preview"); + setPreviewMode("control"); + setSelectedPreviewTargetId(matchingTarget.id); + setMessage(current.ok + ? `Rendered ${matchingTarget.title}.` + : current.error ?? "Preview render failed."); return; - } finally { - setPreviewRefreshing(false); } + setPreviewAgentHelpAction("open-simulator-in-preview"); + setMessage(elementSource + ? `No #Preview matched ${elementSource}. Drafting an agent-backed preview task...` + : "No source-backed simulator element is selected. Drafting an agent prompt with the current snapshot workflow..."); + void draftPreviewAgentHelpRef.current?.("open-simulator-in-preview", { + selectedElement: element, + previewTarget: null, + previewMatch: match, + previewResult: null, + includePreviewAttachment: false, + }); + } catch (error) { + setMessage(error instanceof Error ? error.message : String(error)); + } finally { + setPreviewRefreshing(false); } - const matchingTarget = match?.target - ?? (elementSource - ? previewTargets.find((target) => target.sourceFile === elementSource || target.sourceFilePath === elementSource) ?? null - : null); - if (matchingTarget) { - setMode("preview"); - setPreviewMode("control"); - setSelectedPreviewTargetId(matchingTarget.id); - setPreviewResult(null); - void renderSelectedPreview(matchingTarget); - return; - } - setPreviewAgentHelpAction("open-simulator-in-preview"); - setMessage(elementSource - ? `No #Preview matched ${elementSource}. Drafting an agent-backed preview task...` - : "No element is selected. Drafting an agent prompt to create a preview for the current screen..."); - void draftPreviewAgentHelpRef.current?.("open-simulator-in-preview", { - selectedElement: element, - previewTarget: null, - previewMatch: match, - previewResult: null, - includePreviewAttachment: false, - }); - }, [inspectBridgeElement, previewMatch, previewTargets, projectRoot, renderSelectedPreview]); + }, [inspectBridgeElement, previewCapability?.selectedWindow?.tabIdentifier, projectRoot]); const sendTypedText = useCallback(async () => { const text = typedText; @@ -1983,14 +1986,14 @@ export function ChatIosSimulatorPanel({ ? `- Step 5b: If no matching preview exists, add one in ${match.suggestedSourceFile}${match.suggestedTitle ? ` named ${JSON.stringify(match.suggestedTitle)}` : ""}. Prefer a lightweight harness with bindings, env objects, no-op callbacks, fake state, and no live sync/network dependencies.` : "- Step 5b: If no matching preview exists, add one (prefer a `Previews.swift` sidecar; use a lightweight harness with bindings, env objects, no-op callbacks, fake state)."; requestedWork = [ - "- Step 1: Identify the screen that is currently open in the live iOS Simulator. Start with `ade ios-sim status --text` and `ade ios-sim snapshot --text` so you are using ADE's current simulator session, not a guessed route.", + "- Step 1: Identify the screen that is currently open in the live iOS Simulator. Start with `ade --socket ios-sim status --text` and `ade --socket ios-sim snapshot --text` so you are using ADE's current simulator session, not a guessed route.", "- Step 2: If the simulator is not running, there is no active simulator session, or ADE cannot capture a current screen/snapshot, stop and warn the user with the exact blocker. Do not guess from stale code.", - "- Step 3: Use the simulator snapshot, attached screenshot, visible labels, navigation title, inspector packets, and SwiftUI file search to find the matching screen in code.", - "- Step 4: Resolve ADE's current preview match with `ade ios-sim preview-match --source --text`, then check nearby definitions with `ade ios-sim previews --source --text`.", + "- Step 3: If the selected source is unknown, inspect the snapshot elements and run `ade --socket ios-sim select --x --y --text` on a source-backed element before editing code. If the prompt already provides a source file/line, use that directly.", + "- Step 4: Resolve and render ADE's current preview bridge with `ade --socket ios-sim preview-current --text`. If you have an explicit source, use `ade --socket ios-sim preview-current --source --line --text`.", "- Step 5a: If a matching preview already exists, use it. Do not add a duplicate preview just because the first search was imperfect.", suggestedPreviewLine, - "- Step 6: Finish by opening/rendering the chosen preview through ADE CLI with `ade ios-sim preview-render --source --index --text` so the result lands in ADE's Preview surface.", - "- Report back with the screen you identified, the file:line of the preview that was used or added, and the `ade ios-sim preview-render` result.", + "- Step 6: Finish by running `ade --socket ios-sim preview-current --text` again, or `ade --socket ios-sim preview-render --source --index --text` when you intentionally chose a specific preview.", + "- Report back with the screen you identified, the file:line of the preview that was used or added, and the `ade --socket ios-sim preview-current` or `preview-render` result.", ]; } else if (action === "add-realistic-mocks") { requestedWork = [ diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 174e3148a..11a956c1f 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -275,6 +275,7 @@ export const IPC = { iosSimulatorListPreviewTargets: "ade.iosSimulator.listPreviewTargets", iosSimulatorResolvePreviewMatch: "ade.iosSimulator.resolvePreviewMatch", iosSimulatorEnsurePreviewWorkspace: "ade.iosSimulator.ensurePreviewWorkspace", + iosSimulatorRenderCurrentPreview: "ade.iosSimulator.renderCurrentPreview", iosSimulatorRenderPreview: "ade.iosSimulator.renderPreview", iosSimulatorOpenPreviewWorkspace: "ade.iosSimulator.openPreviewWorkspace", iosSimulatorStartStream: "ade.iosSimulator.startStream", diff --git a/apps/desktop/src/shared/types/iosSimulator.ts b/apps/desktop/src/shared/types/iosSimulator.ts index 0e0dc9cf0..e031912e8 100644 --- a/apps/desktop/src/shared/types/iosSimulator.ts +++ b/apps/desktop/src/shared/types/iosSimulator.ts @@ -270,6 +270,19 @@ export type IosSimulatorRenderPreviewResult = { error: string | null; }; +export type IosSimulatorRenderCurrentPreviewArgs = IosSimulatorListPreviewsArgs & { + tabIdentifier?: string | null; + timeoutSec?: number | null; +}; + +export type IosSimulatorRenderCurrentPreviewResult = { + ok: boolean; + match: IosSimulatorPreviewMatch; + target: IosSimulatorPreviewTarget | null; + render: IosSimulatorRenderPreviewResult | null; + error: string | null; +}; + export type IosSimulatorOpenPreviewWorkspaceArgs = { projectRoot?: string | null; }; diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index 5df354b80..f6589869c 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -700,6 +700,7 @@ export type SyncRemoteCommandAction = | "chat.delete" | "chat.models" | "chat.modelCatalog" + | "chat.getChatEventHistoryPage" | "agentChat.getEventHistoryPage" | "cto.getRoster" | "cto.ensureSession" diff --git a/apps/ios/ADE.xcodeproj/project.pbxproj b/apps/ios/ADE.xcodeproj/project.pbxproj index 9eca0f8c4..715df23e5 100644 --- a/apps/ios/ADE.xcodeproj/project.pbxproj +++ b/apps/ios/ADE.xcodeproj/project.pbxproj @@ -157,6 +157,8 @@ G10000000000000000000003 /* PrListRowModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000003 /* PrListRowModifier.swift */; }; G10000000000000000000004 /* PrFiltersCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000004 /* PrFiltersCard.swift */; }; G10000000000000000000005 /* PrRowCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000005 /* PrRowCard.swift */; }; + G10000000000000000000026 /* PrsRootScreenPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000026 /* PrsRootScreenPreviews.swift */; }; + G10000000000000000000025 /* PrRowCardPreviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000025 /* PrRowCardPreviews.swift */; }; G10000000000000000000006 /* PrsRootScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000006 /* PrsRootScreen.swift */; }; G10000000000000000000007 /* PrDetailScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000007 /* PrDetailScreen.swift */; }; G10000000000000000000008 /* PrDetailOverviewTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = G20000000000000000000008 /* PrDetailOverviewTab.swift */; }; @@ -380,6 +382,8 @@ G20000000000000000000002 /* PrHelpers.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrHelpers.swift; path = ADE/Views/PRs/PrHelpers.swift; sourceTree = ""; }; G20000000000000000000003 /* PrListRowModifier.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrListRowModifier.swift; path = ADE/Views/PRs/PrListRowModifier.swift; sourceTree = ""; }; G20000000000000000000004 /* PrFiltersCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrFiltersCard.swift; path = ADE/Views/PRs/PrFiltersCard.swift; sourceTree = ""; }; + G20000000000000000000026 /* PrsRootScreenPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrsRootScreenPreviews.swift; path = ADE/Views/PRs/PrsRootScreenPreviews.swift; sourceTree = ""; }; + G20000000000000000000025 /* PrRowCardPreviews.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrRowCardPreviews.swift; path = ADE/Views/PRs/PrRowCardPreviews.swift; sourceTree = ""; }; G20000000000000000000005 /* PrRowCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrRowCard.swift; path = ADE/Views/PRs/PrRowCard.swift; sourceTree = ""; }; G20000000000000000000006 /* PrsRootScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrsRootScreen.swift; path = ADE/Views/PRs/PrsRootScreen.swift; sourceTree = ""; }; G20000000000000000000007 /* PrDetailScreen.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = PrDetailScreen.swift; path = ADE/Views/PRs/PrDetailScreen.swift; sourceTree = ""; }; @@ -548,7 +552,9 @@ G20000000000000000000003 /* PrListRowModifier.swift */, G20000000000000000000004 /* PrFiltersCard.swift */, G20000000000000000000005 /* PrRowCard.swift */, + G20000000000000000000025 /* PrRowCardPreviews.swift */, G20000000000000000000006 /* PrsRootScreen.swift */, + G20000000000000000000026 /* PrsRootScreenPreviews.swift */, G20000000000000000000007 /* PrDetailScreen.swift */, G20000000000000000000008 /* PrDetailOverviewTab.swift */, G20000000000000000000009 /* PrDetailFilesTab.swift */, @@ -1093,6 +1099,8 @@ G10000000000000000000003 /* PrListRowModifier.swift in Sources */, G10000000000000000000004 /* PrFiltersCard.swift in Sources */, G10000000000000000000005 /* PrRowCard.swift in Sources */, + G10000000000000000000025 /* PrRowCardPreviews.swift in Sources */, + G10000000000000000000026 /* PrsRootScreenPreviews.swift in Sources */, G10000000000000000000006 /* PrsRootScreen.swift in Sources */, G10000000000000000000007 /* PrDetailScreen.swift in Sources */, G10000000000000000000008 /* PrDetailOverviewTab.swift in Sources */, diff --git a/apps/ios/ADE/App/ContentView.swift b/apps/ios/ADE/App/ContentView.swift index 23ab49da7..164b4d0d9 100644 --- a/apps/ios/ADE/App/ContentView.swift +++ b/apps/ios/ADE/App/ContentView.swift @@ -36,7 +36,6 @@ private enum RootTab: Hashable, CaseIterable, Identifiable { struct ContentView: View { @EnvironmentObject private var syncService: SyncService @State private var selectedTab: RootTab = .work - @State private var rootTabBarHidden = false @AppStorage("ade.colorScheme") private var colorSchemeRaw: String = ADEColorSchemeChoice.system.rawValue private var colorSchemeChoice: ADEColorSchemeChoice { @@ -58,9 +57,6 @@ struct ContentView: View { .preferredColorScheme(colorSchemeChoice.preferredColorScheme) .sensoryFeedback(.selection, trigger: selectedTab) .environmentObject(syncService.attentionDrawer) - .onAppear { - ADEUIKitAppearance.configureTabBar() - } .sheet(isPresented: $syncService.settingsPresented) { ConnectionSettingsView(syncService: syncService) } @@ -107,18 +103,6 @@ struct ContentView: View { filesTab ctoTab } - .toolbar(.hidden, for: .tabBar) - .safeAreaInset(edge: .bottom, spacing: 0) { - if !rootTabBarHidden { - ADERootBottomTabBar( - selectedTab: $selectedTab, - workBadgeCount: syncService.runningChatSessionCount - ) - } - } - .onPreferenceChange(ADERootTabBarHiddenPreferenceKey.self) { hidden in - rootTabBarHidden = hidden - } } private var workTab: some View { @@ -163,64 +147,6 @@ struct ContentView: View { } } -private struct ADERootBottomTabBar: View { - @Binding var selectedTab: RootTab - let workBadgeCount: Int - - var body: some View { - HStack(spacing: 6) { - ForEach(RootTab.allCases) { tab in - Button { - selectedTab = tab - } label: { - VStack(spacing: 4) { - ZStack(alignment: .topTrailing) { - Image(systemName: tab.symbol) - .font(.system(size: 18, weight: .semibold)) - .frame(width: 38, height: 28) - - if tab == .work, workBadgeCount > 0 { - Text("\(min(workBadgeCount, 99))") - .font(.system(size: 10, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .padding(.horizontal, 5) - .frame(minWidth: 18, minHeight: 18) - .background(ADEColor.danger, in: Capsule()) - .offset(x: 10, y: -6) - } - } - - Text(tab.title) - .font(.caption2.weight(.semibold)) - .lineLimit(1) - } - .frame(maxWidth: .infinity) - .foregroundStyle(selectedTab == tab ? ADEColor.accentBright : ADEColor.textSecondary) - .padding(.vertical, 8) - .background( - selectedTab == tab - ? ADEColor.accent.opacity(0.14) - : Color.clear, - in: RoundedRectangle(cornerRadius: 16, style: .continuous) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(tab.title) - .accessibilityValue(selectedTab == tab ? "Selected" : "") - } - } - .padding(.horizontal, 12) - .padding(.top, 8) - .padding(.bottom, 8) - .background(ADEColor.surfaceBackground.ignoresSafeArea(edges: .bottom)) - .overlay(alignment: .top) { - Rectangle() - .fill(ADEColor.glassBorder) - .frame(height: 0.5) - } - } -} - private struct ProjectHomeView: View { @EnvironmentObject private var syncService: SyncService @@ -378,6 +304,31 @@ private struct ProjectHomeView: View { } 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: { @@ -412,7 +363,7 @@ private struct ProjectHomeView: View { switch syncService.connectionState { case .connected, .syncing: return "No projects on machine" case .connecting: return "Connecting to machine" - case .error, .disconnected: return "Connect to a machine running ADE" + case .error, .disconnected: return "No projects on machine" } } @@ -423,7 +374,7 @@ private struct ProjectHomeView: View { case .connecting: return syncService.hostName ?? "Projects appear after this iPhone connects" case .error, .disconnected: - return "Connect first before you can see projects" + return "Open a project on your machine" } } } diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index e8dc2b1e5..b0912f50c 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -1056,7 +1056,7 @@ final class DatabaseService { } } - func replacePullRequestHydration(_ payload: PullRequestRefreshPayload) throws { + func replacePullRequestHydration(_ payload: PullRequestRefreshPayload, pruneStale: Bool = true) throws { guard db != nil else { return } guard let projectId = currentProjectId() else { throw sqliteError(SyncHydrationMessaging.waitingForProjectData) @@ -1067,14 +1067,43 @@ final class DatabaseService { try exec("begin") do { - _ = try execute(""" - delete from pull_request_snapshots - where pr_id in (select id from pull_requests where project_id = ?) - """) { statement in - try bindText(projectId, to: statement, index: 1) + try exec("pragma defer_foreign_keys = on") + let incomingLaneIds = Array(Set(payload.prs.map(\.laneId))).sorted() + let availableLaneIds: Set + if incomingLaneIds.isEmpty { + availableLaneIds = [] + } else { + let placeholders = Array(repeating: "?", count: incomingLaneIds.count).joined(separator: ", ") + availableLaneIds = Set(query(""" + select id + from lanes + where project_id = ? + and id in (\(placeholders)) + """, bind: { [self] statement in + try self.bindText(projectId, to: statement, index: 1) + for (index, laneId) in incomingLaneIds.enumerated() { + try self.bindText(laneId, to: statement, index: Int32(index + 2)) + } + }) { statement in + stringValue(statement, index: 0) ?? "" + }) } + let hydratablePrs = payload.prs.filter { availableLaneIds.contains($0.laneId) } + let hydratablePrIds = Set(hydratablePrs.map(\.id)) - for pr in payload.prs { + if !hydratablePrIds.isEmpty { + let placeholders = Array(repeating: "?", count: hydratablePrIds.count).joined(separator: ", ") + _ = try execute(""" + delete from pull_request_snapshots + where pr_id in (\(placeholders)) + """) { statement in + for (index, prId) in hydratablePrIds.sorted().enumerated() { + try bindText(prId, to: statement, index: Int32(index + 1)) + } + } + } + + for pr in hydratablePrs { _ = try execute(""" insert into pull_requests( id, project_id, lane_id, repo_owner, repo_name, github_pr_number, github_url, github_node_id, @@ -1132,6 +1161,7 @@ final class DatabaseService { } for snapshot in payload.snapshots { + guard hydratablePrIds.contains(snapshot.prId) else { continue } _ = try execute(""" insert into pull_request_snapshots( pr_id, detail_json, status_json, checks_json, reviews_json, comments_json, files_json, commits_json, updated_at @@ -1158,7 +1188,9 @@ final class DatabaseService { } } - try deleteStalePullRequestRows(projectId: projectId, keeping: payload.prs.map(\.id)) + if pruneStale { + try deleteStalePullRequestRows(projectId: projectId, keeping: payload.prs.map(\.id)) + } try exec("commit") notifyDidChange() @@ -2580,6 +2612,7 @@ final class DatabaseService { private func deleteStalePullRequestRows(projectId: String, keeping prIds: [String]) throws { let childTables = [ + "pull_request_snapshots", "pull_request_ai_summaries", "pr_group_members", "pr_issue_inventory", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 0136abb84..0e70ac531 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -2036,6 +2036,9 @@ final class SyncService: ObservableObject { projects = deduplicateProjectListByRoot(sortedProjectList(database.listMobileProjects())) outboundLocalDbVersion = loadOutboundCursorVersionForActiveProject(defaultVersion: database.currentDbVersion()) normalizeActiveProjectSelection(allowSingleProjectFallback: false) + if activeProjectId != nil { + projectHomePresented = false + } pendingOperationCount = loadPendingOperations().count resetOutboundCursorStateForActiveProject() activeHostProfile = loadProfile() @@ -3264,7 +3267,7 @@ final class SyncService: ObservableObject { do { let raw = try await sendCommand(action: "prs.refresh", args: args) let payload = try decodeHydrationPayload(raw, as: PullRequestRefreshPayload.self, domainLabel: "pull request", decoder: decoder) - try database.replacePullRequestHydration(payload) + try database.replacePullRequestHydration(payload, pruneStale: prId == nil) scheduleWorkspaceSnapshotWrite() setDomainStatus([.prs], phase: .ready) } catch { diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 05f927d16..be3bc5fa6 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -362,24 +362,10 @@ enum ADEMotion { } enum ADEUIKitAppearance { + /// Intentionally empty — ADE uses the system `TabView` tab bar so iOS 26 + /// Liquid Glass stays native. Do not override `UITabBar.appearance()` here. @MainActor - static func configureTabBar() { - let appearance = UITabBarAppearance() - appearance.configureWithOpaqueBackground() - appearance.backgroundEffect = nil - appearance.backgroundColor = UIColor { traits in - traits.userInterfaceStyle == .dark ? hex(0x16141e) : hex(0xfaf8f5) - } - appearance.shadowColor = UIColor { traits in - traits.userInterfaceStyle == .dark - ? hex(0xffffff, alpha: 0.10) - : hex(0x1a1a1e, alpha: 0.10) - } - - let tabBar = UITabBar.appearance() - tabBar.standardAppearance = appearance - tabBar.scrollEdgeAppearance = appearance - } + static func configureTabBar() {} } final class ADEImageCache { @@ -1129,8 +1115,6 @@ private struct ADENavigationGlassModifier: ViewModifier { content .toolbarBackground(.clear, for: .navigationBar) .toolbarBackgroundVisibility(.visible, for: .navigationBar) - .toolbarBackground(ADEColor.surfaceBackground.opacity(0.96), for: .tabBar) - .toolbarBackgroundVisibility(.visible, for: .tabBar) } } diff --git a/apps/ios/ADE/Views/PRs/PrRowCard.swift b/apps/ios/ADE/Views/PRs/PrRowCard.swift index 927cc48f2..4b05dcb63 100644 --- a/apps/ios/ADE/Views/PRs/PrRowCard.swift +++ b/apps/ios/ADE/Views/PRs/PrRowCard.swift @@ -22,11 +22,12 @@ struct PrRowCard: View { init( item: GitHubPrListItem, + linkedPr: PullRequestListItem? = nil, transitionNamespace: Namespace.ID? = nil, isSelectedTransitionSource: Bool = false, onLink: (() -> Void)? = nil ) { - self.data = Data(item: item) + self.data = Data(item: item, linkedPr: linkedPr) self.transitionNamespace = transitionNamespace self.isSelectedTransitionSource = isSelectedTransitionSource self.onShowStack = { _, _ in } @@ -34,59 +35,45 @@ struct PrRowCard: View { } var body: some View { - HStack(alignment: .top, spacing: 12) { - PrsStatusRail(state: data.state) - .frame(maxHeight: .infinity) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "pr-status-\(data.id)" : nil, in: transitionNamespace) + let stateColors = PrRowDesktopPalette.stateColors(data.state) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Text("#\(data.prNumber)") - .font(.system(size: 11, weight: .bold, design: .monospaced)) - .foregroundStyle(PrsGlass.statusTint(data.state)) - - PrsRowStateChip(state: data.state) - - if let kindLabel = data.adeKindLabel, let tint = data.adeKindTint { - PrTagChip(label: kindLabel, color: tint) - } - - if data.isUnmapped { - PrsUnlinkedPill() - } - - if data.isExternal && !data.isUnmapped { - PrTagChip(label: "external", color: PrsGlass.externalTop) - } - - Spacer(minLength: 0) - - PrMonoText(text: prRelativeTime(data.updatedAt), color: PrsGlass.textMuted, size: 10) - .lineLimit(1) - } - - Text(data.title) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(PrsGlass.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.leading) - .adeMatchedGeometry(id: isSelectedTransitionSource ? "pr-title-\(data.id)" : nil, in: transitionNamespace) - - metaLine - - // Surface workflow warnings (queued, rebase-needed, merge-conflict, - // CI failing) on cached ADE-side PRs. Unmapped external rows already - // communicate their state via the UNLINKED pill + Link CTA — no need - // for an extra banner there. - if !data.isUnmapped, let warn = data.warnMessage { - PrWarnBanner(text: warn) - .padding(.top, 2) - } + VStack(alignment: .leading, spacing: 6) { + primaryRow(stateColors: stateColors) + if !data.visibleLabels.isEmpty { + labelsRow + } + if data.showsBranchRow { + branchRow + } + statsRow + if !data.isUnmapped, let warnMessage = data.warnMessage { + PrWarnBanner(text: warnMessage) } - .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.vertical, 2) - .prsGlassSurface(cornerRadius: 18, tint: PrsGlass.statusTint(data.state), padding: 14) + .padding(.horizontal, 14) + .padding(.vertical, 11) + .frame(maxWidth: .infinity, alignment: .leading) + .background { + if isSelectedTransitionSource { + LinearGradient( + colors: [stateColors.background, Color.white.opacity(0.02)], + startPoint: .leading, + endPoint: .trailing + ) + } else { + Color.clear + } + } + .overlay(alignment: .leading) { + Rectangle() + .fill(isSelectedTransitionSource ? stateColors.text : .clear) + .frame(width: 3) + } + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.white.opacity(0.04)) + .frame(height: 1) + } .adeMatchedTransitionSource(id: isSelectedTransitionSource ? "pr-container-\(data.id)" : nil, in: transitionNamespace) .accessibilityElement(children: .combine) .accessibilityLabel("PR #\(data.prNumber): \(data.title), state \(data.state)") @@ -103,153 +90,397 @@ struct PrRowCard: View { ) } - @ViewBuilder - private var metaLine: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 4) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9, weight: .semibold)) - .foregroundStyle(PrsGlass.textSecondary.opacity(0.8)) - PrMonoText(text: data.branchDisplayLabel, color: PrsGlass.textSecondary, size: 10) + private func primaryRow(stateColors: PrRowDesktopPalette.StateColors) -> some View { + HStack(alignment: .center, spacing: 6) { + authorAvatar(borderColor: stateColors.background) + + if data.isBot { + Text("bot") + .font(.system(size: 9, weight: .bold)) + .textCase(.uppercase) + .foregroundStyle(PrsGlass.textMuted) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(Color.white.opacity(0.06)) + ) + } + + Text("#\(data.prNumber)") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(stateColors.text) + + Text(data.title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(PrsGlass.textPrimary) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + .adeMatchedGeometry(id: isSelectedTransitionSource ? "pr-title-\(data.id)" : nil, in: transitionNamespace) + + if let ci = data.ciIndicator { + Image(systemName: ci.symbol) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(ci.color) + .accessibilityLabel(ci.title) + } + + if !data.timeAgo.isEmpty { + PrMonoText(text: data.timeAgo, color: PrsGlass.textMuted, size: 10) + .lineLimit(1) + } + + if data.commentCount > 0 { + HStack(spacing: 3) { + Image(systemName: "text.bubble") + .font(.system(size: 12)) + Text("\(data.commentCount)") + .font(.system(size: 10, weight: .regular, design: .monospaced)) + } + .foregroundStyle(PrsGlass.textMuted) + } + } + } + + private var labelsRow: some View { + HStack(spacing: 4) { + ForEach(data.visibleLabels) { label in + Text(label.name) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(prLabelTextColor(label.color)) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background( + Capsule(style: .continuous) + .fill(Color(hex: label.color)) + ) + .lineLimit(1) + } + if data.labelOverflowCount > 0 { + Text("+\(data.labelOverflowCount)") + .font(.system(size: 10)) + .foregroundStyle(PrsGlass.textMuted) + } + } + .padding(.leading, 30) + } + + private var branchRow: some View { + HStack(spacing: 4) { + if let head = data.headBranch { + PrMonoText(text: head, color: PrsGlass.textMuted, size: 10) .lineLimit(1) .truncationMode(.tail) - .layoutPriority(1) + } + if data.headBranch != nil, data.baseBranch != nil { + Text("→") + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(PrsGlass.textSecondary.opacity(0.55)) + } + if let base = data.baseBranch { + PrMonoText(text: base, color: PrsGlass.textMuted, size: 10) + .lineLimit(1) + } + } + .padding(.leading, 30) + } + + private var statsRow: some View { + HStack(spacing: 6) { + if data.showsStateBadge { + Text(titleCase(data.state)) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PrRowDesktopPalette.stateColors(data.state).text) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(PrRowDesktopPalette.stateColors(data.state).background) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(PrRowDesktopPalette.stateColors(data.state).border, lineWidth: 1) + ) + } + + if let adeKind = data.adeKindBadgeLabel, let style = PrRowDesktopPalette.adeKindStyle(data.adeKind) { + Text(adeKind) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(style.text) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(style.background) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(style.border, lineWidth: 1) + ) + } + + if data.isExternal { + PrMonoText( + text: "\(data.repoOwner)/\(data.repoName)", + color: PrsGlass.textMuted, + size: 10 + ) + .lineLimit(1) + } - if let author = data.authorLabel { - PrMonoText(text: "by \(author)", color: PrsGlass.textMuted, size: 10) + if let laneLabel = data.laneLabel { + HStack(spacing: 4) { + Circle() + .fill(data.laneTint ?? PrsGlass.textSecondary) + .frame(width: 6, height: 6) + Text(laneLabel) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(data.laneTint ?? PrsGlass.textSecondary) .lineLimit(1) - .truncationMode(.tail) } + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color.white.opacity(0.04)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } else if data.isUnmapped { + Text("unmapped") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PrsGlass.draftTop) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.10)) + ) + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.18), lineWidth: 1) + ) } - HStack(spacing: 8) { - if let checks = data.checkCounts { - HStack(spacing: 5) { - if checks.fail > 0 { - PrMonoText(text: "✗ \(checks.fail)", color: PrsGlass.closedTop, size: 10) - } - if checks.pass > 0 { - PrMonoText(text: "✓ \(checks.pass)", color: PrsGlass.openTop, size: 10) - } - if checks.pending > 0 { - PrMonoText(text: "◐ \(checks.pending)", color: PrsGlass.draftTop, size: 10) - } - } + if let review = data.reviewIndicator { + Text(review.label) + .font(.system(size: 10, weight: .medium)) + .foregroundStyle(review.color) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(review.color.opacity(0.10)) + ) + } + + if data.additions > 0 || data.deletions > 0 { + HStack(spacing: 4) { + Text("+\(data.additions)") + .foregroundStyle(PrsGlass.openTop) + Text("-\(data.deletions)") + .foregroundStyle(PrsGlass.closedTop) } + .font(.system(size: 10, weight: .regular, design: .monospaced)) + } - if let approvals = data.approvals { - PrMonoText( - text: "✓ \(approvals.have)/\(approvals.need)", - color: approvals.have >= approvals.need ? PrsGlass.openTop : PrsGlass.textSecondary, - size: 10 + if data.cleanupRequired { + Text("cleanup") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PrsGlass.draftTop) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(PrsGlass.draftTop.opacity(0.10)) ) - } + .overlay( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(PrsGlass.draftTop.opacity(0.18), lineWidth: 1) + ) + } - Spacer(minLength: 0) - - if data.isUnmapped, let onLink { - Button(action: onLink) { - HStack(spacing: 4) { - Image(systemName: "link") - .font(.system(size: 9, weight: .bold)) - Text("Link") - .font(.system(size: 10, weight: .semibold)) - } - .foregroundStyle(PrsGlass.accentTop) - .padding(.horizontal, 8) - .padding(.vertical, 3) + if data.adeKind == "queue", let groupId = data.stackGroupId { + Button { + onShowStack(groupId, data.stackGroupName) + } label: { + Text("open queue") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(PrsGlass.externalTop) + .padding(.horizontal, 7) + .padding(.vertical, 2) .background( - Capsule(style: .continuous) - .fill(PrsGlass.accentTop.opacity(0.12)) + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(PrsGlass.externalTop.opacity(0.10)) ) .overlay( - Capsule(style: .continuous) - .stroke(PrsGlass.accentTop.opacity(0.45), lineWidth: 0.6) + RoundedRectangle(cornerRadius: 5, style: .continuous) + .stroke(PrsGlass.externalTop.opacity(0.18), lineWidth: 1) ) + } + .buttonStyle(.plain) + } else if let groupId = data.stackGroupId, let groupCount = data.stackGroupCount, groupCount > 0 { + Button { + onShowStack(groupId, data.stackGroupName) + } label: { + HStack(spacing: 3) { + Image(systemName: "list.number") + .font(.system(size: 9, weight: .bold)) + Text("\(groupCount)") + .font(.system(size: 10, weight: .semibold, design: .monospaced)) + } + .foregroundStyle(PrsGlass.textSecondary) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background { + Capsule(style: .continuous) + .fill(Color.white.opacity(0.06)) + } + .overlay { + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.10), lineWidth: 0.5) } - .buttonStyle(.plain) - .accessibilityLabel("Link pull request to a lane") } + .buttonStyle(.plain) + .accessibilityLabel("Open stack of \(groupCount) pull requests") + } - if let groupId = data.stackGroupId, let groupCount = data.stackGroupCount, groupCount > 0 { - Button { - onShowStack(groupId, data.stackGroupName) - } label: { - HStack(spacing: 3) { - Image(systemName: "list.number") - .font(.system(size: 9, weight: .bold)) - Text("\(groupCount)") - .font(.system(size: 10, weight: .semibold, design: .monospaced)) - } - .foregroundStyle(PrsGlass.textSecondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background { - Capsule(style: .continuous) - .fill(Color.white.opacity(0.06)) - } - .overlay { - Capsule(style: .continuous) - .stroke(Color.white.opacity(0.10), lineWidth: 0.5) - } - } - .buttonStyle(.plain) + Spacer(minLength: 0) + + if data.isUnmapped, let onLink { + Button(action: onLink) { + Image(systemName: "link") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(PrsGlass.textMuted) } + .buttonStyle(.plain) + .accessibilityLabel("Link pull request to a lane") } } + .padding(.leading, 30) } -} -/// Small capsule that mirrors the eyebrow state label on the pencil PR rows -/// (OPEN, DRAFT, MERGED, CLOSED, QUEUED…) using the PrsGlass status tints. -private struct PrsRowStateChip: View { - let state: String - - var body: some View { - let tint = PrsGlass.statusTint(state) - Text(displayLabel.uppercased()) - .font(.system(size: 9, weight: .bold)) - .tracking(0.9) - .foregroundStyle(tint) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background { - Capsule(style: .continuous) - .fill(tint.opacity(0.18)) + @ViewBuilder + private func authorAvatar(borderColor: Color) -> some View { + if let author = data.authorLogin { + AsyncImage(url: URL(string: "https://avatars.githubusercontent.com/\(author)?size=64")) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + default: + Circle() + .fill(Color.white.opacity(0.05)) + } } + .frame(width: 22, height: 22) + .clipShape(Circle()) .overlay { - Capsule(style: .continuous) - .stroke(tint.opacity(0.45), lineWidth: 0.75) + Circle() + .stroke(borderColor, lineWidth: 1.5) } + } else { + Circle() + .fill(Color.white.opacity(0.05)) + .overlay { + Circle() + .stroke(Color.white.opacity(0.08), lineWidth: 1) + } + .frame(width: 22, height: 22) + } + } +} + +private enum PrRowDesktopPalette { + struct StateColors { + let background: Color + let border: Color + let text: Color + } + + struct AdeKindStyle { + let text: Color + let background: Color + let border: Color } - private var displayLabel: String { + static func stateColors(_ state: String) -> StateColors { switch state { - case "open": return "Open" - case "draft": return "Draft" - case "merged": return "Merged" - case "closed": return "Closed" - case "external": return "External" - default: return state + case "open": + return StateColors( + background: Color(red: 0x3B / 255, green: 0x82 / 255, blue: 0xF6 / 255, opacity: 0.10), + border: Color(red: 0x3B / 255, green: 0x82 / 255, blue: 0xF6 / 255, opacity: 0.20), + text: Color(red: 0x60 / 255, green: 0xA5 / 255, blue: 0xFA / 255) + ) + case "draft": + return StateColors( + background: Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.10), + border: Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.20), + text: Color(red: 0xFB / 255, green: 0xBF / 255, blue: 0x24 / 255) + ) + case "merged": + return StateColors( + background: Color(red: 0x22 / 255, green: 0xC5 / 255, blue: 0x5E / 255, opacity: 0.10), + border: Color(red: 0x22 / 255, green: 0xC5 / 255, blue: 0x5E / 255, opacity: 0.20), + text: Color(red: 0x4A / 255, green: 0xDE / 255, blue: 0x80 / 255) + ) + default: + return StateColors( + background: Color(red: 0xA1 / 255, green: 0xA1 / 255, blue: 0xAA / 255, opacity: 0.08), + border: Color(red: 0xA1 / 255, green: 0xA1 / 255, blue: 0xAA / 255, opacity: 0.15), + text: Color(red: 0xA1 / 255, green: 0xA1 / 255, blue: 0xAA / 255) + ) + } + } + + static func adeKindStyle(_ adeKind: String?) -> AdeKindStyle? { + switch adeKind { + case "integration": + return AdeKindStyle( + text: Color(red: 0xFB / 255, green: 0xBF / 255, blue: 0x24 / 255), + background: Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.14), + border: Color(red: 0xF5 / 255, green: 0x9E / 255, blue: 0x0B / 255, opacity: 0.22) + ) + case "queue": + return AdeKindStyle( + text: Color(red: 0x60 / 255, green: 0xA5 / 255, blue: 0xFA / 255), + background: Color(red: 0x3B / 255, green: 0x82 / 255, blue: 0xF6 / 255, opacity: 0.14), + border: Color(red: 0x3B / 255, green: 0x82 / 255, blue: 0xF6 / 255, opacity: 0.22) + ) + default: + return nil } } } -/// Small outline-only "UNLINKED" pill used on external GitHub PR rows, in place -/// of the loud filled amber chip. Pairs with the row-level Link CTA on the -/// right and (optionally) an EXTERNAL section header above. -struct PrsUnlinkedPill: View { - var body: some View { - Text("UNLINKED") - .font(.system(size: 9, weight: .bold)) - .tracking(0.9) - .foregroundStyle(PrsGlass.textMuted) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .overlay { - Capsule(style: .continuous) - .stroke(PrsGlass.textMuted.opacity(0.45), lineWidth: 0.6) - } +private func prLabelTextColor(_ hexColor: String) -> Color { + let hex = hexColor.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + guard hex.count >= 6, + let value = Int(hex.prefix(6), radix: 16) else { + return PrsGlass.textPrimary + } + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + let luminance = (0.299 * r) + (0.587 * g) + (0.114 * b) + return luminance > 0.5 + ? Color(red: 0x1A / 255, green: 0x1A / 255, blue: 0x2E / 255) + : PrsGlass.textPrimary +} + +private extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#")) + let value = Int(hex.prefix(6), radix: 16) ?? 0 + self.init( + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255 + ) } } @@ -259,40 +490,91 @@ extension PrRowCard { let prNumber: Int let title: String let state: String + let adeKind: String? + let createdAt: String let updatedAt: String - let branchLabel: String + let headBranch: String? let baseBranch: String? - let author: String? - let adeKindLabel: String? - let adeKindTint: Color? + let authorLogin: String? + let isBot: Bool + let labels: [PrLabel] + let commentCount: Int + let repoOwner: String + let repoName: String let isExternal: Bool let isUnmapped: Bool - let checkCounts: CheckCounts? - let approvals: Approvals? + let laneLabel: String? + let laneTint: Color? + let checksStatus: String? + let reviewStatus: String? + let additions: Int + let deletions: Int + let cleanupRequired: Bool let warnMessage: String? let stackGroupId: String? let stackGroupName: String? let stackGroupCount: Int? - var branchDisplayLabel: String { - guard let baseBranch, !baseBranch.isEmpty else { return branchLabel } - return "\(branchLabel) → \(baseBranch)" + var visibleLabels: [PrLabel] { + Array(labels.prefix(4)) + } + + var labelOverflowCount: Int { + max(0, labels.count - 4) + } + + var timeAgo: String { + prRelativeTime(updatedAt) + } + + var showsBranchRow: Bool { + headBranch != nil && baseBranch != nil } - var authorLabel: String? { - guard let author, !author.isEmpty else { return nil } - return author.hasPrefix("@") ? author : "@\(author)" + var showsStateBadge: Bool { + state != "open" && state != "draft" } - struct CheckCounts { - let pass: Int - let fail: Int - let pending: Int + var adeKindBadgeLabel: String? { + guard let adeKind, adeKind != "single" else { return nil } + return adeKind } - struct Approvals { - let have: Int - let need: Int + struct CIIndicator { + let symbol: String + let color: Color + let title: String + } + + struct ReviewIndicator { + let label: String + let color: Color + } + + var ciIndicator: CIIndicator? { + switch checksStatus { + case "passing": + return CIIndicator(symbol: "checkmark.circle.fill", color: PrsGlass.openTop, title: "CI passing") + case "failing": + return CIIndicator(symbol: "xmark.circle.fill", color: PrsGlass.closedTop, title: "CI failing") + case "pending": + return CIIndicator(symbol: "clock.fill", color: PrsGlass.draftTop, title: "CI pending") + default: + return nil + } + } + + var reviewIndicator: ReviewIndicator? { + switch reviewStatus { + case "approved": + return ReviewIndicator(label: "Approved", color: PrsGlass.openTop) + case "changes_requested": + return ReviewIndicator(label: "Changes", color: PrsGlass.closedTop) + case "requested": + return ReviewIndicator(label: "Review required", color: PrsGlass.draftTop) + default: + return nil + } } init(pr: PullRequestListItem) { @@ -300,16 +582,26 @@ extension PrRowCard { self.prNumber = pr.githubPrNumber self.title = pr.title self.state = pr.state + self.adeKind = pr.adeKind + self.createdAt = pr.createdAt self.updatedAt = pr.updatedAt - self.branchLabel = pr.headBranch + self.headBranch = pr.headBranch self.baseBranch = pr.baseBranch - self.author = nil - self.adeKindLabel = prAdeKindLabel(pr.adeKind) - self.adeKindTint = pr.adeKind != nil ? ADEColor.tintPRs : nil + self.authorLogin = nil + self.isBot = false + self.labels = [] + self.commentCount = 0 + self.repoOwner = pr.repoOwner + self.repoName = pr.repoName self.isExternal = false self.isUnmapped = false - self.checkCounts = Self.checkCounts(from: pr.checksStatus) - self.approvals = Self.approvals(from: pr.reviewStatus) + self.laneLabel = pr.laneName ?? pr.laneId + self.laneTint = ADEColor.tintPRs + self.checksStatus = pr.checksStatus == "none" ? nil : pr.checksStatus + self.reviewStatus = pr.reviewStatus == "none" ? nil : pr.reviewStatus + self.additions = pr.additions + self.deletions = pr.deletions + self.cleanupRequired = pr.cleanupState == "required" self.warnMessage = Self.warnMessage( workflowDisplayState: pr.workflowDisplayState, checksStatus: pr.checksStatus, @@ -317,10 +609,10 @@ extension PrRowCard { ) self.stackGroupId = pr.linkedGroupId self.stackGroupName = pr.linkedGroupName - self.stackGroupCount = pr.linkedGroupCount + self.stackGroupCount = pr.linkedGroupCount > 0 ? pr.linkedGroupCount : nil } - init(item: GitHubPrListItem) { + init(item: GitHubPrListItem, linkedPr: PullRequestListItem?) { let unmapped = item.scope != "external" && item.linkedPrId == nil && item.linkedLaneId == nil @@ -329,21 +621,31 @@ extension PrRowCard { self.prNumber = item.githubPrNumber self.title = item.title self.state = item.isDraft ? "draft" : item.state + self.adeKind = item.adeKind + self.createdAt = item.createdAt self.updatedAt = item.updatedAt - self.branchLabel = item.headBranch ?? "\(item.repoOwner)/\(item.repoName)" + self.headBranch = item.headBranch self.baseBranch = item.baseBranch - self.author = item.author - self.adeKindLabel = prAdeKindLabel(item.adeKind) - self.adeKindTint = Self.adeKindTint(for: item) + self.authorLogin = item.author + self.isBot = item.isBot + self.labels = item.labels + self.commentCount = item.commentCount + self.repoOwner = item.repoOwner + self.repoName = item.repoName self.isExternal = item.scope == "external" self.isUnmapped = unmapped - self.checkCounts = nil - self.approvals = nil + self.laneLabel = item.linkedLaneName ?? item.linkedLaneId ?? linkedPr?.laneName ?? linkedPr?.laneId + self.laneTint = ADEColor.tintPRs + self.checksStatus = linkedPr?.checksStatus == "none" ? nil : linkedPr?.checksStatus + self.reviewStatus = linkedPr?.reviewStatus == "none" ? nil : linkedPr?.reviewStatus + self.additions = linkedPr?.additions ?? 0 + self.deletions = linkedPr?.deletions ?? 0 + self.cleanupRequired = item.cleanupState == "required" self.warnMessage = unmapped - ? "Unmapped: review details before linking a lane." + ? nil : Self.warnMessage( workflowDisplayState: item.workflowDisplayState, - checksStatus: nil, + checksStatus: linkedPr?.checksStatus, baseBranch: item.baseBranch ) self.stackGroupId = item.linkedGroupId @@ -351,41 +653,6 @@ extension PrRowCard { self.stackGroupCount = nil } - private static func checkCounts(from status: String) -> CheckCounts? { - switch status { - case "passing": - return CheckCounts(pass: 1, fail: 0, pending: 0) - case "failing": - return CheckCounts(pass: 0, fail: 1, pending: 0) - case "pending": - return CheckCounts(pass: 0, fail: 0, pending: 1) - default: - return nil - } - } - - private static func approvals(from reviewStatus: String) -> Approvals? { - switch reviewStatus { - case "approved": - return Approvals(have: 1, need: 1) - case "changes_requested": - return Approvals(have: 0, need: 1) - case "requested", "pending": - return Approvals(have: 0, need: 1) - default: - return nil - } - } - - private static func adeKindTint(for item: GitHubPrListItem) -> Color? { - guard let adeKind = item.adeKind, !adeKind.isEmpty else { return nil } - switch adeKind { - case "integration": return ADEColor.warning - case "queue": return ADEColor.accent - default: return ADEColor.tintPRs - } - } - private static func warnMessage( workflowDisplayState: String?, checksStatus: String?, diff --git a/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift b/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift new file mode 100644 index 000000000..a4fff1756 --- /dev/null +++ b/apps/ios/ADE/Views/PRs/PrRowCardPreviews.swift @@ -0,0 +1,172 @@ +#if DEBUG +import SwiftUI + +@MainActor +private enum PrRowCardPreviewData { + static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func iso(minutesAgo: Int) -> String { + iso.string(from: Date().addingTimeInterval(-Double(minutesAgo * 60))) + } + + static let linkedPr559 = PullRequestListItem( + id: "pr-559", + laneId: "lane-mobile-cleanup", + laneName: "mobile app cleanup", + projectId: "proj-ade", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 559, + githubUrl: "https://github.com/arul28/ADE/pull/559", + title: "Mobile App Cleanup", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-app-cleanup-b1ae5c6b", + checksStatus: "passing", + reviewStatus: "approved", + additions: 412, + deletions: 76, + lastSyncedAt: nil, + createdAt: iso(minutesAgo: 180), + updatedAt: iso(minutesAgo: 180), + adeKind: "single", + linkedGroupId: nil, + linkedGroupType: nil, + linkedGroupName: nil, + linkedGroupPosition: nil, + linkedGroupCount: 0, + workflowDisplayState: nil, + cleanupState: nil + ) + + static let github559 = GitHubPrListItem( + id: "gh-559", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 559, + githubUrl: "https://github.com/arul28/ADE/pull/559", + title: "Mobile App Cleanup", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "ade/mobile-app-cleanup-b1ae5c6b", + author: "arul28", + createdAt: iso(minutesAgo: 180), + updatedAt: iso(minutesAgo: 180), + linkedPrId: linkedPr559.id, + linkedGroupId: nil, + linkedLaneId: linkedPr559.laneId, + linkedLaneName: linkedPr559.laneName, + adeKind: "single", + workflowDisplayState: nil, + cleanupState: nil, + labels: [PrLabel(name: "mobile app cleanup", color: "8B5CF6")], + isBot: false, + commentCount: 0 + ) + + static let github346 = GitHubPrListItem( + id: "PR_kwDORNN9Fc7eiokw", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 346, + githubUrl: "https://github.com/arul28/ADE/pull/346", + title: "Bump eslint-plugin-react-hooks from 7.0.1 to 7.1.1 in /apps/desktop", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "dependabot/npm_and_yarn/apps/desktop/eslint-plugin-react-hooks-7.1.1", + author: "dependabot", + createdAt: iso(minutesAgo: 60 * 24 * 14), + updatedAt: iso(minutesAgo: 60 * 24 * 14), + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [ + PrLabel(name: "dependencies", color: "0366d6"), + PrLabel(name: "javascript", color: "168700"), + ], + isBot: true, + commentCount: 0 + ) + + static let github425 = GitHubPrListItem( + id: "gh-425", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 425, + githubUrl: "https://github.com/arul28/ADE/pull/425", + title: "Bump @typescript-eslint/eslint-plugin from 8.46.2 to 8.48.0 in /apps/desktop", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "dependabot/npm_and_yarn/apps/desktop/typescript-eslint-eslint-plugin-8.48.0", + author: "dependabot", + createdAt: iso(minutesAgo: 60 * 24 * 7), + updatedAt: iso(minutesAgo: 60 * 24 * 7), + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [ + PrLabel(name: "dependencies", color: "0366d6"), + PrLabel(name: "javascript", color: "168700"), + ], + isBot: true, + commentCount: 0 + ) + + static var githubListRows: some View { + ScrollView { + VStack(spacing: 0) { + PrRowCard(item: github559, linkedPr: linkedPr559) + PrRowCard(item: github346, onLink: {}) + PrRowCard(item: github425, onLink: {}) + } + .padding(.horizontal, 16) + } + .background(PrsLiquidBackdrop()) + } +} + +#Preview("GitHub PR list rows") { + PrRowCardPreviewData.githubListRows +} + +#Preview("GitHub PR list rows · light") { + PrRowCardPreviewData.githubListRows + .preferredColorScheme(.light) +} + +#Preview("Linked PR #559") { + PrRowCard( + item: PrRowCardPreviewData.github559, + linkedPr: PrRowCardPreviewData.linkedPr559 + ) + .padding(.horizontal, 16) + .background(PrsLiquidBackdrop()) +} + +#Preview("Unmapped bot PR #346") { + PrRowCard( + item: PrRowCardPreviewData.github346, + onLink: {} + ) + .padding(.horizontal, 16) + .background(PrsLiquidBackdrop()) +} +#endif diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift index 607788bc3..c6d852d9a 100644 --- a/apps/ios/ADE/Views/PRs/PrsRootScreen.swift +++ b/apps/ios/ADE/Views/PRs/PrsRootScreen.swift @@ -910,6 +910,11 @@ struct PRsTabView: View { } } + private func linkedPullRequest(for item: GitHubPrListItem) -> PullRequestListItem? { + guard let linkedPrId = item.linkedPrId else { return nil } + return prs.first { $0.id == linkedPrId } + } + @ViewBuilder private func githubRowNavigation(for item: GitHubPrListItem) -> some View { if let prId = item.linkedPrId { @@ -918,6 +923,7 @@ struct PRsTabView: View { } label: { PrRowCard( item: item, + linkedPr: linkedPullRequest(for: item), transitionNamespace: ADEMotion.allowsMatchedGeometry(reduceMotion: reduceMotion) ? prTransitionNamespace : nil, isSelectedTransitionSource: selectedPrTransitionId == prId ) @@ -945,6 +951,7 @@ struct PRsTabView: View { } label: { PrRowCard( item: item, + linkedPr: linkedPullRequest(for: item), onLink: canLinkGitHubPullRequests ? { laneLinkRequest = PrGitHubLaneLinkRequest(item: item) } : nil diff --git a/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift b/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift new file mode 100644 index 000000000..00ee96ec4 --- /dev/null +++ b/apps/ios/ADE/Views/PRs/PrsRootScreenPreviews.swift @@ -0,0 +1,287 @@ +#if DEBUG +import SwiftUI + +@MainActor +private enum PrsRootPreviewData { + static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + static func iso(minutesAgo: Int) -> String { + iso.string(from: Date().addingTimeInterval(-Double(minutesAgo * 60))) + } + + static let linkedPr559 = PullRequestListItem( + id: "pr-559", + laneId: "lane-mobile-cleanup", + laneName: "mobile app cleanup", + projectId: "proj-ade", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 559, + githubUrl: "https://github.com/arul28/ADE/pull/559", + title: "Mobile App Cleanup", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-app-cleanup-b1ae5c6b", + checksStatus: "passing", + reviewStatus: "approved", + additions: 412, + deletions: 76, + lastSyncedAt: nil, + createdAt: iso(minutesAgo: 180), + updatedAt: iso(minutesAgo: 180), + adeKind: "single", + linkedGroupId: nil, + linkedGroupType: nil, + linkedGroupName: nil, + linkedGroupPosition: nil, + linkedGroupCount: 0, + workflowDisplayState: nil, + cleanupState: nil + ) + + static let github559 = GitHubPrListItem( + id: "gh-559", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 559, + githubUrl: "https://github.com/arul28/ADE/pull/559", + title: "Mobile App Cleanup", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "ade/mobile-app-cleanup-b1ae5c6b", + author: "arul28", + createdAt: iso(minutesAgo: 180), + updatedAt: iso(minutesAgo: 180), + linkedPrId: linkedPr559.id, + linkedGroupId: nil, + linkedLaneId: linkedPr559.laneId, + linkedLaneName: linkedPr559.laneName, + adeKind: "single", + workflowDisplayState: nil, + cleanupState: nil, + labels: [PrLabel(name: "mobile app cleanup", color: "8B5CF6")], + isBot: false, + commentCount: 0 + ) + + static let github346 = GitHubPrListItem( + id: "PR_kwDORNN9Fc7eiokw", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 346, + githubUrl: "https://github.com/arul28/ADE/pull/346", + title: "Bump eslint-plugin-react-hooks from 7.0.1 to 7.1.1 in /apps/desktop", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "dependabot/npm_and_yarn/apps/desktop/eslint-plugin-react-hooks-7.1.1", + author: "dependabot", + createdAt: iso(minutesAgo: 60 * 24 * 14), + updatedAt: iso(minutesAgo: 60 * 24 * 14), + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [ + PrLabel(name: "dependencies", color: "0366d6"), + PrLabel(name: "javascript", color: "168700"), + ], + isBot: true, + commentCount: 0 + ) + + static let github425 = GitHubPrListItem( + id: "gh-425", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 425, + githubUrl: "https://github.com/arul28/ADE/pull/425", + title: "Bump @typescript-eslint/eslint-plugin from 8.46.2 to 8.48.0 in /apps/desktop", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "dependabot/npm_and_yarn/apps/desktop/typescript-eslint-eslint-plugin-8.48.0", + author: "dependabot", + createdAt: iso(minutesAgo: 60 * 24 * 7), + updatedAt: iso(minutesAgo: 60 * 24 * 7), + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [ + PrLabel(name: "dependencies", color: "0366d6"), + PrLabel(name: "javascript", color: "168700"), + ], + isBot: true, + commentCount: 0 + ) + + static let github344 = GitHubPrListItem( + id: "gh-344", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 344, + githubUrl: "https://github.com/arul28/ADE/pull/344", + title: "Bump @types/node from 22.15.3 to 22.15.30 in /apps/desktop", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "dependabot/npm_and_yarn/apps/desktop/types/node-22.15.30", + author: "dependabot", + createdAt: iso(minutesAgo: 60 * 24 * 14), + updatedAt: iso(minutesAgo: 60 * 24 * 14), + linkedPrId: nil, + linkedGroupId: nil, + linkedLaneId: nil, + linkedLaneName: nil, + adeKind: nil, + workflowDisplayState: nil, + cleanupState: nil, + labels: [ + PrLabel(name: "dependencies", color: "0366d6"), + PrLabel(name: "javascript", color: "168700"), + ], + isBot: true, + commentCount: 0 + ) + + static let categoryCounts = PrGitHubCategoryCounts(open: 21, merged: 0, closed: 0) +} + +/// Lightweight harness for the PRs root GitHub surface shown in the live simulator. +/// Uses the same chrome + row components as `PRsTabView` without sync/network. +private struct PrsGitHubRootPreviewScreen: View { + @State private var searchText = "" + @State private var rootSurface: PrRootSurface = .github + @State private var githubCategory: PrGitHubCategory = .open + + var body: some View { + NavigationStack { + List { + PrsGlassSearchPill( + text: $searchText, + placeholder: "Search PRs, branches, authors" + ) + .listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 8, trailing: 16)) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) + + PrsSurfaceToggle( + selection: $rootSurface, + repoPrCount: 21, + workflowCount: 1 + ) + .padding(.top, 2) + .prListRow() + + PrGitHubCategoryTabs( + selection: $githubCategory, + counts: PrsRootPreviewData.categoryCounts + ) + .prListRow() + + Section("arul28/ADE") { + PrRowCard( + item: PrsRootPreviewData.github559, + linkedPr: PrsRootPreviewData.linkedPr559 + ) + .prListRow() + + PrRowCard( + item: PrsRootPreviewData.github346, + onLink: {} + ) + .prListRow() + + PrRowCard( + item: PrsRootPreviewData.github425, + onLink: {} + ) + .prListRow() + + PrRowCard( + item: PrsRootPreviewData.github344, + onLink: {} + ) + .prListRow() + } + } + .listStyle(.plain) + .listRowSpacing(10) + .scrollContentBackground(.hidden) + .adeScreenBackground() + .adeNavigationGlass() + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { + previewTopBar + } + } + } + + private var previewTopBar: some View { + HStack(alignment: .center, spacing: 12) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("PRs") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .tracking(-0.6) + .foregroundStyle(PrsGlass.textPrimary) + Text("21") + .font(.system(size: 11, weight: .bold, design: .monospaced)) + .foregroundStyle(PrsGlass.textMuted) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Capsule(style: .continuous).fill(Color.white.opacity(0.06))) + .overlay(Capsule(style: .continuous).stroke(Color.white.opacity(0.10), lineWidth: 0.6)) + } + Spacer(minLength: 0) + HStack(spacing: 8) { + PrsGlassDisc(tint: PrsGlass.textSecondary, isAlive: false) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(PrsGlass.textSecondary) + } + PrsGlassDisc(tint: PrsGlass.textSecondary, isAlive: false) { + Image(systemName: "arrow.clockwise") + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(PrsGlass.textSecondary) + } + PrsAccentCapsule(isEnabled: true) { + Image(systemName: "plus") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(.white) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 10) + .background(PrsLiquidBackdrop().opacity(0.001)) + } +} + +#Preview("PRs · GitHub list") { + PrsGitHubRootPreviewScreen() +} + +#Preview("PRs · GitHub list · light") { + PrsGitHubRootPreviewScreen() + .preferredColorScheme(.light) +} +#endif diff --git a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift index 8bee51679..69f6847bb 100644 --- a/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift +++ b/apps/ios/ADE/Views/Settings/ConnectionSettingsView.swift @@ -43,9 +43,6 @@ struct ConnectionSettingsView: View { ) .padding(.horizontal, 16) - SettingsTailscaleHelpSection() - .padding(.horizontal, 16) - SettingsNotificationsSection( onPreferencesChanged: { prefs in syncService.uploadNotificationPrefs(prefs) @@ -275,39 +272,6 @@ private final class SettingsConnectionPresentationModel: ObservableObject { } } -private struct SettingsTailscaleHelpSection: View { - var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - Image(systemName: "network") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(ADEColor.purpleAccent) - .frame(width: 28, height: 28) - .background(ADEColor.purpleAccent.opacity(0.14), in: RoundedRectangle(cornerRadius: 9, style: .continuous)) - VStack(alignment: .leading, spacing: 2) { - Text("Away from home") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(ADEColor.textPrimary) - Text("Install Tailscale on this iPhone and your ADE machine. Pair once on local Wi-Fi or enter the machine's Tailscale address, then reconnect from the saved machine when you are away.") - .font(.caption) - .foregroundStyle(ADEColor.textSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(14) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(Color.white.opacity(0.045)) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(Color.white.opacity(0.08), lineWidth: 0.8) - ) - } -} - private struct SettingsAuroraBackground: View { var body: some View { ZStack { diff --git a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift index 0917906dc..23fb417fa 100644 --- a/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift +++ b/apps/ios/ADE/Views/Settings/SettingsConnectionHeader.swift @@ -39,6 +39,7 @@ struct SettingsConnectionHeader: View { onDisconnect: onDisconnect, onReconnect: onReconnect ) + .layoutPriority(1) } if health.transport.isConnected { @@ -52,7 +53,7 @@ struct SettingsConnectionHeader: View { .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) } else { - Text("Pair a machine to start syncing lanes, work, and files.") + Text("Pair once on Wi‑Fi to remotely connect later.") .font(.subheadline) .foregroundStyle(ADEColor.textSecondary) .fixedSize(horizontal: false, vertical: true) @@ -210,33 +211,26 @@ private struct SettingsConnectionQuickAction: View { .accessibilityLabel("Disconnect from machine") case .connecting: - HStack(spacing: 8) { + // Status copy lives in the header's leading column — keep the trailing + // control compact so it never steals width from the title stack. + Button { + onDisconnect() + } label: { HStack(spacing: 6) { ProgressView().controlSize(.mini) - Text("Connecting") - .font(.caption.weight(.medium)) - .foregroundStyle(ADEColor.textSecondary) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background(ADEColor.textSecondary.opacity(0.1), in: Capsule()) - .glassEffect() - - // Always offer a way out of a connect attempt — `disconnect()` cancels - // the in-flight attempt so the user is never stuck watching a spinner. - Button { - onDisconnect() - } label: { Text("Cancel") .font(.caption.weight(.semibold)) .foregroundStyle(ADEColor.textSecondary) - .padding(.horizontal, 10) - .padding(.vertical, 7) + .lineLimit(1) } - .buttonStyle(.plain) - .background(ADEColor.textSecondary.opacity(0.1), in: Capsule()) - .accessibilityLabel("Cancel connecting") + .padding(.horizontal, 10) + .padding(.vertical, 7) } + .buttonStyle(.plain) + .background(ADEColor.textSecondary.opacity(0.1), in: Capsule()) + .glassEffect() + .fixedSize(horizontal: true, vertical: false) + .accessibilityLabel("Cancel connecting") case .error, .disconnected: if canReconnectToSavedHost { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index cbaab5736..9c30ef3cc 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -92,6 +92,30 @@ func workChatErrorIndicatesActiveTurn(_ error: Error) -> Bool { || message.contains("already active") } +func workTranscriptEntryIdentity(_ entry: AgentChatTranscriptEntry) -> String { + [ + entry.timestamp, + entry.role, + entry.turnId ?? "", + entry.text + ].joined(separator: "\u{1F}") +} + +func mergeWorkTranscriptEntries( + older: [AgentChatTranscriptEntry], + newer: [AgentChatTranscriptEntry] +) -> [AgentChatTranscriptEntry] { + var seen = Set() + var result: [AgentChatTranscriptEntry] = [] + result.reserveCapacity(older.count + newer.count) + for entry in older + newer { + if seen.insert(workTranscriptEntryIdentity(entry)).inserted { + result.append(entry) + } + } + return result +} + private func workChatProviderFamilyFromToolType(_ toolType: String?) -> String? { let raw = toolType?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? "" guard !raw.isEmpty else { return nil } diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index e64ddfa7e..72b9f0811 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3224,7 +3224,7 @@ final class ADETests: XCTestCase { database.close() } - func testDatabaseRejectsUnknownIncomingSyncTable() throws { + func testDatabaseIgnoresUnknownIncomingSyncTable() throws { let database = makeDatabase(baseURL: makeTemporaryDirectory()) XCTAssertNil(database.initializationError) @@ -3232,9 +3232,9 @@ final class ADETests: XCTestCase { let siteId = "b00e9b92c864a27958669c1595fcb2c3" let change = CrsqlChangeRow(table: "missing_future_table", pk: .string("row-1"), cid: "name", val: .string("future"), colVersion: 1, dbVersion: 2, siteId: siteId, cl: 1, seq: 0) - XCTAssertThrowsError(try database.applyChanges([change])) { error in - XCTAssertTrue((error as NSError).localizedDescription.contains("missing_future_table")) - } + let result = try database.applyChanges([change]) + XCTAssertEqual(result.appliedCount, 0) + XCTAssertTrue(result.touchedTables.isEmpty) XCTAssertEqual(database.currentDbVersion(), initialVersion) database.close() } @@ -4664,6 +4664,175 @@ final class ADETests: XCTestCase { database.close() } + func testDatabaseReplacePullRequestHydrationSkipsPrsUntilLaneRowsArrive() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try database.executeSqlForTesting(""" + insert into projects ( + id, root_path, display_name, default_base_ref, created_at, last_opened_at + ) values ( + 'project-1', '/tmp/project', 'ADE', 'main', '2026-03-17T00:00:00.000Z', '2026-03-17T00:00:00.000Z' + ); + """) + + let payload = PullRequestRefreshPayload( + refreshedCount: 1, + prs: [ + PrSummary( + id: "pr-before-lane", + laneId: "lane-primary", + projectId: "project-1", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: 43, + githubUrl: "https://github.com/arul/ade/pull/43", + githubNodeId: nil, + title: "Arrives before lane", + state: "open", + baseBranch: "main", + headBranch: "ade/mobile-pr-before-lane", + checksStatus: "pending", + reviewStatus: "requested", + additions: 5, + deletions: 1, + lastSyncedAt: "2026-03-17T00:10:00.000Z", + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: "2026-03-17T00:10:00.000Z" + ), + ], + snapshots: [ + PullRequestSnapshotHydration( + prId: "pr-before-lane", + detail: nil, + status: PrStatus( + prId: "pr-before-lane", + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + isMergeable: true, + mergeConflicts: false, + behindBaseBy: 0 + ), + checks: [], + reviews: [], + comments: [], + files: [], + updatedAt: "2026-03-17T00:10:00.000Z" + ), + ] + ) + + XCTAssertNoThrow(try database.replacePullRequestHydration(payload)) + XCTAssertTrue(database.fetchPullRequests().isEmpty) + XCTAssertNil(database.fetchPullRequestSnapshot(prId: "pr-before-lane")) + + try database.executeSqlForTesting(""" + insert into lanes ( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, folder, + status, created_at, archived_at + ) values ( + 'lane-primary', 'project-1', 'Primary', null, 'primary', 'main', 'main', '/tmp/project', + null, 1, null, null, null, null, null, + 'active', '2026-03-17T00:00:00.000Z', null + ); + """) + + try database.replacePullRequestHydration(payload) + XCTAssertEqual(database.fetchPullRequests().map(\.id), ["pr-before-lane"]) + XCTAssertEqual(database.fetchPullRequestSnapshot(prId: "pr-before-lane")?.status?.isMergeable, true) + database.close() + } + + func testDatabaseReplacePullRequestHydrationTargetedRefreshDoesNotPruneOtherPullRequests() throws { + let baseURL = makeTemporaryDirectory() + let database = makeControllerHydrationDatabase(baseURL: baseURL) + XCTAssertNil(database.initializationError) + + try insertHydrationProjectGraph(into: database) + + func summary(id: String, number: Int, title: String, updatedAt: String) -> PrSummary { + PrSummary( + id: id, + laneId: "lane-primary", + projectId: "project-1", + repoOwner: "arul", + repoName: "ade", + githubPrNumber: number, + githubUrl: "https://github.com/arul/ade/pull/\(number)", + githubNodeId: nil, + title: title, + state: "open", + baseBranch: "main", + headBranch: "ade/\(id)", + checksStatus: "pending", + reviewStatus: "requested", + additions: 5, + deletions: 1, + lastSyncedAt: updatedAt, + createdAt: "2026-03-17T00:10:00.000Z", + updatedAt: updatedAt + ) + } + + func snapshot(prId: String, isMergeable: Bool, updatedAt: String) -> PullRequestSnapshotHydration { + PullRequestSnapshotHydration( + prId: prId, + detail: nil, + status: PrStatus( + prId: prId, + state: "open", + checksStatus: "pending", + reviewStatus: "requested", + isMergeable: isMergeable, + mergeConflicts: false, + behindBaseBy: 0 + ), + checks: [], + reviews: [], + comments: [], + files: [], + updatedAt: updatedAt + ) + } + + try database.replacePullRequestHydration( + PullRequestRefreshPayload( + refreshedCount: 2, + prs: [ + summary(id: "pr-one", number: 41, title: "One", updatedAt: "2026-03-17T00:10:00.000Z"), + summary(id: "pr-two", number: 42, title: "Two", updatedAt: "2026-03-17T00:11:00.000Z"), + ], + snapshots: [ + snapshot(prId: "pr-one", isMergeable: true, updatedAt: "2026-03-17T00:10:00.000Z"), + snapshot(prId: "pr-two", isMergeable: true, updatedAt: "2026-03-17T00:11:00.000Z"), + ] + ) + ) + + try database.replacePullRequestHydration( + PullRequestRefreshPayload( + refreshedCount: 1, + prs: [ + summary(id: "pr-one", number: 41, title: "One updated", updatedAt: "2026-03-17T00:12:00.000Z"), + ], + snapshots: [ + snapshot(prId: "pr-one", isMergeable: false, updatedAt: "2026-03-17T00:12:00.000Z"), + ] + ), + pruneStale: false + ) + + let prs = database.fetchPullRequests() + XCTAssertEqual(Set(prs.map(\.id)), Set(["pr-one", "pr-two"])) + XCTAssertEqual(prs.first(where: { $0.id == "pr-one" })?.title, "One updated") + XCTAssertEqual(database.fetchPullRequestSnapshot(prId: "pr-one")?.status?.isMergeable, false) + XCTAssertEqual(database.fetchPullRequestSnapshot(prId: "pr-two")?.status?.isMergeable, true) + database.close() + } + func testDatabaseReplacePullRequestHydrationScopesPayloadToActiveProject() throws { let baseURL = makeTemporaryDirectory() let database = makeControllerHydrationDatabase(baseURL: baseURL) @@ -7761,7 +7930,10 @@ final class ADETests: XCTestCase { let opencodeGroup = groups.first(where: { $0.key == "opencode" }) let anthropicProvider = opencodeGroup?.providers.first(where: { $0.key == "anthropic" }) - XCTAssertEqual(anthropicProvider?.models.first?.id, "opencode/anthropic/claude-sonnet-4-6") + XCTAssertEqual( + anthropicProvider?.models.filter { $0.id == "opencode/anthropic/claude-sonnet-4-6" }.count, + 1 + ) } func testWorkModelCatalogIncludesFlagshipModelMetadata() { @@ -10538,6 +10710,34 @@ final class ADETests: XCTestCase { XCTAssertNil(status.organizationLogoUrl) } + func testWorkTranscriptEntryMergePrependsOlderPagesWithoutDuplicatingTailOverlap() { + let oldest = AgentChatTranscriptEntry( + role: "user", + text: "oldest", + timestamp: "2026-06-11T10:00:00.000Z", + turnId: "turn-1" + ) + let overlap = AgentChatTranscriptEntry( + role: "assistant", + text: "middle", + timestamp: "2026-06-11T10:01:00.000Z", + turnId: "turn-1" + ) + let newest = AgentChatTranscriptEntry( + role: "assistant", + text: "newest", + timestamp: "2026-06-11T10:02:00.000Z", + turnId: "turn-2" + ) + + let merged = mergeWorkTranscriptEntries( + older: [oldest, overlap], + newer: [overlap, newest] + ) + + XCTAssertEqual(merged, [oldest, overlap, newest]) + } + // MARK: - Orchestration session fields forward-compat func testAgentChatSessionSummaryDecodesOrchestrationFields() throws { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 00ea1143d..72a83e3be 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -462,7 +462,7 @@ ade.sync.* # device registry, PIN pairing (getPin/setPin/clear ade.usage.* # token/cost accounting ade.layout.* / ade.graph.* ade.computerUse.* -ade.iosSimulator.* # macOS-only iOS Simulator drawer + Preview Lab: getStatus/launch/shutdown/screenshot/getScreenSnapshot/getInspectorSnapshot/inspectPoint/getPreviewCapability/listPreviewTargets/resolvePreviewMatch/ensurePreviewWorkspace/renderPreview/openPreviewWorkspace/startStream/stopStream/getStreamStatus/getWindowState/listWindowSources/tap/typeText/drag/swipe/selectPoint, plus the ade.iosSimulator.event push channel +ade.iosSimulator.* # macOS-only iOS Simulator drawer + Preview Lab: getStatus/launch/shutdown/screenshot/getScreenSnapshot/getInspectorSnapshot/inspectPoint/getPreviewCapability/listPreviewTargets/resolvePreviewMatch/ensurePreviewWorkspace/renderCurrentPreview/renderPreview/openPreviewWorkspace/startStream/stopStream/getStreamStatus/getWindowState/listWindowSources/tap/typeText/drag/swipe/selectPoint, plus the ade.iosSimulator.event push channel ade.appControl.* # Electron app control bridge over Chrome DevTools Protocol: getStatus/launch/launchInTerminal/connect/stop/screenshot/getSnapshot/inspectPoint/selectPoint/click/typeText/scroll/dispatchKey/listTargets/attachToTarget, plus the ade.appControl.event push channel (session-started/updated/stopped, selection, screencast frame) ade.builtInBrowser.* # in-app web browser owned by `builtInBrowserService`: getStatus/showPanel/setBounds/attachWebview/navigate/createTab/switchTab/closeTab/reload/goBack/goForward/stop/startSession/listSessions/endSession/observe/getTrace/click/typeText/dispatchKey/scroll/fill/clear/wait/startInspect/stopInspect/captureScreenshot/selectPoint/selectCurrent/clearSelection/claim, plus the ade.builtInBrowser.event push channel (status / open-request / selection / selection-cleared / error). Backs the Work sidebar's Browser tab and the renderer-wide `openUrlInAdeBrowser()` link router. ade.terminal.* # chat-owned terminal control: list/read/write/signal/activeForChat. Resolves a chat's active terminal via chatSessionId so in-chat agents and the App Control panel can drive the visible launch terminal. diff --git a/docs/features/ios-simulator/README.md b/docs/features/ios-simulator/README.md index f85a0f8db..9e1cf7949 100644 --- a/docs/features/ios-simulator/README.md +++ b/docs/features/ios-simulator/README.md @@ -77,10 +77,12 @@ force shutdown is requested. 8. **Preview Lab.** `listPreviewTargets()` discovers nearby `#Preview` and `PreviewProvider` definitions. `resolvePreviewMatch()` ranks the best target - for the selected source file, using inspector label/component metadata only - as naming hints when a preview needs to be created. `ensurePreviewWorkspace()` - opens this lane's iOS project in Xcode when needed and waits for Preview Lab - readiness before `renderPreview()` drives Xcode MCP. + for the selected source file or the drawer's last selected simulator item, + using inspector label/component metadata only as naming hints when a preview + needs to be created. `renderCurrentPreview()` is the one-shot bridge from the + current simulator selection to ADE's Preview drawer: it resolves the match, + opens/waits for Xcode when needed, then calls `renderPreview()` through Xcode + MCP. 9. **Shutdown.** `shutdown({ force? })` stops live-view status, releases the active session, stops idb companion work, clears window parking follow state, @@ -100,6 +102,7 @@ ade --socket ios-sim select --x 120 --y 420 --text ade --socket ios-sim tap --x 120 --y 420 --text ade --socket ios-sim preview-match --source apps/ios/ADE/Views/Home.swift --line 42 --text ade --socket ios-sim preview-ensure --source apps/ios/ADE/Views/Home.swift --line 42 --text +ade --socket ios-sim preview-current --text ade --socket ios-sim preview-render --source apps/ios/ADE/Views/Home.swift --index 0 --text ade --socket ios-sim shutdown --text ``` @@ -107,6 +110,11 @@ ade --socket ios-sim shutdown --text Use `--socket` whenever the CLI and desktop drawer must share live session, selection, and proof state. +For current-screen Preview Lab work, run `select` on a source-backed simulator +element or pass an explicit `--source` and `--line`, then use +`preview-current`. A `no-context` result means ADE has no selected source-backed +element yet; it is not a signal to guess the SwiftUI screen from stale code. + ## Troubleshooting - If the live view is blank, verify Simulator.app is running and not minimized.