diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 6bfddec73..f08dd37f6 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -4071,7 +4071,8 @@ "@connectrpc/connect": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/@connectrpc/connect/-/connect-1.7.0.tgz", - "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==" + "integrity": "sha512-iNKdJRi69YP3mq6AePRT8F/HrxWCewrhxnLMNm0vpqXAR8biwzRtO6Hjx80C6UvtKJ5sFmffQT7I4Baecz389w==", + "requires": {} }, "@connectrpc/connect-node": { "version": "1.7.0", @@ -4593,7 +4594,8 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", - "dev": true + "dev": true, + "requires": {} }, "@types/estree": { "version": "1.0.8", @@ -5063,7 +5065,8 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true + "dev": true, + "requires": {} }, "file-uri-to-path": { "version": "1.0.0", diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index aa4c87cb8..035a4a989 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -3012,6 +3012,33 @@ describe("adeRpcServer", () => { }); }); + it("uses initialized chat session identity when the server process has no ADE_CHAT_SESSION_ID", async () => { + await withEnv({ ADE_CHAT_SESSION_ID: undefined, ADE_DEFAULT_ROLE: "agent" }, async () => { + const fixture = createRuntime(); + fixture.runtime.agentChatService.requestChatInput = vi.fn(async () => ({ + decision: "decline", + answers: {}, + responseText: null, + })); + const handler = createAdeRpcRequestHandler({ runtime: fixture.runtime, serverVersion: "test" }); + + await initialize(handler, { + callerId: "chat-session-identity", + role: "agent", + chatSessionId: "chat-session-identity", + }); + const response = await callTool(handler, "ask_user", { + title: "Pick a flow", + body: "Which part should we test first?", + }); + + expect(response?.isError).toBeUndefined(); + expect(fixture.runtime.agentChatService.requestChatInput).toHaveBeenCalledWith(expect.objectContaining({ + chatSessionId: "chat-session-identity", + })); + }); + }); + it("returns explicit timed_out semantics for standalone ask_user when the user does not answer in time", async () => { await withEnv({ ADE_CHAT_SESSION_ID: "chat-session-env" }, async () => { const fixture = createRuntime(); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index bde9dec61..18eff7061 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -2889,10 +2889,10 @@ function resolveCallerContext(session?: SessionState): CallerContext { return { callerId: asOptionalTrimmedString(session.identity.callerId), role: session.identity.role ?? envContext.role, - chatSessionId: envContext.chatSessionId, + chatSessionId: session.identity.chatSessionId ?? envContext.chatSessionId, standaloneChatSession: session.identity.standaloneChatSession, missionId: session.identity.missionId ?? envContext.missionId, - runId: envContext.runId, + runId: session.identity.runId ?? envContext.runId, stepId: session.identity.stepId ?? envContext.stepId, attemptId: session.identity.attemptId ?? envContext.attemptId, ownerId: session.identity.ownerId ?? envContext.ownerId, @@ -2995,7 +2995,11 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI ? identityRole : null; const validRole: SessionIdentity["role"] = envContext.role ?? "external"; - const resolvedRunId = envContext.runId; + const requestedChatSessionId = asOptionalTrimmedString(identity.chatSessionId); + const resolvedChatSessionId = envContext.chatSessionId ?? requestedChatSessionId; + const resolvedRunId = envContext.runId ?? asOptionalTrimmedString(identity.runId); + const resolvedStepId = envContext.stepId ?? asOptionalTrimmedString(identity.stepId); + const resolvedAttemptId = envContext.attemptId ?? asOptionalTrimmedString(identity.attemptId); const requestedMissionId = asOptionalTrimmedString(identity.missionId); const resolvedMissionId = envContext.missionId @@ -3007,21 +3011,21 @@ function parseInitializeIdentity(runtime: AdeRuntime, params: unknown): SessionI ); } - const standaloneChatSession = Boolean(envContext.chatSessionId) + const standaloneChatSession = Boolean(resolvedChatSessionId) && !envContext.missionId - && !envContext.runId - && !envContext.stepId - && !envContext.attemptId; + && !resolvedRunId + && !resolvedStepId + && !resolvedAttemptId; return { - callerId: asOptionalTrimmedString(identity.callerId) ?? envContext.chatSessionId ?? envContext.attemptId ?? "unknown", + callerId: asOptionalTrimmedString(identity.callerId) ?? resolvedChatSessionId ?? envContext.attemptId ?? "unknown", role: validRole, - chatSessionId: envContext.chatSessionId, + chatSessionId: resolvedChatSessionId, standaloneChatSession, missionId: resolvedMissionId ?? requestedMissionId ?? null, runId: resolvedRunId, - stepId: asOptionalTrimmedString(identity.stepId) ?? envContext.stepId, - attemptId: asOptionalTrimmedString(identity.attemptId) ?? envContext.attemptId, + stepId: resolvedStepId, + attemptId: resolvedAttemptId, ownerId: asOptionalTrimmedString(identity.ownerId) ?? envContext.ownerId, }; } diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 4c9d4a515..01021ecfb 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -3790,12 +3790,24 @@ export function shouldAttemptDesktopSocketConnection(socketPath: string): boolea } async function initializeConnection(connection: CliConnection, options: GlobalOptions): Promise { + const envChatSessionId = asString(process.env.ADE_CHAT_SESSION_ID); + const envMissionId = asString(process.env.ADE_MISSION_ID); + const envRunId = asString(process.env.ADE_RUN_ID); + const envStepId = asString(process.env.ADE_STEP_ID); + const envAttemptId = asString(process.env.ADE_ATTEMPT_ID); + const envOwnerId = asString(process.env.ADE_OWNER_ID); await connection.request("ade/initialize", { protocolVersion: PROTOCOL_VERSION, clientInfo: { name: "ade-cli", version: VERSION }, identity: { - callerId: "ade-cli", + callerId: envChatSessionId ?? envAttemptId ?? "ade-cli", role: options.role, + ...(envChatSessionId ? { chatSessionId: envChatSessionId } : {}), + ...(envMissionId ? { missionId: envMissionId } : {}), + ...(envRunId ? { runId: envRunId } : {}), + ...(envStepId ? { stepId: envStepId } : {}), + ...(envAttemptId ? { attemptId: envAttemptId } : {}), + ...(envOwnerId ? { ownerId: envOwnerId } : {}), computerUsePolicy: { mode: "auto", allowLocalFallback: options.role !== "external", diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 89d075b7c..bb8903e21 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -61,6 +61,7 @@ import type { PortLease, ProjectInfo, SyncMobileProjectSummary, SyncProjectConne import type { AutomationTriggerType } from "../shared/types/config"; import type { AutomationTriggerLinearIssueContext } from "../shared/types/automations"; import type { LinearIngressEventRecord } from "../shared/types/linearSync"; +import type { IosSimulatorDrawerMode } from "../shared/types/iosSimulator"; import type { AppContext } from "./services/ipc/registerIpc"; import fs from "node:fs"; import net from "node:net"; @@ -118,6 +119,7 @@ import { createWorkerAdapterRuntimeService } from "./services/cto/workerAdapterR import { createWorkerTaskSessionService } from "./services/cto/workerTaskSessionService"; import { createWorkerHeartbeatService } from "./services/cto/workerHeartbeatService"; import { createLinearCredentialService } from "./services/cto/linearCredentialService"; +import { buildRendererCspPolicy } from "./rendererCsp"; import { createLinearClient } from "./services/cto/linearClient"; import { createLinearIssueTracker } from "./services/cto/linearIssueTracker"; import { createLinearTemplateService } from "./services/cto/linearTemplateService"; @@ -468,26 +470,7 @@ async function createWindow(args: { // Set CSP dynamically so it works with both http:// (dev) and file:// (production). const isDevMode = !!process.env.VITE_DEV_SERVER_URL; - const cspSources = isDevMode - ? "'self' http://localhost:* http://127.0.0.1:*" - : "'self' file: app:"; - const cspWsSources = isDevMode ? " ws://localhost:* ws://127.0.0.1:*" : ""; - const cspLocalSources = " http://localhost:* http://127.0.0.1:*"; - const cspImageSources = `${cspSources}${cspLocalSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com`; - const cspPolicy = [ - `default-src ${cspSources}`, - `base-uri 'self'`, - `form-action 'self'`, - `object-src 'none'`, - `frame-src ${cspSources}${cspLocalSources} about:`, - `script-src ${cspSources} 'unsafe-inline'`, - `style-src ${cspSources} 'unsafe-inline'`, - `img-src ${cspImageSources} ade-artifact: data: blob:`, - `media-src ${cspSources}${cspLocalSources} ade-artifact: blob: data:`, - `font-src ${cspSources} data:`, - `connect-src ${cspSources}${cspWsSources} https:`, - `worker-src 'self' blob:`, - ].join("; "); + const cspPolicy = buildRendererCspPolicy(isDevMode); win.webContents.session.webRequest.onHeadersReceived((details, callback) => { callback({ @@ -2767,6 +2750,94 @@ app.whenReady().then(async () => { onEvent: (payload) => emitProjectEvent(projectRoot, IPC.iosSimulatorEvent, payload), }); + const iosSimulatorDrawerActionModes: Partial> = { + inspectPoint: "inspect", + launch: "interact", + openPreviewWorkspace: "preview", + renderPreview: "preview", + selectPoint: "inspect", + startStream: "interact", + tap: "interact", + typeText: "interact", + drag: "interact", + swipe: "interact", + }; + const requestIosSimulatorDrawerOpen = ( + action: keyof typeof iosSimulatorDrawerActionModes, + rawArgs: unknown, + result?: unknown, + ): void => { + const mode = iosSimulatorDrawerActionModes[action]; + if (!mode) return; + const argRecord = rawArgs && typeof rawArgs === "object" && !Array.isArray(rawArgs) + ? rawArgs as Record + : null; + const resultRecord = result && typeof result === "object" && !Array.isArray(result) + ? result as Record + : null; + const chatSessionId = readString(argRecord, "chatSessionId") ?? readString(resultRecord, "chatSessionId") ?? null; + const laneId = readString(argRecord, "laneId") ?? readString(resultRecord, "laneId") ?? null; + emitProjectEvent(projectRoot, IPC.iosSimulatorEvent, { + type: "drawer-open-requested", + action, + mode, + chatSessionId, + laneId, + }); + }; + const iosSimulatorRpcService = { + ...iosSimulatorService, + inspectPoint: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.inspectPoint(arg); + requestIosSimulatorDrawerOpen("inspectPoint", arg, result); + return result; + }, + launch: async (arg?: Parameters[0]) => { + const result = await iosSimulatorService.launch(arg); + requestIosSimulatorDrawerOpen("launch", arg, result); + return result; + }, + openPreviewWorkspace: async (arg?: Parameters[0]) => { + const result = await iosSimulatorService.openPreviewWorkspace(arg); + requestIosSimulatorDrawerOpen("openPreviewWorkspace", arg, result); + return result; + }, + renderPreview: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.renderPreview(arg); + requestIosSimulatorDrawerOpen("renderPreview", arg, result); + return result; + }, + selectPoint: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.selectPoint(arg); + requestIosSimulatorDrawerOpen("selectPoint", arg, result); + return result; + }, + startStream: async (arg?: Parameters[0]) => { + const result = await iosSimulatorService.startStream(arg); + requestIosSimulatorDrawerOpen("startStream", arg, result); + return result; + }, + tap: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.tap(arg); + requestIosSimulatorDrawerOpen("tap", arg, result); + return result; + }, + typeText: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.typeText(arg); + requestIosSimulatorDrawerOpen("typeText", arg, result); + return result; + }, + drag: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.drag(arg); + requestIosSimulatorDrawerOpen("drag", arg, result); + return result; + }, + swipe: async (arg: Parameters[0]) => { + const result = await iosSimulatorService.swipe(arg); + requestIosSimulatorDrawerOpen("swipe", arg, result); + return result; + }, + }; const appControlService = createAppControlService({ projectRoot, logger, @@ -3470,7 +3541,7 @@ app.whenReady().then(async () => { automationService, automationPlannerService, computerUseArtifactBrokerService, - iosSimulatorService, + iosSimulatorService: iosSimulatorRpcService, appControlService, builtInBrowserService, orchestratorService, diff --git a/apps/desktop/src/main/rendererCsp.test.ts b/apps/desktop/src/main/rendererCsp.test.ts new file mode 100644 index 000000000..4f860cd97 --- /dev/null +++ b/apps/desktop/src/main/rendererCsp.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { buildRendererCspPolicy } from "./rendererCsp"; + +describe("buildRendererCspPolicy", () => { + it("allows packaged renderer fetches to local simulator stream URLs", () => { + const policy = buildRendererCspPolicy(false); + + expect(policy).toContain("connect-src 'self' file: app: http://localhost:* http://127.0.0.1:* https:"); + }); + + it("keeps dev websocket sources for Vite while allowing local fetches", () => { + const policy = buildRendererCspPolicy(true); + + expect(policy).toContain("connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https:"); + }); + + it("frames built-in browser content from local servers and about:blank in packaged builds", () => { + const policy = buildRendererCspPolicy(false); + + expect(policy).toContain("frame-src 'self' file: app: http://localhost:* http://127.0.0.1:* about:"); + }); +}); diff --git a/apps/desktop/src/main/rendererCsp.ts b/apps/desktop/src/main/rendererCsp.ts new file mode 100644 index 000000000..132968afa --- /dev/null +++ b/apps/desktop/src/main/rendererCsp.ts @@ -0,0 +1,23 @@ +export function buildRendererCspPolicy(isDevMode: boolean): string { + const cspSources = isDevMode + ? "'self' http://localhost:* http://127.0.0.1:*" + : "'self' file: app:"; + const cspWsSources = isDevMode ? " ws://localhost:* ws://127.0.0.1:*" : ""; + const cspLocalSources = " http://localhost:* http://127.0.0.1:*"; + const cspConnectLocalSources = isDevMode ? "" : cspLocalSources; + const cspImageSources = `${cspSources}${cspLocalSources} https://avatars.githubusercontent.com https://*.githubusercontent.com https://github.githubassets.com https://opengraph.githubassets.com https://github.com https://vercel.com https://*.vercel.com https://img.shields.io https://*.s3.amazonaws.com`; + return [ + `default-src ${cspSources}`, + `base-uri 'self'`, + `form-action 'self'`, + `object-src 'none'`, + `frame-src ${cspSources}${cspLocalSources} about:`, + `script-src ${cspSources} 'unsafe-inline'`, + `style-src ${cspSources} 'unsafe-inline'`, + `img-src ${cspImageSources} ade-artifact: data: blob:`, + `media-src ${cspSources}${cspLocalSources} ade-artifact: blob: data:`, + `font-src ${cspSources} data:`, + `connect-src ${cspSources}${cspConnectLocalSources}${cspWsSources} https:`, + `worker-src 'self' blob:`, + ].join("; "); +} diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts index a3029a919..c88245cea 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.test.ts @@ -2,8 +2,107 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { BuiltInBrowserEventPayload } from "../../../shared/types"; import { createBuiltInBrowserService } from "./builtInBrowserService"; +const fakes = vi.hoisted(() => { + type DebuggerHandler = (...args: unknown[]) => void; + + class FakeDebugger { + attached = false; + sendCommandImpl: (method: string, params?: Record) => Promise = async () => ({}); + sendCommand(method: string, params?: Record): Promise { + return this.sendCommandImpl(method, params); + } + private listeners: Record = {}; + attach = (): void => { + this.attached = true; + }; + detach = (): void => { + this.attached = false; + }; + isAttached = (): boolean => this.attached; + on = (event: string, fn: DebuggerHandler): void => { + (this.listeners[event] ??= []).push(fn); + }; + off = (event: string, fn: DebuggerHandler): void => { + const list = this.listeners[event]; + if (!list) return; + const idx = list.indexOf(fn); + if (idx >= 0) list.splice(idx, 1); + }; + } + + class FakeWebContents { + id = Math.floor(Math.random() * 1_000_000); + debugger = new FakeDebugger(); + loadURL = async (_url: string): Promise => undefined; + reload = (): void => undefined; + goBack = (): void => undefined; + goForward = (): void => undefined; + stop = (): void => undefined; + isLoading = (): boolean => false; + canGoBack = (): boolean => false; + canGoForward = (): boolean => false; + isDestroyed = (): boolean => false; + getURL = (): string => ""; + getTitle = (): string => ""; + setAudioMuted = (_muted: boolean): void => undefined; + setWindowOpenHandler = (_handler: unknown): void => undefined; + on = (_event: string, _fn: (...args: unknown[]) => void): void => undefined; + } + + class FakeWebContentsView { + webContents = new FakeWebContents(); + setBackgroundColor = (_color: string): void => undefined; + setBounds = (_rect: unknown): void => undefined; + setVisible = (_visible: boolean): void => undefined; + } + + // Track the most recently constructed FakeDebugger so tests can wire sendCommand impls. + const debuggerInstances: FakeDebugger[] = []; + const OriginalFakeDebugger = FakeDebugger; + class TrackedFakeDebugger extends OriginalFakeDebugger { + constructor() { + super(); + debuggerInstances.push(this); + } + } + // Replace FakeWebContents.debugger with the tracked variant. + class TrackedFakeWebContents extends FakeWebContents { + constructor() { + super(); + this.debugger = new TrackedFakeDebugger(); + } + } + class TrackedFakeWebContentsView extends FakeWebContentsView { + constructor() { + super(); + this.webContents = new TrackedFakeWebContents(); + } + } + + let activeImpl: (method: string, params?: Record) => Promise = async () => ({}); + // Override sendCommand on the prototype to delegate to the shared activeImpl, so future + // instances pick it up automatically without per-instance patching races. + OriginalFakeDebugger.prototype.sendCommand = function (method: string, params?: Record) { + return activeImpl(method, params); + }; + + return { + WebContentsView: TrackedFakeWebContentsView, + debuggerInstances, + setSendCommand: (impl: (method: string, params?: Record) => Promise) => { + activeImpl = impl; + }, + resetSendCommand: () => { + activeImpl = async () => ({}); + }, + clearDebuggerInstances: () => { + debuggerInstances.length = 0; + }, + }; +}); + vi.mock("electron", () => ({ - WebContentsView: class {}, + WebContentsView: fakes.WebContentsView, nativeImage: { createFromDataURL: () => ({ getSize: () => ({ width: 0, height: 0 }) }) }, session: { fromPartition: () => ({ setPermissionCheckHandler: () => {}, setPermissionRequestHandler: () => {} }) }, webContents: { fromId: () => null }, @@ -109,3 +208,77 @@ describe("createBuiltInBrowserService — bounds and status dedupe", () => { expect(service.getStatus().tabs).toEqual([]); }); }); + +describe("createBuiltInBrowserService — switchTab and navigate inspect/selection invariants", () => { + let collector: ReturnType; + + beforeEach(() => { + collector = captureStatusEvents(); + fakes.resetSendCommand(); + fakes.clearDebuggerInstances(); + }); + + it("switchTab to the currently active tab does not clear an existing selection", async () => { + fakes.setSendCommand(async (method) => { + switch (method) { + case "DOM.getNodeForLocation": + return { backendNodeId: 42 }; + case "DOM.resolveNode": + return { object: { objectId: "obj-1" } }; + case "Runtime.callFunctionOn": + return { + result: { + value: { + tagName: "div", + selector: "div#root", + testId: null, + frame: { x: 0, y: 0, width: 10, height: 10 }, + pixelRatio: 1, + url: "http://example.test/", + title: "test", + metadata: { viewport: { width: 100, height: 100 } }, + }, + }, + }; + default: + return {}; + } + }); + + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.navigate({ url: "https://example.test", newTab: true }); + const activeTabId = service.getStatus().activeTabId; + expect(activeTabId).toBeTruthy(); + + const result = await service.selectPoint({ x: 5, y: 5, includeScreenshot: false }); + expect(result.item).not.toBeNull(); + expect(service.getStatus().hasSelection).toBe(true); + + const eventsBefore = collector.events.length; + if (!activeTabId) throw new Error("missing activeTabId"); + await service.switchTab({ tabId: activeTabId }); + + expect(service.getStatus().hasSelection).toBe(true); + const newClearEvents = collector.events + .slice(eventsBefore) + .filter((e) => e.type === "selection-cleared"); + expect(newClearEvents).toHaveLength(0); + }); + + it("navigate to a URL on the active tab stops inspect mode (CDP overlay desync fix)", async () => { + fakes.setSendCommand(async () => ({})); + + const service = createBuiltInBrowserService({ onEvent: collector.onEvent }); + await service.navigate({ url: "https://example.test", newTab: true }); + + await service.startInspect(); + expect(service.getStatus().isInspecting).toBe(true); + + const activeTabId = service.getStatus().activeTabId; + if (!activeTabId) throw new Error("missing activeTabId"); + + await service.navigate({ url: "https://example.test/two", tabId: activeTabId }); + + expect(service.getStatus().isInspecting).toBe(false); + }); +}); diff --git a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts index 35f8f0c77..86934cf83 100644 --- a/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts +++ b/apps/desktop/src/main/services/builtInBrowser/builtInBrowserService.ts @@ -453,8 +453,8 @@ export function createBuiltInBrowserService(args: { if (!existingTab) throw new Error(`Browser tab not found: ${input.tabId}`); } const switchingTabs = input.newTab || (input.tabId && input.tabId !== activeTabId); + await stopInspectQuietly("built_in_browser.navigate_stop_inspect_failed"); if (switchingTabs) { - await stopInspectQuietly("built_in_browser.navigate_stop_inspect_failed"); clearSelectionInternal(); } let tab = input.newTab ? createTabState() : null; @@ -501,11 +501,14 @@ export function createBuiltInBrowserService(args: { if (!tabId) throw new Error("Browser tab id is required."); const tab = tabs.find((entry) => entry.id === tabId); if (!tab) throw new Error(`Browser tab not found: ${tabId}`); - if (tab.id !== activeTabId) { + const wasDifferentTab = tab.id !== activeTabId; + if (wasDifferentTab) { await stopInspectQuietly("built_in_browser.switch_tab_stop_inspect_failed"); } activeTabId = tab.id; - clearSelectionInternal(); + if (wasDifferentTab) { + clearSelectionInternal(); + } attachViewsToCurrentWindow(); emitStatus(); return getStatus(); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index e8c92b01c..507100430 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -14,6 +14,7 @@ import type { } from "../../../shared/types"; import { ADE_CLI_INLINE_GUIDANCE } from "../../../shared/adeCliGuidance"; import { getCtoPersonalityPreset } from "../../../shared/ctoPersonalityPresets"; +import { getDefaultModelDescriptor, listModelDescriptorsForProvider, type ModelProviderGroup } from "../../../shared/modelRegistry"; import type { createMemoryService, Memory, MemoryCategory } from "../memory/memoryService"; import type { AdeDb } from "../state/kvDb"; import { nowIso, parseIsoToEpoch, safeJsonParse, uniqueStrings, writeTextAtomic } from "../shared/utils"; @@ -65,6 +66,37 @@ const DURABLE_MEMORY_CATEGORY_ORDER: MemoryCategory[] = [ "fact", ]; +const CTO_MODEL_PROVIDER_GROUPS: ModelProviderGroup[] = ["claude", "codex", "cursor", "droid", "opencode"]; + +function buildCtoModelSelectionKnowledge(): string[] { + const providerLines = CTO_MODEL_PROVIDER_GROUPS.map((provider) => { + const defaultModel = getDefaultModelDescriptor(provider); + const models = listModelDescriptorsForProvider(provider) + .filter((model) => !model.deprecated) + .slice(0, 6); + const modelText = models.length + ? models.map((model) => { + const suffixes = [ + model.shortId ? `shortId: ${model.shortId}` : null, + model.id === defaultModel?.id ? "default" : null, + ].filter(Boolean).join(", "); + return suffixes ? `${model.id} (${suffixes})` : model.id; + }).join("; ") + : "runtime-discovered only"; + return ` ${provider}: ${modelText}`; + }); + return [ + "## Model Selection", + "", + "ADE's model registry is the source of truth for model IDs, short IDs, defaults, and provider routing. Current registered provider groups:", + ...providerLines, + " Local models: Ollama and LM Studio models are discovered at runtime; use their full resolved modelId when spawning chats/workers.", + " Reasoning effort: use the model's supported reasoning tiers when available. Do not invent tiers for a model that does not advertise them.", + " IMPORTANT: When the user says 'use opus', 'use sonnet', 'use gpt-5.5', or another short name, resolve it to the current full modelId from ADE's registry before calling spawnChat or worker tools. Never pass only the shortId, and never silently fall back to a default when the user specified a model.", + "", + ]; +} + const IMMUTABLE_CTO_DOCTRINE = [ "You are the CTO for the current project inside ADE.", "ADE (Autonomous Development Environment) is a local-first Electron desktop app that wraps your entire development workflow: git branching via lanes, AI chat sessions, terminal shells, PR management, mission orchestration, worker agents, conflict resolution, test execution, Linear integration, and more.", @@ -86,7 +118,8 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", "- When you are unsure which tool to use, consult the capability manifest in your system prompt before asking the user.", - `- ${ADE_CLI_INLINE_GUIDANCE}`, + "ADE CLI operating guidance:", + ADE_CLI_INLINE_GUIDANCE, ].join("\n"); const CTO_MEMORY_OPERATING_MODEL = [ @@ -106,7 +139,8 @@ const CTO_MEMORY_OPERATING_MODEL = [ "- Distill important session context before compaction removes detail, but persist only durable insights.", ].join("\n"); -const CTO_ENVIRONMENT_KNOWLEDGE = [ +function buildCtoEnvironmentKnowledge(): string { + return [ "# ADE Architecture & Concepts", "", "## Core Concepts", @@ -160,15 +194,7 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ " /settings — App settings: AI providers, GitHub token, Linear integration, keybindings, usage budgets, and external connectors.", " When an action should be opened in ADE, return a navigation suggestion. Never silently switch tabs.", "", - "## Model Selection", - "", - "ADE supports multiple AI providers and models. When spawning chats or configuring workers, use the correct modelId:", - " Anthropic models (via Claude CLI): anthropic/claude-opus-4-7 (shortId: opus), anthropic/claude-sonnet-4-6 (shortId: sonnet), anthropic/claude-haiku-4-5 (shortId: haiku).", - " OpenAI models (via Codex CLI): openai/gpt-5.5 (shortId: gpt-5.5), openai/gpt-5.4, openai/gpt-5.4-mini, openai/gpt-5.3-codex.", - " Local models: ollama/llama-3.3, lmstudio/* (discovered at runtime).", - " Reasoning effort (for supported models): low, medium, high, max (opus), xhigh (openai).", - " IMPORTANT: When the user says 'use opus' → modelId: 'anthropic/claude-opus-4-7'. 'Use sonnet' → 'anthropic/claude-sonnet-4-6'. 'Use gpt-5.5' → 'openai/gpt-5.5'. Always pass the full modelId, never just the shortId, to spawnChat and other tools.", - "", + ...buildCtoModelSelectionKnowledge(), "## Critical Distinctions", "", "Chats vs Terminals — both are valid, match the user's intent:", @@ -178,7 +204,7 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ " - Example: 'Launch a chat with opus' → spawnChat({ modelId: 'anthropic/claude-opus-4-7', ... }). 'Open a terminal' → createTerminal. 'Run npm test' → createTerminal({ startupCommand: 'npm test' }).", "", "Tool calling convention:", - ` - ${ADE_CLI_INLINE_GUIDANCE}`, + ADE_CLI_INLINE_GUIDANCE, " - If a tool from the manifest below is not in your immediate tool list, use the closest ADE CLI command or report the missing capability clearly.", "", "## PR Lifecycle in ADE", @@ -261,7 +287,8 @@ const CTO_ENVIRONMENT_KNOWLEDGE = [ " 'Archive a lane' → archiveLane({ laneId }).", " 'Show me recent commits' → gitListRecentCommits.", " 'Create a PR for this lane' → createPrFromLane({ laneId, title, body }).", -].join("\n"); + ].join("\n"); +} // Keep in sync with ctoOperatorTools.ts tool registrations const CTO_CAPABILITY_MANIFEST = [ @@ -1264,7 +1291,7 @@ export function createCtoStateService(args: CtoStateServiceArgs) { sections.push("- Memory write policy: use memoryUpdateCore for standing brief changes, use memoryAdd for durable reusable lessons, and skip ephemeral status notes."); sections.push(""); sections.push("ADE Operational Knowledge"); - sections.push(CTO_ENVIRONMENT_KNOWLEDGE); + sections.push(buildCtoEnvironmentKnowledge()); sections.push(""); sections.push("CTO Identity"); sections.push(`- Name: ${snapshot.identity.name}`); @@ -1377,7 +1404,7 @@ export function createCtoStateService(args: CtoStateServiceArgs) { { id: "knowledge", title: "ADE environment knowledge", - content: CTO_ENVIRONMENT_KNOWLEDGE, + content: buildCtoEnvironmentKnowledge(), }, { id: "capabilities", diff --git a/apps/desktop/src/main/services/diffs/diffService.test.ts b/apps/desktop/src/main/services/diffs/diffService.test.ts new file mode 100644 index 000000000..e21675dcd --- /dev/null +++ b/apps/desktop/src/main/services/diffs/diffService.test.ts @@ -0,0 +1,49 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { createDiffService } from "./diffService"; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }); +} + +function createLaneServiceStub(rootPath: string) { + return { + getLaneBaseAndBranch: () => ({ + worktreePath: rootPath, + }), + } as any; +} + +describe("diffService", () => { + it("bounds large file diff sides before they reach Monaco", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-diff-service-large-")); + const service = createDiffService({ laneService: createLaneServiceStub(rootPath) }); + + try { + git(rootPath, ["init"]); + git(rootPath, ["config", "user.email", "ade@example.com"]); + git(rootPath, ["config", "user.name", "ADE"]); + fs.writeFileSync(path.join(rootPath, "large.ts"), `${"a".repeat(260 * 1024)}\n`, "utf8"); + git(rootPath, ["add", "large.ts"]); + git(rootPath, ["commit", "-m", "base"]); + fs.writeFileSync(path.join(rootPath, "large.ts"), `${"b".repeat(260 * 1024)}\n`, "utf8"); + + const diff = await service.getFileDiff({ + laneId: "lane-1", + filePath: "large.ts", + mode: "unstaged", + }); + + expect(diff.original.isTruncated).toBe(true); + expect(diff.modified.isTruncated).toBe(true); + expect(diff.original.text.length).toBeLessThan(210 * 1024); + expect(diff.modified.text.length).toBeLessThan(210 * 1024); + expect(diff.modified.text).toContain("Preview truncated"); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/desktop/src/main/services/diffs/diffService.ts b/apps/desktop/src/main/services/diffs/diffService.ts index a2873ff15..d0cbf6ed1 100644 --- a/apps/desktop/src/main/services/diffs/diffService.ts +++ b/apps/desktop/src/main/services/diffs/diffService.ts @@ -4,6 +4,13 @@ import type { createLaneService } from "../lanes/laneService"; import { runGit } from "../git/git"; import type { DiffChanges, DiffMode, FileDiff, FileChange } from "../../../shared/types"; +export const MAX_DIFF_SIDE_TEXT_BYTES = 192 * 1024; +export const DIFF_TRUNCATION_NOTICE = "\n\n[Preview truncated. Open the file externally or in Files for the full content.]\n"; + +export function appendDiffTruncationNotice(text: string): string { + return `${text.replace(/\s*$/, "")}${DIFF_TRUNCATION_NOTICE}`; +} + function parseStatusKind(code: string): FileChange["kind"] { if (code === "??") return "untracked"; const c = code.replace(/[^A-Z]/g, ""); @@ -26,7 +33,13 @@ function detectBinary(buf: Buffer): boolean { return buf.includes(0); } -function readTextFileSafe(absPath: string, maxBytes: number): { exists: boolean; text: string; isBinary?: boolean } { +function readTextFileSafe(absPath: string, maxBytes: number): { + exists: boolean; + text: string; + isBinary?: boolean; + isTruncated?: boolean; + size?: number; +} { try { const stat = fs.statSync(absPath); if (!stat.isFile()) return { exists: false, text: "" }; @@ -38,8 +51,8 @@ function readTextFileSafe(absPath: string, maxBytes: number): { exists: boolean; fs.readSync(fd, buf, 0, buf.length, 0); if (detectBinary(buf)) return { exists: true, text: "", isBinary: true }; const text = buf.toString("utf8"); - // If truncated, keep the visible prefix; UI can show a warning. - return { exists: true, text }; + const isTruncated = size > maxBytes; + return { exists: true, text: isTruncated ? appendDiffTruncationNotice(text) : text, isTruncated, size }; } finally { fs.closeSync(fd); } @@ -52,7 +65,7 @@ async function gitShowText( cwd: string, spec: string, maxBytes: number -): Promise<{ exists: boolean; text: string; isBinary?: boolean }> { +): Promise<{ exists: boolean; text: string; isBinary?: boolean; isTruncated?: boolean; size?: number }> { const res = await runGit(["show", spec], { cwd, timeoutMs: 10_000, @@ -61,13 +74,18 @@ async function gitShowText( if (res.exitCode !== 0) return { exists: false, text: "" }; const buf = Buffer.from(res.stdout, "utf8"); if (detectBinary(buf)) return { exists: true, text: "", isBinary: true }; - if (buf.length > maxBytes) return { exists: true, text: buf.subarray(0, maxBytes).toString("utf8") }; - return { exists: true, text: res.stdout }; + if (buf.length > maxBytes) { + return { + exists: true, + text: appendDiffTruncationNotice(buf.subarray(0, maxBytes).toString("utf8")), + isTruncated: true, + size: buf.length, + }; + } + return { exists: true, text: res.stdout, size: buf.length }; } export function createDiffService({ laneService }: { laneService: ReturnType }) { - const MAX_TEXT_BYTES = 512 * 1024; - return { async getChanges(laneId: string): Promise { const { worktreePath } = laneService.getLaneBaseAndBranch(laneId); @@ -115,14 +133,14 @@ export function createDiffService({ laneService }: { laneService: ReturnType working tree - const idx = await gitShowText(worktreePath, `:${filePath}`, MAX_TEXT_BYTES); - const wt = readTextFileSafe(abs, MAX_TEXT_BYTES); + const idx = await gitShowText(worktreePath, `:${filePath}`, MAX_DIFF_SIDE_TEXT_BYTES); + const wt = readTextFileSafe(abs, MAX_DIFF_SIDE_TEXT_BYTES); const isBinary = Boolean(idx.isBinary || wt.isBinary); return { path: filePath, mode, - original: { exists: idx.exists, text: idx.text }, - modified: { exists: wt.exists, text: wt.text }, + original: { exists: idx.exists, text: idx.text, size: idx.size, isTruncated: idx.isTruncated }, + modified: { exists: wt.exists, text: wt.text, size: wt.size, isTruncated: wt.isTruncated }, ...(isBinary ? { isBinary: true } : {}) }; } diff --git a/apps/desktop/src/main/services/files/fileSearchIndexService.ts b/apps/desktop/src/main/services/files/fileSearchIndexService.ts index b5ca4b4f5..7bbfbc356 100644 --- a/apps/desktop/src/main/services/files/fileSearchIndexService.ts +++ b/apps/desktop/src/main/services/files/fileSearchIndexService.ts @@ -7,6 +7,20 @@ const MAX_INDEXED_FILES = 25_000; const MAX_TEXT_FILE_BYTES = 1_000_000; const MAX_TOTAL_CONTENT_BYTES = 80 * 1024 * 1024; const YIELD_EVERY_FILES = 120; +const VOLATILE_ADE_PREFIXES = [ + ".ade/artifacts/", + ".ade/cache/", + ".ade/secrets/", + ".ade/transcripts/", + ".ade/worktrees/", +]; +const VOLATILE_ADE_FILES = new Set([ + ".ade/ade.db", + ".ade/ade.db-shm", + ".ade/ade.db-wal", + ".ade/ade.sock", + ".ade/local.secret.yaml", +]); type IndexedFile = { path: string; @@ -27,7 +41,13 @@ type WorkspaceIndex = { builtAt: string | null; }; +function isVolatileAdeRuntimePath(relPath: string): boolean { + return VOLATILE_ADE_FILES.has(relPath) + || VOLATILE_ADE_PREFIXES.some((prefix) => relPath === prefix.slice(0, -1) || relPath.startsWith(prefix)); +} + function shouldSkipPathPrefix(relPath: string, includeIgnored: boolean): boolean { + if (isVolatileAdeRuntimePath(relPath)) return true; if (includeIgnored) return false; return relPath === ".ade" || relPath.startsWith(".ade/"); } @@ -141,7 +161,7 @@ export function createFileSearchIndexService() { }); }; - const shouldSkipDirectoryName = (name: string, includeIgnored: boolean): boolean => { + const shouldSkipDirectoryName = (name: string): boolean => { if (name === ".git") return true; if (name === "node_modules") return true; return false; @@ -171,7 +191,7 @@ export function createFileSearchIndexService() { const relPath = normalizeRelative(path.join(relDir, entry.name)); if (!relPath) continue; if (shouldSkipPathPrefix(relPath, index.includeIgnored)) continue; - if (entry.isDirectory() && shouldSkipDirectoryName(entry.name, index.includeIgnored)) continue; + if (entry.isDirectory() && shouldSkipDirectoryName(entry.name)) continue; if (await opts.shouldIgnore(relPath, index.includeIgnored)) continue; if (entry.isDirectory()) { diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index e6fd2e55b..20d9ba256 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -72,8 +72,47 @@ describe("fileService", () => { isBinary: true, previewKind: "image", mimeType: "image/png", - dataUrl: `data:image/png;base64,${pngBytes.toString("base64")}`, }); + expect(result.dataUrl).toBeUndefined(); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + + it("omits oversized text and image payloads instead of sending them to the renderer", () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-large-")); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.writeFileSync(path.join(rootPath, "huge.ts"), "x".repeat(1024 * 1024 + 1), "utf8"); + fs.writeFileSync(path.join(rootPath, "huge.png"), Buffer.alloc(1024 * 1024 + 1, 1)); + + const text = service.readFile({ workspaceId: "workspace-1", path: "huge.ts" }); + const image = service.readFile({ workspaceId: "workspace-1", path: "huge.png" }); + + expect(text).toMatchObject({ + content: "", + encoding: "utf-8", + size: 1024 * 1024 + 1, + languageId: "typescript", + isBinary: true, + previewKind: "binary", + contentOmitted: true, + omittedReason: "too_large", + }); + expect(image).toMatchObject({ + content: "", + encoding: "base64", + size: 1024 * 1024 + 1, + languageId: "image", + isBinary: true, + previewKind: "binary", + mimeType: "image/png", + contentOmitted: true, + omittedReason: "too_large", + }); + expect(image.dataUrl).toBeUndefined(); } finally { fs.rmSync(rootPath, { recursive: true, force: true }); } @@ -100,7 +139,7 @@ describe("fileService", () => { languageId: "plaintext", isBinary: true, previewKind: "binary", - mimeType: null, + mimeType: "application/octet-stream", }); expect(result.dataUrl).toBeUndefined(); } finally { @@ -151,6 +190,51 @@ describe("fileService", () => { } }); + it("keeps useful dotfiles searchable while skipping volatile ADE runtime paths", async () => { + const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-volatile-")); + const { execSync } = await import("node:child_process"); + execSync("git init", { cwd: rootPath, stdio: "ignore" }); + const laneService = createLaneServiceStub(rootPath); + const service = createFileService({ laneService }); + + try { + fs.mkdirSync(path.join(rootPath, ".ade", "notes"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, ".ade", "worktrees", "lane-a"), { recursive: true }); + fs.mkdirSync(path.join(rootPath, ".ade", "cache"), { recursive: true }); + fs.writeFileSync(path.join(rootPath, ".ade", "notes", "project.md"), "keep searchable\n", "utf8"); + fs.writeFileSync(path.join(rootPath, ".ade", "worktrees", "lane-a", "ghost.ts"), "hidden worktree payload\n", "utf8"); + fs.writeFileSync(path.join(rootPath, ".ade", "cache", "scratch.log"), "hidden cache payload\n", "utf8"); + + const quickOpen = await service.quickOpen({ + workspaceId: "workspace-1", + query: "ghost", + includeIgnored: true, + }); + const search = await service.searchText({ + workspaceId: "workspace-1", + query: "payload", + includeIgnored: true, + }); + const notes = await service.searchText({ + workspaceId: "workspace-1", + query: "searchable", + includeIgnored: true, + }); + const adeChildren = await service.listTree({ + workspaceId: "workspace-1", + parentPath: ".ade", + includeIgnored: true, + }); + + expect(quickOpen).toEqual([]); + expect(search).toEqual([]); + expect(notes.map((item) => item.path)).toEqual([".ade/notes/project.md"]); + expect(adeChildren.map((node) => node.path)).toEqual([".ade/notes"]); + } finally { + fs.rmSync(rootPath, { recursive: true, force: true }); + } + }); + it("lists only the requested tree depth without extra file metadata", async () => { const rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-file-service-tree-")); const { execSync } = await import("node:child_process"); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 50f9b2fe4..7efd02173 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -35,8 +35,25 @@ import { import { createFileWatcherService } from "./fileWatcherService"; import { createFileSearchIndexService } from "./fileSearchIndexService"; -const MAX_EDITOR_READ_BYTES = 5 * 1024 * 1024; +const MAX_EDITOR_TEXT_READ_BYTES = 1024 * 1024; +const MAX_INLINE_IMAGE_PREVIEW_BYTES = 1024 * 1024; +const MAX_INLINE_BINARY_BYTES = 256 * 1024; +const MAX_TREE_CHILDREN_PER_DIRECTORY = 1_000; const GIT_STATUS_CACHE_TTL_MS = 5_000; +const VOLATILE_ADE_PREFIXES = [ + ".ade/artifacts/", + ".ade/cache/", + ".ade/secrets/", + ".ade/transcripts/", + ".ade/worktrees/", +]; +const VOLATILE_ADE_FILES = new Set([ + ".ade/ade.db", + ".ade/ade.db-shm", + ".ade/ade.db-wal", + ".ade/ade.sock", + ".ade/local.secret.yaml", +]); const TEXT_EXTENSIONS = new Set([ ".bash", ".c", @@ -175,6 +192,42 @@ function isAlwaysIgnoredPath(normalized: string): boolean { ); } +function isVolatileAdeRuntimePath(normalized: string): boolean { + return VOLATILE_ADE_FILES.has(normalized) + || VOLATILE_ADE_PREFIXES.some((prefix) => normalized === prefix.slice(0, -1) || normalized.startsWith(prefix)); +} + +function readFilePrefix(absPath: string, maxBytes: number): Buffer { + const fd = fs.openSync(absPath, "r"); + try { + const buf = Buffer.alloc(maxBytes); + const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0); + return bytesRead === buf.length ? buf : buf.subarray(0, bytesRead); + } finally { + fs.closeSync(fd); + } +} + +function omittedFileContent(args: { + relPath: string; + size: number; + encoding: "utf-8" | "base64"; + mimeType?: string | null; + reason: FileContent["omittedReason"]; +}): FileContent { + return { + content: "", + encoding: args.encoding, + size: args.size, + languageId: languageIdFromPath(args.relPath), + isBinary: true, + previewKind: "binary", + mimeType: args.mimeType ?? null, + contentOmitted: true, + omittedReason: args.reason, + }; +} + async function runGitCheckIgnoreBatch(args: { cwd: string; paths: string[]; timeoutMs?: number }): Promise> { if (args.paths.length === 0) return new Set(); const timeoutMs = args.timeoutMs ?? 7_000; @@ -241,6 +294,9 @@ function ensureSafePath( opts: { allowMissing?: boolean } = {}, ): { absPath: string; normalizedRel: string } { const normalizedRel = normalizeRelative(relPath); + if (isVolatileAdeRuntimePath(normalizedRel)) { + throw new Error("Refusing to access ADE runtime paths"); + } const joinedPath = path.normalize(path.join(rootPath, normalizedRel)); let absPath: string; try { @@ -259,6 +315,9 @@ function ensureSafePath( function assertMutablePathAllowed(rootPath: string, relPath: string): string { const normalizedRel = normalizeRelative(relPath); + if (isVolatileAdeRuntimePath(normalizedRel)) { + throw new Error("Refusing to mutate ADE runtime paths"); + } const candidatePath = path.join(rootPath, normalizedRel); if (containsDotGit(candidatePath)) { throw new Error("Refusing to access .git internals"); @@ -270,13 +329,25 @@ function isWorkspaceRootRelativePath(normalizedRel: string): boolean { return normalizedRel === "" || normalizedRel === "."; } -function inferDirectoryStatus(statusMap: Map, relPath: string): FileTreeChangeStatus { - const prefix = `${normalizeRelative(relPath)}/`; - for (const [filePath, status] of statusMap) { +type GitStatusSnapshot = { + fileStatus: Map; + changedDirectories: Set; +}; + +function buildGitStatusSnapshot(fileStatus: Map): GitStatusSnapshot { + const changedDirectories = new Set(); + for (const [filePath, status] of fileStatus) { if (!status) continue; - if (filePath.startsWith(prefix)) return "M"; + const segments = normalizeRelative(filePath).split("/").filter(Boolean); + for (let i = 1; i < segments.length; i++) { + changedDirectories.add(segments.slice(0, i).join("/")); + } } - return null; + return { fileStatus, changedDirectories }; +} + +function inferDirectoryStatus(statusSnapshot: GitStatusSnapshot, relPath: string): FileTreeChangeStatus { + return statusSnapshot.changedDirectories.has(normalizeRelative(relPath)) ? "M" : null; } export function createFileService({ @@ -290,7 +361,7 @@ export function createFileService({ const indexService = createFileSearchIndexService(); const ignoreCache = new Map(); const ignoredPrefixCache = new Set(); - const gitStatusCache = new Map }>(); + const gitStatusCache = new Map(); const clearIgnoreCacheForRoot = (rootPath: string): void => { const prefix = `${rootPath}::`; @@ -322,6 +393,7 @@ export function createFileService({ const normalized = normalizeRelative(relPath); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); + if (isVolatileAdeRuntimePath(normalized)) continue; if (isAlwaysIgnoredPath(normalized)) continue; const segments = normalized.split("/"); @@ -384,16 +456,16 @@ export function createFileService({ })); }; - const getGitStatusMap = async (rootPath: string): Promise> => { + const getGitStatusSnapshot = async (rootPath: string): Promise => { const cached = gitStatusCache.get(rootPath); const now = Date.now(); if (cached && now - cached.fetchedAt <= GIT_STATUS_CACHE_TTL_MS) { - return cached.map; + return cached.snapshot; } const res = await runGit(["status", "--porcelain=v1"], { cwd: rootPath, timeoutMs: 10_000 }); const out = new Map(); - if (res.exitCode !== 0) return out; + if (res.exitCode !== 0) return buildGitStatusSnapshot(out); const lines = res.stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean); for (const line of lines) { const code = line.slice(0, 2); @@ -414,14 +486,16 @@ export function createFileService({ else if (combined.length) out.set(normalized, "M"); else out.set(normalized, null); } - gitStatusCache.set(rootPath, { fetchedAt: now, map: out }); - return out; + const snapshot = buildGitStatusSnapshot(out); + gitStatusCache.set(rootPath, { fetchedAt: now, snapshot }); + return snapshot; }; const isIgnoredPath = async (rootPath: string, relPath: string, includeIgnored: boolean): Promise => { - if (includeIgnored) return false; const normalized = normalizeRelative(relPath); if (!normalized) return false; + if (isVolatileAdeRuntimePath(normalized)) return true; + if (includeIgnored) return false; if (isAlwaysIgnoredPath(normalized)) return true; const keyPrefix = `${rootPath}::`; @@ -446,14 +520,14 @@ export function createFileService({ parentPath, depth, includeIgnored, - statusMap + statusSnapshot }: { rootPath: string; parentPath: string; depth: number; includeIgnored: boolean; - statusMap: Map; - }): Promise => { + statusSnapshot: GitStatusSnapshot; + }): Promise<{ children: FileTreeNode[]; truncated: boolean }> => { const { absPath: dirPath } = ensureSafePath(rootPath, parentPath); const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const entryPaths = entries.map((entry) => normalizeRelative(path.join(parentPath, entry.name))); @@ -465,8 +539,14 @@ export function createFileService({ }); const out: FileTreeNode[] = []; + let truncated = false; for (const entry of entries) { + if (out.length >= MAX_TREE_CHILDREN_PER_DIRECTORY) { + truncated = true; + break; + } const rel = normalizeRelative(path.join(parentPath, entry.name)); + if (isVolatileAdeRuntimePath(rel)) continue; if (await isIgnoredPath(rootPath, rel, includeIgnored)) continue; if (entry.name === ".git") continue; @@ -474,22 +554,24 @@ export function createFileService({ name: entry.name, path: rel, type: entry.isDirectory() ? "directory" : "file", - changeStatus: statusMap.get(rel) ?? null + changeStatus: statusSnapshot.fileStatus.get(rel) ?? null }; if (entry.isDirectory()) { if (!node.changeStatus) { - node.changeStatus = inferDirectoryStatus(statusMap, rel); + node.changeStatus = inferDirectoryStatus(statusSnapshot, rel); } if (depth > 1) { - node.children = await listTreeNode({ + const sub = await listTreeNode({ rootPath, parentPath: rel, depth: depth - 1, includeIgnored, - statusMap + statusSnapshot }); + node.children = sub.children; + if (sub.truncated) node.childrenTruncated = true; if (!node.changeStatus && node.children.some((child) => child.changeStatus)) { node.changeStatus = "M"; } @@ -498,7 +580,7 @@ export function createFileService({ out.push(node); } - return out; + return { children: out, truncated }; }; return { @@ -523,14 +605,15 @@ export function createFileService({ const workspace = resolveWorkspace(args.workspaceId); const depth = Number.isFinite(args.depth) ? Math.max(1, Math.min(8, Math.floor(args.depth ?? 1))) : 1; const parentPath = normalizeRelative(args.parentPath ?? ""); - const statusMap = await getGitStatusMap(workspace.rootPath); - return await listTreeNode({ + const statusSnapshot = await getGitStatusSnapshot(workspace.rootPath); + const result = await listTreeNode({ rootPath: workspace.rootPath, parentPath, depth, includeIgnored: Boolean(args.includeIgnored), - statusMap + statusSnapshot }); + return result.children; }, readFile(args: FilesReadFileArgs): FileContent { @@ -540,14 +623,18 @@ export function createFileService({ if (!stat.isFile()) { throw new Error("Path is not a file."); } - if (stat.size > MAX_EDITOR_READ_BYTES) { - throw new Error( - `Refusing to open files larger than ${Math.round(MAX_EDITOR_READ_BYTES / (1024 * 1024))}MB in the editor.` - ); - } - const buf = fs.readFileSync(absPath); const imageMimeType = inferImageMimeType(normalizedRel); if (imageMimeType) { + if (stat.size > MAX_INLINE_IMAGE_PREVIEW_BYTES) { + return omittedFileContent({ + relPath: normalizedRel, + size: stat.size, + encoding: "base64", + mimeType: imageMimeType, + reason: "too_large", + }); + } + const buf = fs.readFileSync(absPath); const base64 = buf.toString("base64"); return { content: base64, @@ -557,17 +644,48 @@ export function createFileService({ isBinary: true, previewKind: "image", mimeType: imageMimeType, - dataUrl: `data:${imageMimeType};base64,${base64}`, }; } - const isBinary = looksLikeBinary(buf, normalizedRel); + const sample = readFilePrefix(absPath, Math.min(stat.size, 8192)); + const isBinary = looksLikeBinary(sample, normalizedRel); + if (isBinary) { + if (stat.size > MAX_INLINE_BINARY_BYTES) { + return omittedFileContent({ + relPath: normalizedRel, + size: stat.size, + encoding: "base64", + mimeType: "application/octet-stream", + reason: "unsupported_binary", + }); + } + const buf = fs.readFileSync(absPath); + return { + content: buf.toString("base64"), + encoding: "base64", + size: stat.size, + languageId: languageIdFromPath(normalizedRel), + isBinary: true, + previewKind: "binary", + mimeType: "application/octet-stream", + }; + } + if (stat.size > MAX_EDITOR_TEXT_READ_BYTES) { + return omittedFileContent({ + relPath: normalizedRel, + size: stat.size, + encoding: "utf-8", + mimeType: null, + reason: "too_large", + }); + } + const buf = fs.readFileSync(absPath); return { - content: isBinary ? buf.toString("base64") : buf.toString("utf8"), - encoding: isBinary ? "base64" : "utf-8", + content: buf.toString("utf8"), + encoding: "utf-8", size: stat.size, languageId: languageIdFromPath(normalizedRel), - isBinary, - previewKind: isBinary ? "binary" : "text", + isBinary: false, + previewKind: "text", mimeType: null, }; }, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 46da2977a..280fdc052 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -14,7 +14,7 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import { browseProjectDirectories } from "../projects/projectBrowserService"; import { getProjectDetail } from "../projects/projectDetailService"; -import { removeProjectIconOverride, resolveProjectIcon, setProjectIconOverride } from "../projects/projectIconResolver"; +import { removeProjectIconOverride, resolveProjectIcon, setProjectIconOverrideFromSelection } from "../projects/projectIconResolver"; import { runGit } from "../git/git"; import type { AdeCleanupResult, AdeProjectSnapshot, IosSimulatorWindowState } from "../../../shared/types"; import { toRecentProjectSummary } from "../projects/recentProjectSummary"; @@ -568,7 +568,11 @@ import type { createAutoRebaseService } from "../lanes/autoRebaseService"; import type { createSessionService } from "../sessions/sessionService"; import type { SessionDeltaService } from "../sessions/sessionDeltaService"; import type { createPtyService } from "../pty/ptyService"; -import type { createDiffService } from "../diffs/diffService"; +import { + type createDiffService, + MAX_DIFF_SIDE_TEXT_BYTES, + appendDiffTruncationNotice, +} from "../diffs/diffService"; import type { createFileService } from "../files/fileService"; import { mergeAiConfig, type createProjectConfigService } from "../config/projectConfigService"; import type { createProcessService } from "../processes/processService"; @@ -3227,12 +3231,10 @@ export function registerIpc({ const selectedPath = result.filePaths[0]; if (!selectedPath) return null; try { - return setProjectIconOverride(validatedRoot, selectedPath); + return setProjectIconOverrideFromSelection(validatedRoot, selectedPath); } catch (error) { - // setProjectIconOverride throws when the picked file is outside the - // project root or has an unsupported extension. Surface the message - // so the renderer can display a meaningful error instead of a - // silently rejected promise. + // Surface validation/import failures so the renderer can display a + // meaningful error instead of a silently rejected promise. const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to set project icon: ${message}`); } @@ -5898,14 +5900,32 @@ export function registerIpc({ const cwd = ctx.project?.rootPath; if (!cwd) throw new Error("No project root"); const lang = arg.filePath.split(".").pop() ?? undefined; - const origResult = await runGit(["show", `${arg.beforeSha}:${arg.filePath}`], { cwd, timeoutMs: 10_000 }).catch(() => ({ stdout: "", exitCode: 1 })); - const modResult = await runGit(["show", `${arg.afterSha}:${arg.filePath}`], { cwd, timeoutMs: 10_000 }).catch(() => ({ stdout: "", exitCode: 1 })); + const maxSideBytes = MAX_DIFF_SIDE_TEXT_BYTES; + const readSide = async (spec: string): Promise<{ exists: boolean; text: string; isTruncated?: boolean; isBinary?: boolean }> => { + const result = await runGit(["show", spec], { + cwd, + timeoutMs: 10_000, + maxOutputBytes: maxSideBytes + 64 * 1024, + }); + if (result.exitCode !== 0) return { exists: false, text: "" }; + const buf = Buffer.from(result.stdout, "utf8"); + if (buf.includes(0)) return { exists: true, text: "", isBinary: true }; + if (buf.length <= maxSideBytes) return { exists: true, text: result.stdout }; + return { + exists: true, + text: appendDiffTruncationNotice(buf.subarray(0, maxSideBytes).toString("utf8")), + isTruncated: true, + }; + }; + const origResult = await readSide(`${arg.beforeSha}:${arg.filePath}`); + const modResult = await readSide(`${arg.afterSha}:${arg.filePath}`); return { path: arg.filePath, mode: "commit", language: lang, - original: { text: origResult.exitCode === 0 ? origResult.stdout : null }, - modified: { text: modResult.exitCode === 0 ? modResult.stdout : null }, + original: origResult, + modified: modResult, + ...(origResult.isBinary || modResult.isBinary ? { isBinary: true } : {}), }; }); @@ -6566,13 +6586,22 @@ export function registerIpc({ ipcMain.handle(IPC.diffGetFile, async (_event, arg: GetFileDiffArgs) => { const ctx = getCtx(); - return await ctx.diffService.getFileDiff({ - laneId: arg.laneId, - filePath: arg.path, - mode: arg.mode, - compareRef: arg.compareRef, - compareTo: arg.compareTo - }); + return await withIpcTiming( + ctx, + "diff.getFile", + async () => await ctx.diffService.getFileDiff({ + laneId: arg.laneId, + filePath: arg.path, + mode: arg.mode, + compareRef: arg.compareRef, + compareTo: arg.compareTo + }), + { + laneId: arg.laneId, + mode: arg.mode, + pathLength: arg.path.length, + } + ); }); ipcMain.handle(IPC.filesWriteTextAtomic, async (_event, arg: WriteTextAtomicArgs): Promise => { @@ -6601,7 +6630,15 @@ export function registerIpc({ ipcMain.handle(IPC.filesReadFile, async (_event, arg: FilesReadFileArgs): Promise => { const ctx = getCtx(); - return ctx.fileService.readFile(arg); + return await withIpcTiming( + ctx, + "files.readFile", + async () => ctx.fileService.readFile(arg), + { + workspaceId: arg.workspaceId, + pathLength: arg.path.length, + } + ); }); ipcMain.handle(IPC.filesWriteText, async (_event, arg: FilesWriteTextArgs): Promise => { diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts index a1fd20a8b..85c60d67c 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -3,7 +3,13 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { removeProjectIconOverride, resolveProjectIcon, resolveProjectIconPath, setProjectIconOverride } from "./projectIconResolver"; +import { + removeProjectIconOverride, + resolveProjectIcon, + resolveProjectIconPath, + setProjectIconOverride, + setProjectIconOverrideFromSelection, +} from "./projectIconResolver"; function makeProjectRoot(): string { // Resolve through realpath so the assertions still hold on platforms @@ -43,6 +49,56 @@ describe("projectIconResolver", () => { expect(resolveProjectIconPath(root)).toBe(iconPath); }); + it("detects iOS app icons from asset catalogs", () => { + const root = makeProjectRoot(); + writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/Contents.json", JSON.stringify({ + images: [ + { filename: "Icon-App-20x20@2x.png", idiom: "iphone", size: "20x20", scale: "2x" }, + { filename: "Icon-App-1024x1024@1x.png", idiom: "ios-marketing", size: "1024x1024", scale: "1x" }, + ], + info: { author: "xcode", version: 1 }, + })); + writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png", Buffer.from("small")); + const iconPath = writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png", Buffer.from("large")); + + expect(resolveProjectIconPath(root)).toBe(iconPath); + }); + + it("detects iOS asset catalogs nested below an app folder", () => { + const root = makeProjectRoot(); + writeFile(root, "apps/mobile/ios/MyApp/Assets.xcassets/AppIcon.appiconset/Contents.json", JSON.stringify({ + images: [{ filename: "AppIcon-1024.png", idiom: "ios-marketing", size: "1024x1024", scale: "1x" }], + info: { author: "xcode", version: 1 }, + })); + const iconPath = writeFile(root, "apps/mobile/ios/MyApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png", Buffer.from("large")); + + expect(resolveProjectIconPath(root)).toBe(iconPath); + }); + + it("falls back to a renderable iOS brand image when the app icon is too large", () => { + const root = makeProjectRoot(); + writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/Contents.json", JSON.stringify({ + images: [{ filename: "icon.png", idiom: "universal", size: "1024x1024" }], + info: { author: "xcode", version: 1 }, + })); + writeFile(root, "apps/ios/ADE/Assets.xcassets/AppIcon.appiconset/icon.png", Buffer.alloc(1024 * 1024 + 1)); + writeFile(root, "apps/ios/ADE/Assets.xcassets/BrandMark.imageset/Contents.json", JSON.stringify({ + images: [{ filename: "logo.png", idiom: "universal", scale: "1x" }], + info: { author: "xcode", version: 1 }, + })); + const brandPath = writeFile(root, "apps/ios/ADE/Assets.xcassets/BrandMark.imageset/logo.png", Buffer.from("brand")); + + expect(resolveProjectIconPath(root)).toBe(brandPath); + }); + + it("skips overlarge source-linked icons during auto-detection", () => { + const root = makeProjectRoot(); + writeFile(root, "index.html", ''); + writeFile(root, "public/brand/logo.png", Buffer.alloc(1024 * 1024 + 1)); + + expect(resolveProjectIconPath(root)).toBeNull(); + }); + it("uses a tracked project icon override before auto-detection", () => { const root = makeProjectRoot(); writeFile(root, "apps/web/app/icon.png", Buffer.from("auto")); @@ -62,6 +118,25 @@ describe("projectIconResolver", () => { expect(fs.readFileSync(path.join(root, ".ade", "ade.yaml"), "utf8")).toContain("iconPath: assets/icon.svg"); }); + it("imports selected icons from outside the project root", () => { + const root = makeProjectRoot(); + const outside = makeProjectRoot(); + const iconPath = writeFile(outside, "brand.png", Buffer.from("png")); + + const icon = setProjectIconOverrideFromSelection(root, iconPath); + + expect(icon.sourcePath).toContain(path.join(root, ".ade", "project-icons")); + expect(fs.existsSync(icon.sourcePath ?? "")).toBe(true); + expect(fs.readFileSync(path.join(root, ".ade", "ade.yaml"), "utf8")).toMatch(/iconPath: \.ade\/project-icons\/brand-[a-f0-9]{12}\.png/); + }); + + it("rejects selected icons that are too large to render", () => { + const root = makeProjectRoot(); + const iconPath = writeFile(root, "assets/icon.png", Buffer.alloc(1024 * 1024 + 1)); + + expect(() => setProjectIconOverride(root, iconPath)).toThrow("Project icon must be 1 MB or smaller."); + }); + it("can explicitly disable automatic icon detection", () => { const root = makeProjectRoot(); writeFile(root, "apps/web/app/icon.png", Buffer.from("auto")); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts index f7f830dfa..df362442d 100644 --- a/apps/desktop/src/main/services/projects/projectIconResolver.ts +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -1,12 +1,14 @@ import fs from "node:fs"; import path from "node:path"; +import { createHash } from "node:crypto"; import YAML from "yaml"; import type { ProjectIcon } from "../../../shared/types"; -import { resolvePathWithinRoot } from "../shared/utils"; +import { isWithinDir, resolvePathWithinRoot } from "../shared/utils"; const ICON_MAX_BYTES = 1024 * 1024; const SUPPORTED_ICON_EXTENSIONS = new Set([".svg", ".ico", ".png", ".jpg", ".jpeg", ".webp"]); +const IMPORTED_PROJECT_ICON_DIR = ".ade/project-icons"; const IGNORED_ICON_DIRS = new Set([ ".ade", @@ -57,6 +59,13 @@ const ICON_SOURCE_FILES = [ "src/index.html", ] as const; +const IOS_ASSET_CATALOG_CANDIDATE_DIRS = [ + "Assets.xcassets", + "Resources/Assets.xcassets", +] as const; + +const IOS_ASSET_ICONSET_EXTENSIONS = new Set([".appiconset", ".imageset"]); + const LINK_ICON_HTML_RE = /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; const LINK_ICON_OBJ_RE = @@ -109,6 +118,12 @@ function isSupportedIconPath(filePath: string): boolean { return SUPPORTED_ICON_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +function realpathExisting(filePath: string): string { + return typeof fs.realpathSync.native === "function" + ? fs.realpathSync.native(filePath) + : fs.realpathSync(filePath); +} + function toProjectRelative(projectRoot: string, filePath: string): string { const relative = path.relative(projectRoot, filePath).split(path.sep).join("/"); return relative || "."; @@ -312,9 +327,124 @@ function buildDetectedIconCandidates(projectRoot: string): string[] { } } } + for (const discovered of discoverIosAssetCatalogIconFiles(projectRoot)) { + candidates.add(discovered); + } return Array.from(candidates); } +function assetCatalogCandidateRoots(projectRoot: string): string[] { + const candidates = new Set(); + const addCatalogCandidates = (root: string) => { + for (const candidateDir of IOS_ASSET_CATALOG_CANDIDATE_DIRS) { + candidates.add(root === "." ? candidateDir : path.posix.join(root, candidateDir)); + } + }; + const addNestedCatalogCandidates = (root: string, remainingDepth: number) => { + addCatalogCandidates(root); + if (remainingDepth <= 0) return; + for (const child of listSubdirectories(projectRoot, root)) { + addNestedCatalogCandidates(child, remainingDepth - 1); + } + }; + + for (const root of iconSearchRoots(projectRoot)) { + addNestedCatalogCandidates(root, 2); + } + + const existing: string[] = []; + for (const candidate of candidates) { + let resolved: string; + try { + resolved = resolvePathWithinRoot(projectRoot, candidate, { allowMissing: false }); + } catch { + continue; + } + try { + if (fs.statSync(resolved).isDirectory()) existing.push(candidate); + } catch { + // Keep probing. + } + } + return existing; +} + +function isLikelyIosAssetIconSet(dirName: string): boolean { + const ext = path.extname(dirName).toLowerCase(); + if (!IOS_ASSET_ICONSET_EXTENSIONS.has(ext)) return false; + if (ext === ".appiconset") return true; + + const base = path.basename(dirName, ext).toLowerCase(); + return base.includes("icon") + || base.includes("logo") + || base.includes("brand") + || base.includes("mark"); +} + +function assetContentsFilenames(assetSetPath: string): string[] { + const contentsPath = path.join(assetSetPath, "Contents.json"); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(contentsPath, "utf8")); + } catch { + return []; + } + const images = parsed && typeof parsed === "object" && "images" in parsed + ? (parsed as { images?: unknown }).images + : undefined; + if (!Array.isArray(images)) return []; + + return images + .map((entry) => { + if (!entry || typeof entry !== "object" || !("filename" in entry)) return null; + const filename = (entry as { filename?: unknown }).filename; + return typeof filename === "string" ? filename.trim() : null; + }) + .filter((filename): filename is string => + !!filename + && !path.isAbsolute(filename) + && !filename.includes("/") + && !filename.includes("\\") + && isSupportedIconPath(filename) + ); +} + +function discoverIosAssetCatalogIconFiles(projectRoot: string): string[] { + const candidates: string[] = []; + for (const catalogDir of assetCatalogCandidateRoots(projectRoot)) { + let catalogPath: string; + try { + catalogPath = resolvePathWithinRoot(projectRoot, catalogDir, { allowMissing: false }); + } catch { + continue; + } + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(catalogPath, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + if (!entry.isDirectory() || !isLikelyIosAssetIconSet(entry.name)) continue; + const relativeSetDir = path.posix.join(catalogDir, entry.name); + const assetSetPath = path.join(catalogPath, entry.name); + const filenames = assetContentsFilenames(assetSetPath); + if (filenames.length > 0) { + for (const filename of filenames) { + candidates.push(path.posix.join(relativeSetDir, filename)); + } + continue; + } + for (const discovered of discoverDirectoryIconFiles(projectRoot, relativeSetDir)) { + candidates.push(discovered); + } + } + } + return candidates; +} + function scoreIconCandidate(projectRoot: string, filePath: string): number { const relativePath = toProjectRelative(projectRoot, filePath); const normalized = relativePath.toLowerCase(); @@ -330,10 +460,27 @@ function scoreIconCandidate(projectRoot: string, filePath: string): number { if (normalized.includes("/app/") || normalized.includes("/src/app/")) score += 16; if (normalized.includes("/assets/")) score += 14; + if (normalized.includes("/assets.xcassets/")) score += 24; + if (normalized.includes(".appiconset/")) score += 90; + if (normalized.includes("/appicon.appiconset/")) score += 35; + if (normalized.includes(".imageset/")) score += 18; + if (normalized.includes("/brandmark.imageset/")) score += 30; if (normalized.includes("/public/")) score += 8; if (normalized.includes("/apps/desktop/build/")) score += 20; if (normalized.includes("/docs/") || normalized.includes("/mintlify/")) score -= 30; + const pixelMatch = normalized.match(/(?:^|[-_])(\d{2,4})x(\d{2,4})(?:@(\d)x)?/); + if (pixelMatch) { + const width = Number(pixelMatch[1]); + const height = Number(pixelMatch[2]); + const scale = pixelMatch[3] ? Number(pixelMatch[3]) : 1; + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) { + const displayEdge = Math.sqrt(width * height) * (Number.isFinite(scale) && scale > 0 ? scale : 1); + score += Math.min(44, Math.round(displayEdge / 24)); + } + } + if (normalized.includes("1024")) score += 18; + switch (path.extname(normalized)) { case ".png": score += 8; @@ -354,11 +501,20 @@ function scoreIconCandidate(projectRoot: string, filePath: string): number { return score - depth; } +function isInlineableIconFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + return stat.isFile() && stat.size <= ICON_MAX_BYTES; + } catch { + return false; + } +} + function findBestDetectedIcon(projectRoot: string): string | null { const matches: string[] = []; for (const candidate of buildDetectedIconCandidates(projectRoot)) { const match = findExistingFile(projectRoot, [candidate]); - if (match && isSupportedIconPath(match)) matches.push(match); + if (match && isSupportedIconPath(match) && isInlineableIconFile(match)) matches.push(match); } matches.sort((a, b) => { const delta = scoreIconCandidate(projectRoot, b) - scoreIconCandidate(projectRoot, a); @@ -402,7 +558,7 @@ export function resolveProjectIconPath( const href = extractIconHref(source); if (!href || !isLocalIconHref(href)) continue; const existing = findExistingFile(root, resolveIconHref(root, href)); - if (existing && isSupportedIconPath(existing)) return existing; + if (existing && isSupportedIconPath(existing) && isInlineableIconFile(existing)) return existing; } return null; @@ -465,11 +621,7 @@ function writeProjectIconPathOverride(projectRoot: string, iconPath: string | nu export function setProjectIconOverride(projectRoot: string, iconPath: string): ProjectIcon { const root = path.resolve(projectRoot); const resolvedIconPath = resolvePathWithinRoot(root, iconPath, { allowMissing: false }); - const stat = fs.statSync(resolvedIconPath); - if (!stat.isFile()) throw new Error("Project icon must be a file."); - if (!isSupportedIconPath(resolvedIconPath)) { - throw new Error("Project icon must be an ico, jpg, png, svg, or webp file."); - } + assertUsableProjectIconFile(resolvedIconPath); const relativeIconPath = toProjectRelative(root, resolvedIconPath); writeProjectIconPathOverride(root, relativeIconPath); @@ -477,6 +629,61 @@ export function setProjectIconOverride(projectRoot: string, iconPath: string): P return resolveProjectIcon(root, { iconPathOverride: relativeIconPath }); } +function assertUsableProjectIconFile(iconPath: string): void { + const stat = fs.statSync(iconPath); + if (!stat.isFile()) throw new Error("Project icon must be a file."); + if (!isSupportedIconPath(iconPath)) { + throw new Error("Project icon must be an ico, jpg, png, svg, or webp file."); + } + if (stat.size > ICON_MAX_BYTES) { + throw new Error("Project icon must be 1 MB or smaller."); + } +} + +function importedProjectIconRelativePath(sourcePath: string, data: Buffer): string { + const ext = path.extname(sourcePath).toLowerCase(); + const rawBase = path.basename(sourcePath, path.extname(sourcePath)); + const safeBase = rawBase + .trim() + .replace(/[^a-zA-Z0-9._-]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) + || "icon"; + const hash = createHash("sha256").update(data).digest("hex").slice(0, 12); + return path.posix.join(IMPORTED_PROJECT_ICON_DIR, `${safeBase}-${hash}${ext}`); +} + +export function setProjectIconOverrideFromSelection(projectRoot: string, iconPath: string): ProjectIcon { + const root = path.resolve(projectRoot); + const selectedPath = realpathExisting(path.resolve(iconPath)); + assertUsableProjectIconFile(selectedPath); + + const rootReal = realpathExisting(root); + if (isWithinDir(rootReal, selectedPath)) { + return setProjectIconOverride(root, selectedPath); + } + + const data = fs.readFileSync(selectedPath); + // TOCTOU safety net: file may have grown between assertUsableProjectIconFile's stat and this read. + if (data.length > ICON_MAX_BYTES) { + throw new Error("Project icon must be 1 MB or smaller."); + } + const relativeImportPath = importedProjectIconRelativePath(selectedPath, data); + const importDir = resolvePathWithinRoot(root, IMPORTED_PROJECT_ICON_DIR, { allowMissing: true }); + fs.mkdirSync(importDir, { recursive: true }); + const importPath = resolvePathWithinRoot(root, relativeImportPath, { allowMissing: true }); + try { + fs.writeFileSync(importPath, data, { flag: "wx", mode: 0o644 }); + } catch (error) { + const code = error && typeof error === "object" && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (code !== "EEXIST") throw error; + } + + return setProjectIconOverride(root, relativeImportPath); +} + export function removeProjectIconOverride(projectRoot: string): ProjectIcon { const root = path.resolve(projectRoot); writeProjectIconPathOverride(root, null); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index efb52c5f0..a95ac4415 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -1750,6 +1750,39 @@ describe("ptyService", () => { vi.useRealTimers(); } }); + + it("stops scanning output for a late-printed resume command after 60 seconds and ignores matches in stale buffers", async () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(new Date("2026-04-29T00:00:00.000Z")); + mocks.defaultResumeCommandForTool.mockReturnValue("claude --resume" as any); + const { service, mockPty, sessionService } = createHarness(); + const { sessionId } = await service.create({ + laneId: "lane-1", + title: "Claude CLI", + cols: 80, + rows: 24, + toolType: "claude", + }); + + mockPty._emitter.emit("data", "boot output\n"); + expect(mocks.extractResumeCommandFromOutput).toHaveBeenCalled(); + mocks.extractResumeCommandFromOutput.mockClear(); + (sessionService.setResumeCommand as ReturnType).mockClear(); + + vi.setSystemTime(new Date("2026-04-29T00:01:00.500Z")); + mocks.extractResumeCommandFromOutput.mockReturnValue("claude --resume claude-late-session" as any); + mockPty._emitter.emit("data", "claude --resume claude-late-session\n"); + + expect(mocks.extractResumeCommandFromOutput).not.toHaveBeenCalled(); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith( + sessionId, + "claude --resume claude-late-session", + ); + } finally { + vi.useRealTimers(); + } + }); }); describe("ensureResumeTargets", () => { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 46c8d663c..ecb392570 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -327,6 +327,7 @@ function inferSessionCwdFromTranscriptPath(transcriptPath: string | null | undef const MAX_TRANSCRIPT_BYTES = 8 * 1024 * 1024; const TRANSCRIPT_LIMIT_NOTICE = "\n[ADE] transcript limit reached (8MB). Further output omitted.\n"; const RESUME_TARGET_MISSING_COOLDOWN_MS = 10 * 60_000; +const RESUME_SCAN_WINDOW_MS = 60_000; export function createPtyService({ projectRoot, @@ -1806,7 +1807,15 @@ export function createPtyService({ ); } - if (!entry.resumeCommand || entry.resumeCommandIsFallback) { + // Resume-command scanning runs an ANSI strip + 2 regex passes over a + // 12KB rolling buffer on every output chunk. Claude/codex print the + // resume command near startup, so cap the window — long-running + // sessions otherwise pay this cost forever. Storage-based backfill + // (tryBackfillResumeTarget) covers sessions that never print one. + if ( + (!entry.resumeCommand || entry.resumeCommandIsFallback) + && Date.now() - entry.createdAt < RESUME_SCAN_WINDOW_MS + ) { entry.resumeScanBuffer = `${entry.resumeScanBuffer}${data}`.slice(-12_000); const detected = extractResumeCommandFromOutput(entry.resumeScanBuffer, entry.toolTypeHint); if (detected && detected !== entry.resumeCommand) { @@ -1814,6 +1823,8 @@ export function createPtyService({ entry.resumeCommandIsFallback = false; sessionService.setResumeCommand(sessionId, detected); } + } else if (entry.resumeScanBuffer.length > 0) { + entry.resumeScanBuffer = ""; } // Accumulate initial output for session title generation diff --git a/apps/desktop/src/renderer/components/app/TopBar.test.tsx b/apps/desktop/src/renderer/components/app/TopBar.test.tsx index 942fe38d5..695390b51 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.test.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.test.tsx @@ -204,4 +204,17 @@ describe("TopBar", () => { expect(await screen.findByText("1 phone connected")).toBeTruthy(); }); + + it("shows project icon replacement errors", async () => { + globalThis.window.ade.project.chooseIcon = vi.fn(async () => { + throw new Error("Failed to set project icon: Project icon must be 1 MB or smaller."); + }) as any; + + render(); + + fireEvent.click(await screen.findByLabelText("Project icon")); + fireEvent.click(await screen.findByText("Replace")); + + expect((await screen.findByRole("alert")).textContent).toContain("Project icon must be 1 MB or smaller."); + }); }); diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 5a6185372..497a0be94 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -69,6 +69,15 @@ function syncDotClass(snapshot: SyncRoleSnapshot): string { return "ade-status-dot-warning"; } +function projectIconErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + const cleaned = raw + .replace(/^Error invoking remote method '[^']+':\s*/i, "") + .replace(/^Error:\s*/i, "") + .trim(); + return cleaned || "Failed to update project icon."; +} + function deriveSyncLabel(snapshot: SyncRoleSnapshot | null): string | null { if (!snapshot) return null; if (snapshot.client.state === "error") return "Phone sync error"; @@ -108,6 +117,7 @@ function ProjectTabIcon({ const [iconDialogOpen, setIconDialogOpen] = useState(false); const [choosing, setChoosing] = useState(false); const [removing, setRemoving] = useState(false); + const [iconError, setIconError] = useState(null); useEffect(() => { setFailed(false); @@ -166,16 +176,22 @@ function ProjectTabIcon({ const handleChooseIcon = useCallback(async () => { if (disabled || choosing) return; setChoosing(true); + setIconError(null); try { const nextIcon = await window.ade.project.chooseIcon(rootPath); if (nextIcon) { setProjectIconCache(rootPath, nextIcon); setFailed(false); setIcon(nextIcon); - setIconDialogOpen(false); + if (nextIcon.dataUrl) { + setIconDialogOpen(false); + } else { + setIconError("ADE saved the path, but the image could not be rendered as a project icon."); + } } - } catch { - // Keep the current icon; the dialog path is best-effort UI chrome. + } catch (error) { + // Keep the current icon while surfacing why replacement failed. + setIconError(projectIconErrorMessage(error)); } finally { setChoosing(false); } @@ -184,14 +200,16 @@ function ProjectTabIcon({ const handleRemoveIcon = useCallback(async () => { if (disabled || removing) return; setRemoving(true); + setIconError(null); try { const nextIcon = await window.ade.project.removeIcon(rootPath); setProjectIconCache(rootPath, nextIcon); setFailed(false); setIcon(nextIcon); setIconDialogOpen(false); - } catch { - // Keep the current icon. + } catch (error) { + // Keep the current icon while surfacing why removal failed. + setIconError(projectIconErrorMessage(error)); } finally { setRemoving(false); } @@ -200,7 +218,13 @@ function ProjectTabIcon({ if (disabled) return iconNode; return ( - + { + setIconDialogOpen(open); + if (!open) setIconError(null); + }} + > + + + ) : null} + + + + + + ); const handleStopSimulator = useCallback(() => { if (typeof window === "undefined") return; @@ -2780,8 +3010,8 @@ export function ChatIosSimulatorPanel({ }, [shutdownSimulator]); return ( -
-
+
+
) : showLaunchProgress ? (
@@ -3377,38 +3612,25 @@ export function ChatIosSimulatorPanel({ Open in preview - {liveVisual.kind === "window" ? ( -
) : mode === "inspect" && snapshotRefreshing && !snapshotImage ? (
@@ -3432,14 +3672,6 @@ export function ChatIosSimulatorPanel({ onPointerUp={simulatorCaptureActive ? finishSimulatorCapture : undefined} onPointerCancel={simulatorCaptureActive ? cancelSimulatorCapture : undefined} > - {snapshotImage.alt}
event.stopPropagation()} @@ -3520,56 +3752,68 @@ export function ChatIosSimulatorPanel({ Open in preview - {mode === "inspect" && bounds && snapshot ? inspectOverlayElements.map((element, index) => { - const frame = clampFrame( - element.pixelFrame, - snapshot.screenshot.width ?? snapshot.screen.width, - snapshot.screenshot.height ?? snapshot.screen.height, - ); - const isHovered = hoveredElement?.id === element.id; - const isSelected = selectedElement?.id === element.id; - return ( -
MEDIA_ZOOM_MIN ? "overflow-auto" : "overflow-hidden")}> +
+ {snapshotImage.alt} - ); - }) : null} - {mode === "inspect" && simulatorCaptureActive && simulatorCaptureSelection && activeSimulatorCaptureFrame ? ( -
- ) : null} - {mode === "inspect" && bounds && hoveredElement ? ( -
- {elementLabel(hoveredElement)} - {hoverSource} + {mode === "inspect" && bounds && snapshot ? inspectOverlayElements.map((element, index) => { + const frame = clampFrame( + element.pixelFrame, + snapshot.screenshot.width ?? snapshot.screen.width, + snapshot.screenshot.height ?? snapshot.screen.height, + ); + const isHovered = hoveredElement?.id === element.id; + const isSelected = selectedElement?.id === element.id; + return ( +
+ ); + }) : null} + {mode === "inspect" && simulatorCaptureActive && simulatorCaptureSelection && activeSimulatorCaptureFrame ? ( +
+ ) : null} + {mode === "inspect" && bounds && hoveredElement ? ( +
+ {elementLabel(hoveredElement)} + {hoverSource} +
+ ) : null}
- ) : null} +
{snapshotRefreshing ? (
@@ -3578,6 +3822,7 @@ export function ChatIosSimulatorPanel({
) : null} + {mediaViewToolbar}
) : (
@@ -3598,7 +3843,7 @@ export function ChatIosSimulatorPanel({ )}
-
+ {!mediaExpanded ?
{mode === "interact" && !ownedByOtherChat && !showSetupChecklist ? (
@@ -3636,7 +3881,7 @@ export function ChatIosSimulatorPanel({ Install a supported full Xcode for native touch input, or idb + idb_companion for fallback tap, drag, and text.
) : null} -
+
: null}
); } diff --git a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx index a6c83df9f..6d994a05c 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.test.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.test.tsx @@ -406,6 +406,29 @@ describe("FilesPage", () => { expect(screen.queryByTestId("mock-monaco-editor")).toBeNull(); }); + it("does not start Monaco for omitted oversized file payloads", async () => { + fileReadOverrides["dist/large.bundle.js"] = { + content: "", + encoding: "utf-8", + size: 1024 * 1024 + 1, + languageId: "javascript", + isBinary: true, + previewKind: "binary", + mimeType: null, + contentOmitted: true, + omittedReason: "too_large", + }; + + renderFilesPage({ + openFilePath: "dist/large.bundle.js", + preferPrimaryWorkspace: true, + }); + + expect(await screen.findByText(/PREVIEW UNAVAILABLE/i)).toBeTruthy(); + expect(screen.getByText(/too large to display inline/i)).toBeTruthy(); + expect(screen.queryByTestId("mock-monaco-editor")).toBeNull(); + }); + it("remaps clean open tabs when files are renamed", async () => { renderFilesPage({ openFilePath: "src/index.ts", diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 8c9b8fb27..e3ab3f386 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -54,14 +54,14 @@ type OpenTab = { isBinary: boolean; previewKind?: FilePreviewKind; mimeType?: string | null; - dataUrl?: string; size?: number; + contentOmitted?: boolean; + omittedReason?: FileContent["omittedReason"]; }; type FilePreviewLike = { isBinary: boolean; previewKind?: FilePreviewKind; - dataUrl?: string; mimeType?: string | null; size?: number; }; @@ -69,7 +69,7 @@ type FilePreviewLike = { function getFilePreviewKind(file: FilePreviewLike | null | undefined): FilePreviewKind { if (!file) return "text"; if (file.previewKind) return file.previewKind; - if (file.dataUrl) return "image"; + if (file.mimeType?.startsWith("image/") && file.isBinary) return "image"; return file.isBinary ? "binary" : "text"; } @@ -93,8 +93,9 @@ function openTabFromFileContent(filePath: string, loaded: FileContent): OpenTab isBinary: loaded.isBinary, previewKind: getFilePreviewKind(loaded), mimeType: loaded.mimeType ?? null, - dataUrl: loaded.dataUrl, size: loaded.size, + contentOmitted: loaded.contentOmitted, + omittedReason: loaded.omittedReason, }; } @@ -159,6 +160,8 @@ type FilesPageSessionState = { const filesPageSessionByScope = new Map(); const filesPageSessionLru: string[] = []; const MAX_FILES_PAGE_CACHED_SCOPES = 8; +const MAX_CACHED_CLEAN_TAB_CHARS = 256 * 1024; +const MAX_CACHED_DIRTY_TAB_CHARS = 8 * 1024 * 1024; const MAX_QUEUED_TREE_PARENT_REFRESHES = 24; const FILES_WATCH_START_DELAY_MS = import.meta.env.MODE === "test" || (window as any).__adeBrowserMock ? 0 : 2_000; @@ -193,6 +196,17 @@ function filesPageSessionHasUnsavedTabs(session: FilesPageSessionState | undefin return session?.openTabs.some((tab) => tab.content !== tab.savedContent) ?? false; } +function cacheableSessionTabs(openTabs: OpenTab[]): OpenTab[] { + return openTabs + .filter((tab) => { + if (tab.content !== tab.savedContent) { + return tab.content.length <= MAX_CACHED_DIRTY_TAB_CHARS; + } + return isTextTab(tab) && tab.content.length <= MAX_CACHED_CLEAN_TAB_CHARS; + }) + .map((tab) => ({ ...tab })); +} + function hasFilesPageSessionForWorkspaceRoot(workspaceRootPath: string, excludeSessionKey?: string): boolean { for (const [key, session] of filesPageSessionByScope.entries()) { if (excludeSessionKey && key === excludeSessionKey) continue; @@ -236,13 +250,15 @@ function snapshotFilesPageSessionState(args: { searchQuery: string; editorTheme: EditorThemeMode; }): FilesPageSessionState { + const openTabs = cacheableSessionTabs(args.openTabs); + const activeTabPath = openTabs.some((tab) => tab.path === args.activeTabPath) ? args.activeTabPath : (openTabs.at(-1)?.path ?? null); return { workspaceId: args.workspaceId, workspaceRootPath: args.workspaceRootPath, allowPrimaryEdit: args.allowPrimaryEdit, selectedNodePath: args.selectedNodePath, - openTabs: args.openTabs, - activeTabPath: args.activeTabPath, + openTabs, + activeTabPath, mode: args.mode, searchQuery: args.searchQuery, editorTheme: args.editorTheme, @@ -481,18 +497,21 @@ function FilePreviewSurface({ tab }: { tab: OpenTab }) { const previewKind = getFilePreviewKind(tab); const sizeLabel = formatFileSize(tab.size); const details = [tab.mimeType, sizeLabel].filter(Boolean).join(" · "); + const imageSrc = previewKind === "image" && tab.mimeType && tab.content + ? `data:${tab.mimeType};base64,${tab.content}` + : null; const [imageFailed, setImageFailed] = React.useState(false); useEffect(() => { setImageFailed(false); - }, [tab.dataUrl]); + }, [imageSrc]); - if (previewKind === "image" && tab.dataUrl && !imageFailed) { + if (imageSrc && !imageFailed) { return (
{tab.path} @@ -1062,8 +1083,9 @@ export function FilesPage({ tab.isBinary === loaded.isBinary && getFilePreviewKind(tab) === getFilePreviewKind(loaded) && tab.mimeType === (loaded.mimeType ?? null) && - tab.dataUrl === loaded.dataUrl && - tab.size === loaded.size + tab.size === loaded.size && + tab.contentOmitted === loaded.contentOmitted && + tab.omittedReason === loaded.omittedReason ) { return tab; } @@ -1588,9 +1610,20 @@ export function FilesPage({ return; } if (!showQuickOpen) return; - window.ade.files.quickOpen({ workspaceId, query: quickOpen, limit: 80, includeIgnored: true }) - .then(setQuickOpenResults) - .catch(() => setQuickOpenResults([])); + let cancelled = false; + const timer = window.setTimeout(() => { + window.ade.files.quickOpen({ workspaceId, query: quickOpen, limit: 80, includeIgnored: true }) + .then((results) => { + if (!cancelled) setQuickOpenResults(results); + }) + .catch(() => { + if (!cancelled) setQuickOpenResults([]); + }); + }, 120); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; }, [quickOpen, workspaceId, showQuickOpen]); useEffect(() => { diff --git a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx index 6cfb8c20a..3e0721acb 100644 --- a/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx +++ b/apps/desktop/src/renderer/components/lanes/CommitTimeline.tsx @@ -48,6 +48,7 @@ export function CommitTimeline({ const [error, setError] = React.useState(null); const [limit, setLimit] = React.useState(40); const metaByShaRef = React.useRef>(new Map()); + const inFlightMetaRef = React.useRef>(new Set()); const [hoveredSha, setHoveredSha] = React.useState(null); const [tooltipPos, setTooltipPos] = React.useState<{ x: number; y: number } | null>(null); const scrollRef = React.useRef(null); @@ -71,6 +72,7 @@ export function CommitTimeline({ React.useEffect(() => { metaByShaRef.current = new Map(); + inFlightMetaRef.current = new Set(); setHoveredSha(null); setTooltipPos(null); setLimit(40); @@ -100,17 +102,17 @@ export function CommitTimeline({ const ensureMeta = React.useCallback( async (sha: string) => { if (!laneId) return; - if (metaByShaRef.current.has(sha)) return; + if (metaByShaRef.current.has(sha) || inFlightMetaRef.current.has(sha)) return; + inFlightMetaRef.current.add(sha); try { - const [files, messageRaw] = await Promise.all([ - window.ade.git.listCommitFiles({ laneId, commitSha: sha }), - window.ade.git.getCommitMessage({ laneId, commitSha: sha }).catch(() => "") - ]); + const messageRaw = await window.ade.git.getCommitMessage({ laneId, commitSha: sha }).catch(() => ""); const message = messageRaw.trim().length ? messageRaw.trim() : null; - metaByShaRef.current.set(sha, { fileCount: files.length, message, loadedAt: new Date().toISOString() }); + metaByShaRef.current.set(sha, { fileCount: null, message, loadedAt: new Date().toISOString() }); setHoveredSha((prev) => (prev === sha ? sha : prev)); } catch { metaByShaRef.current.set(sha, { fileCount: null, message: null, loadedAt: new Date().toISOString() }); + } finally { + inFlightMetaRef.current.delete(sha); } }, [laneId] diff --git a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx index 80e8d82c3..161b9d667 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDiffPane.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; import { FolderOpen, FloppyDisk } from "@phosphor-icons/react"; import { useNavigate } from "react-router-dom"; import { Group, Panel } from "react-resizable-panels"; @@ -13,6 +13,8 @@ function normalizePath(pathValue: string): string { return pathValue.replace(/\\/g, "/").replace(/^\/+/, "").replace(/\/+$/, ""); } +const MAX_COMMIT_FILE_ROWS = 500; + function DiffFailedRetry({ onRetry }: { onRetry: () => void }) { return (
@@ -43,6 +45,9 @@ export function LaneDiffPane({ }) { const navigate = useNavigate(); const diffRef = useRef(null); + const workingDiffRequestSeq = useRef(0); + const commitFilesRequestSeq = useRef(0); + const commitDiffRequestSeq = useRef(0); const [diff, setDiff] = useState(null); const [diffFailed, setDiffFailed] = useState(false); @@ -51,32 +56,41 @@ export function LaneDiffPane({ const [commitDiff, setCommitDiff] = useState(null); const [commitDiffFailed, setCommitDiffFailed] = useState(false); const [busyAction, setBusyAction] = useState(null); + const [showAllCommitFiles, setShowAllCommitFiles] = useState(false); + useEffect(() => { + setShowAllCommitFiles(false); + }, [selectedCommit?.sha]); + const visibleCommitFiles = useMemo( + () => (showAllCommitFiles ? commitFiles : commitFiles.slice(0, MAX_COMMIT_FILE_ROWS)), + [commitFiles, showAllCommitFiles], + ); + const hiddenCommitFileCount = Math.max(0, commitFiles.length - visibleCommitFiles.length); const refreshWorkingDiff = React.useCallback(() => { + const requestId = ++workingDiffRequestSeq.current; if (!laneId || !selectedPath || !selectedFileMode) { setDiff(null); setDiffFailed(false); return Promise.resolve(); } - setDiffFailed(false); return window.ade.diff .getFile({ laneId, path: selectedPath, mode: selectedFileMode }) .then((value) => { + if (workingDiffRequestSeq.current !== requestId) return; setDiff(value); + setDiffFailed(false); }) .catch(() => { + if (workingDiffRequestSeq.current !== requestId) return; setDiff(null); setDiffFailed(true); }); }, [laneId, selectedPath, selectedFileMode]); useEffect(() => { - setDiff(null); - setDiffFailed(false); - if (!laneId || !selectedPath || !selectedFileMode) return; void refreshWorkingDiff(); - }, [laneId, selectedPath, selectedFileMode, refreshWorkingDiff]); + }, [refreshWorkingDiff]); useEffect(() => { if (!liveSync) return; @@ -139,21 +153,24 @@ export function LaneDiffPane({ }, [liveSync, laneId, selectedPath, selectedFileMode, selectedCommit, refreshWorkingDiff]); useEffect(() => { + const requestId = ++commitFilesRequestSeq.current; + commitDiffRequestSeq.current += 1; setCommitFiles([]); setSelectedCommitFilePath(null); setCommitDiff(null); + setCommitDiffFailed(false); if (!laneId || !selectedCommit) return; let cancelled = false; window.ade.git .listCommitFiles({ laneId, commitSha: selectedCommit.sha }) .then((files) => { - if (cancelled) return; + if (cancelled || commitFilesRequestSeq.current !== requestId) return; setCommitFiles(files); setSelectedCommitFilePath(files[0] ?? null); }) .catch(() => { - if (cancelled) return; + if (cancelled || commitFilesRequestSeq.current !== requestId) return; setCommitFiles([]); setSelectedCommitFilePath(null); }); @@ -163,6 +180,7 @@ export function LaneDiffPane({ }, [laneId, selectedCommit]); const refreshCommitDiff = React.useCallback(() => { + const requestId = ++commitDiffRequestSeq.current; setCommitDiff(null); setCommitDiffFailed(false); if (!laneId || !selectedCommit || !selectedCommitFilePath) return; @@ -175,9 +193,11 @@ export function LaneDiffPane({ compareTo: "parent" }) .then((value) => { + if (commitDiffRequestSeq.current !== requestId) return; setCommitDiff(value); }) .catch(() => { + if (commitDiffRequestSeq.current !== requestId) return; setCommitDiff(null); setCommitDiffFailed(true); }); @@ -230,7 +250,7 @@ export function LaneDiffPane({
{commitFiles.length ? ( - commitFiles.map((file) => { + visibleCommitFiles.map((file) => { const isFileSelected = selectedCommitFilePath === file; return ( +
+ ) : null}
diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx index 81f20a255..d64dfe873 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.test.tsx @@ -323,6 +323,23 @@ describe("LaneGitActionsPane rescue action", () => { }); }); + it("bounds rendered change rows for large file lists", async () => { + mockChangesByLaneId["lane-1"] = { + staged: [], + unstaged: Array.from({ length: 305 }, (_, index) => ({ + path: `src/generated/file-${index}.ts`, + kind: "modified" as const, + })), + }; + + renderPane(); + + expect(await screen.findByText("UNSTAGED (305)")).toBeTruthy(); + expect(screen.getByText("src/generated/file-0.ts")).toBeTruthy(); + expect(screen.getByText("Showing first 300 of 305 unstaged files.")).toBeTruthy(); + expect(screen.queryByText("src/generated/file-300.ts")).toBeNull(); + }); + it("disables the rescue button during an in-progress merge or rebase", async () => { mockConflictState = { laneId: "lane-1", diff --git a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx index 633de9ce7..300fdd41a 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneGitActionsPane.tsx @@ -46,6 +46,7 @@ type CommitMessageAiState = { type ResponsiveMode = "narrow" | "medium" | "wide"; const AUTO_GENERATE_COMMIT_ACTION = "generate commit message"; +const MAX_RENDERED_CHANGE_ROWS_PER_SECTION = 300; type LaneGitActionRuntimeState = { version: number; busyAction: string | null; @@ -524,6 +525,18 @@ export function LaneGitActionsPane({ const stagedCount = changes.staged.length; const hasStaged = stagedCount > 0; const hasUnstaged = changes.unstaged.length > 0; + const [showAllStagedChanges, setShowAllStagedChanges] = useState(false); + const [showAllUnstagedChanges, setShowAllUnstagedChanges] = useState(false); + const visibleStagedChanges = useMemo( + () => (showAllStagedChanges ? changes.staged : changes.staged.slice(0, MAX_RENDERED_CHANGE_ROWS_PER_SECTION)), + [changes.staged, showAllStagedChanges], + ); + const visibleUnstagedChanges = useMemo( + () => (showAllUnstagedChanges ? changes.unstaged : changes.unstaged.slice(0, MAX_RENDERED_CHANGE_ROWS_PER_SECTION)), + [changes.unstaged, showAllUnstagedChanges], + ); + const hiddenStagedChangeCount = Math.max(0, changes.staged.length - visibleStagedChanges.length); + const hiddenUnstagedChangeCount = Math.max(0, changes.unstaged.length - visibleUnstagedChanges.length); const responsiveMode = getResponsiveMode(paneWidth); const maxVisibleStashes = responsiveMode === "wide" ? 2 : 3; const actionGridColumns = @@ -2497,13 +2510,37 @@ export function LaneGitActionsPane({ {changes.staged.length > 0 ? (
STAGED ({changes.staged.length})
- {changes.staged.map((file) => renderFileRow(file, "staged"))} + {visibleStagedChanges.map((file) => renderFileRow(file, "staged"))} + {hiddenStagedChangeCount > 0 ? ( +
+ Showing first {MAX_RENDERED_CHANGE_ROWS_PER_SECTION} of {changes.staged.length} staged files. + +
+ ) : null}
) : null} {changes.unstaged.length > 0 ? (
UNSTAGED ({changes.unstaged.length})
- {changes.unstaged.map((file) => renderFileRow(file, "unstaged"))} + {visibleUnstagedChanges.map((file) => renderFileRow(file, "unstaged"))} + {hiddenUnstagedChangeCount > 0 ? ( +
+ Showing first {MAX_RENDERED_CHANGE_ROWS_PER_SECTION} of {changes.unstaged.length} unstaged files. + +
+ ) : null}
) : null} {changes.staged.length === 0 && changes.unstaged.length === 0 ? ( diff --git a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx index a33dbb88a..17b17c224 100644 --- a/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx +++ b/apps/desktop/src/renderer/components/lanes/MonacoDiffView.tsx @@ -68,10 +68,11 @@ export const MonacoDiffView = forwardRef( null ); - const modelIdentityRef = useRef(null); + const editableRef = useRef(editable); const [ready, setReady] = useState(false); const [failed, setFailed] = useState(false); const monacoTheme = theme === "light" ? "vs" : "vs-dark"; + const monacoThemeRef = useRef(monacoTheme); useImperativeHandle(ref, () => ({ getModifiedValue: () => diffEditorRef.current?.getModel()?.modified.getValue() ?? null, @@ -85,6 +86,14 @@ export const MonacoDiffView = forwardRef { + editableRef.current = editable; + }, [editable]); + + useEffect(() => { + monacoThemeRef.current = monacoTheme; + }, [monacoTheme]); + useEffect(() => { let cancelled = false; loadMonaco() @@ -93,7 +102,7 @@ export const MonacoDiffView = forwardRef { @@ -160,7 +165,14 @@ export const MonacoDiffView = forwardRef { cancelled = true; }; - }, [diff, editable]); + }, [diff, ready]); + + useEffect(() => { + const editor = diffEditorRef.current; + if (!editor) return; + editor.getModifiedEditor().updateOptions({ readOnly: !editable }); + editor.getOriginalEditor().updateOptions({ readOnly: true }); + }, [editable]); useEffect(() => { let cancelled = false; @@ -179,13 +191,18 @@ export const MonacoDiffView = forwardRef -
- {!ready && !failed ? ( +
+ {diff.isBinary ? ( +
+ Binary diff preview unavailable. +
+ ) : null} + {!ready && !failed && !diff.isBinary ? (
Loading diff editor…
) : null} - {failed ? ( + {failed && !diff.isBinary ? (
Monaco failed to load in dev mode. Showing plain-text diff fallback. diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 33c18c933..13dcdfa7b 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -1,24 +1,54 @@ export const ADE_CLI_AGENT_GUIDANCE = [ "## ADE CLI", - "`ade` is the default control plane for ADE-managed sessions: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state.", - "If `command -v ade` fails, try `${ADE_CLI_PATH:-}` when set, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout fall back to `node apps/ade-cli/dist/cli.cjs ...` after confirming the file exists.", - "The only normal reason to skip ADE CLI for an ADE action is that the user truly does not have it installed or reachable after those fallbacks.", - "For ADE work beyond the immediate local edit, shell command, or repository inspection in front of you, check ADE CLI first: run `ade doctor` if needed, use typed commands like `ade lanes list --text` / `ade prs checks --text`, or discover with `ade actions list --text` and `ade actions run ...`.", - "iOS Simulator and Preview Lab control is only supported from ADE desktop chats. If you are in a standalone CLI session outside an ADE chat, do not try to drive `ade ios-sim`; tell the user to open or rerun the request in an ADE chat.", - "Use `ade help ` and `ade help ` to get precise flags instead of guessing. For iOS Simulator and Preview Lab work, start with `ade help ios-sim`, then drill into `ade help ios-sim launch`, `snapshot`, `previews`, `preview-render`, `select`, `tap`, `drag`, `type`, or `shutdown` as needed. Use `ade --socket ios-sim actions --text` for the raw ios_simulator action catalog.", - "For App Control work on Electron apps, prefer desktop socket mode so the CLI and ADE drawer share the same managed app session. Start with `ade help app-control` and `ade --socket app-control status --text`; launch with `ade --socket app-control launch --command \"npm run dev\" --text` or connect to an existing debuggable app with `ade --socket app-control connect --cdp-port --text`. To reuse a Run-tab process, list configured commands via `ade --socket settings get --text`, then launch with `--command` plus `--cwd` matching that process so the app runs from the same directory the Run tab does (relative cwds resolve against the lane root, and the launch is rejected if the resolved cwd escapes the lane). Launches run in the visible chat terminal; ADE sets ADE_APP_CONTROL_CDP_PORT and ADE_APP_CONTROL_DEBUG_FLAGS in the environment and auto-forwards debug flags for common npm/pnpm/yarn/bun script launches and direct electron commands. Custom launchers should forward one of those values to Electron's --remote-debugging-port.", - "After an App Control launch or when the user asks what happened in that visible launch terminal, first run `ade --socket app-control status --text` or `--json`; do not say you lack terminal visibility until App Control status and terminal list have both been checked. Prefer `ade --socket app-control logs --text --max-bytes 8388608` to inspect full scrollback, `ade --socket app-control terminal write --data \"y\\n\"` to answer prompts, and `ade --socket app-control terminal signal --signal SIGINT` to interrupt the active App Control launch. If there is no active App Control terminal, fall back to `ade --socket terminal list --text --limit 20` and choose the running/recent `App Control:` terminal, or use `ADE_CHAT_SESSION_ID` with `ade --socket terminal read --chat-session \"$ADE_CHAT_SESSION_ID\" --text`. After the Electron renderer connects, use `ade --socket app-control snapshot --text` for screenshot + DOM refs, `click` / `type` for Control-mode input, and the drawer's Inspect mode (or `ade --socket app-control select --x --y `) to attach screenshot-backed UI context to chat. App Control is a bridge over existing tools; Playwright, agent-browser, browser-use, or Computer Use can still be useful, but ADE should preserve the launch state, terminal output, screenshot, DOM/selector packet, source candidates, and proof/context attachments.", - "For ADE browser work, prefer desktop socket mode so CLI calls and the Work sidebar share the same global browser tabs. Use `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab `, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start` / `select-current` / `clear-selection` for browser context. The ADE browser is global rather than lane-scoped; links from chat output and localhost URLs in chat terminals should open there by default.", - "For iOS Simulator work, prefer desktop socket mode so the CLI and ADE drawer share one long-lived simulator service: run `ade --socket ios-sim status --text`, `ade --socket ios-sim devices --text`, `ade --socket ios-sim apps --device --text`, then `ade --socket ios-sim launch --target --text` (or `--bundle-id`/`--app-bundle`). Launch is headless by default; use explicit window capture or foreground launch only when the user wants the real Simulator.app window.", - "After an iOS app is launched, use `ade --socket ios-sim snapshot --text` or `ade --socket ios-sim elements --text` for screenshot + accessibility/ADEInspector grounding, then `ade --socket ios-sim select --x --y ` to add selected UI context to the drawer chat. Use `tap`, `drag`/`swipe`, and `type` against the active launched app; `live-start` uses auto backend resolution (IOSurface first, then idb/simctl fallbacks), while `window-start`, `preview-start`, `stream-status`, and `stream-stop` manage visual streams explicitly. Use `stream-status --text` to explain which backend/input path is active, any fallback reason, helper pid, and latency; low idle fps is normal on iosurface-indigo because frames are event-driven when the simulator is still. If the simulator is not running or no active session/snapshot is available, warn the user with the exact blocker instead of guessing the screen. When you finish, run `ade --socket ios-sim shutdown` to tear down the session, stop streaming, and release helper processes.", - "For ADE Preview Lab work, use `ade --socket ios-sim preview-status --text` to check Xcode MCP readiness, `ade --socket ios-sim previews --source --text` to discover existing `#Preview`/`PreviewProvider` targets, and `ade --socket ios-sim preview-render --source --index --text` as the final open/render step. If no matching preview exists, add one first; if one exists, reuse it.", - "When changing SwiftUI from simulator or preview context, keep the affected UI previewable. Add or repair nearby `#Preview` definitions and deterministic preview fixtures when needed, preferably in feature sidecar files such as `Previews.swift` or a DEBUG-only `PreviewSupport` helper. Preview fixtures should use representative mock data derived from visible UI context when possible, but must not depend on live sync, keychain, network, push, sockets, or a production database.", - "When preview appearance matters, add named light/dark preview variants with `.preferredColorScheme(.light)` or `.preferredColorScheme(.dark)` and prefer adaptive system colors over hardcoded light-only or dark-only values. Do this as part of making the SwiftUI surface previewable, not as an app-specific special case.", - "When asked to make a SwiftUI preview reachable in the live simulator, add a DEBUG-only route, deep link, launch-argument handler, or small preview host that presents the same view with the same deterministic fixtures. ADE may pass `ADE_PREVIEW_*` environment values when launching from a selected preview target; use them when helpful, but keep the implementation app-local and optional.", - "When an iOS visual inspect packet is attached to chat, treat previewability as part of the work: ensure the affected source file or a nearby related Swift file contains a renderable `#Preview`/`PreviewProvider`, and add mock data or a preview harness if the screen would otherwise be blank.", - "When the user asks you to capture, send, attach, or provide proof, use whatever computer-use or browser tool is appropriate to produce the evidence, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane.", - "When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task.", + "ADE is a local-first desktop development environment. It manages lanes (git worktrees), native ADE chats, terminal sessions, missions, PR workflows, memory, proof/artifacts, App Control, iOS Simulator/Preview Lab state, config, and managed processes.", + "`ade` is the default control plane for ADE-managed sessions. Use normal shell commands for the immediate repo inspection/edit/test in front of you; use ADE CLI when you need ADE state, drawer/session state, proof registration, missions, PR metadata, memory, or managed app/simulator control.", + "", + "### First orientation", + "- For ADE work beyond the immediate local edit, shell command, or repository inspection in front of you, check ADE CLI first.", + "- Start with `ade doctor --text` when the environment is unclear.", + "- Use typed commands with `--text` for readable output: `ade lanes list --text`, `ade missions list --text`, `ade chats list --text`, `ade prs checks --text`, `ade proof status --text`, and `ade actions list --text`.", + "- Use `ade help ` and `ade help ` to discover exact flags instead of guessing. `ade actions list --text` / `ade actions run ...` is the escape hatch for service actions that do not yet have a friendly command.", + "- If `command -v ade` fails, try `${ADE_CLI_PATH:-}` when set, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout fall back to `node apps/ade-cli/dist/cli.cjs ...` after confirming the file exists.", + "- The only normal reason to skip ADE CLI for an ADE action is that the user truly does not have it installed or reachable after those fallbacks.", + "", + "### Desktop socket surfaces", + "- Use `--socket` when the ADE desktop drawer and the CLI must share one live session. This matters for App Control, iOS Simulator, Preview Lab, terminal logs, selection/context capture, and proof drawer updates.", + "- iOS Simulator and Preview Lab control is only supported from ADE desktop chats. If you are in a standalone CLI session outside an ADE chat, do not try to drive `ade ios-sim`; tell the user to open or rerun the request in an ADE chat.", + "- For App Control on Electron apps, prefer socket mode: `ade help app-control`, `ade --socket app-control status --text`, `ade --socket app-control launch --command \"npm run dev\" --text`, or `ade --socket app-control connect --cdp-port --text`.", + "- For App Control terminal/log questions, check `ade --socket app-control status --text`, then prefer `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control terminal write --data \"y\\n\"`, or `ade --socket app-control terminal signal --signal SIGINT`. Only fall back to `ade --socket terminal list --text` / `terminal read` when no App Control terminal is active.", + "- ADE sets `ADE_APP_CONTROL_CDP_PORT` and `ADE_APP_CONTROL_DEBUG_FLAGS` for App Control launches. Custom Electron launchers should forward one of those values to `--remote-debugging-port`.", + "- After App Control launch/connect, use `ade --socket app-control snapshot --text` or `elements --text`; use Control mode or `click`/`type` for input; use Inspect mode or `select --x --y ` to attach screenshot-backed DOM/selector/source context to chat.", + "- For ADE browser work, prefer socket mode so CLI calls and the Work sidebar share the same global browser tabs: `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text` / `ade --socket browser clear-selection --text`. The ADE browser is global rather than lane-scoped; links from chat output and localhost URLs in chat terminals should open there by default.", + "", + "### iOS Simulator and Preview Lab", + "- For iOS work inside an ADE chat, start with `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim devices --text`, and `ade --socket ios-sim apps --device --text`, then launch with `ade --socket ios-sim launch --target --text`.", + "- After launch, use `ade --socket ios-sim snapshot --text` or `ade --socket ios-sim elements --text` for screenshot + accessibility/ADEInspector grounding. Use `ade --socket ios-sim select --x --y ` to attach context to the drawer chat, and `ade --socket ios-sim tap`, `ade --socket ios-sim drag` / `ade --socket ios-sim swipe`, or `ade --socket ios-sim type` against the active app.", + "- Stream commands are `ade --socket ios-sim window-start`, `ade --socket ios-sim live-start`, `ade --socket ios-sim preview-start`, `ade --socket ios-sim stream-status`, and `ade --socket ios-sim stream-stop`. Use `ade --socket ios-sim stream-status --text` to explain the active backend/input path, fallback reason, helper pid, latency, and any blocker. Low idle fps is normal on `iosurface-indigo` because frames are event-driven while the simulator is still.", + "- For ADE Preview Lab, check `ade --socket ios-sim preview-status --text`, discover existing previews with `ade --socket ios-sim previews --source --text`, and finish with `ade --socket ios-sim preview-render --source --index --text`. Add a preview only when no matching preview already exists.", + "- If the simulator is not running or no active session/snapshot is available, warn the user with the exact blocker instead of guessing the screen.", + "- When you finish iOS Simulator work, run `ade --socket ios-sim shutdown` when you own the session and the task no longer needs the running simulator.", + "", + "### SwiftUI previewability", + "- When changing SwiftUI from simulator or preview context, keep the affected UI previewable: add or repair nearby `#Preview` definitions and deterministic mock fixtures when needed.", + "- Preview fixtures should use representative visible UI context when useful, but must not depend on live sync, keychain, network, push, sockets, or a production database.", + "- For appearance-sensitive work, add named light/dark preview variants with `.preferredColorScheme(.light)` / `.preferredColorScheme(.dark)` and prefer adaptive system colors.", + "- When asked to make a preview reachable in the live simulator, add a DEBUG-only route, deep link, launch-argument handler, or small preview host. ADE may pass optional `ADE_PREVIEW_*` environment values.", + "- When an iOS visual inspect packet is attached to chat, ensure the affected source file or a nearby related Swift file contains a renderable `#Preview`/`PreviewProvider`, and add mock data or a preview harness if the screen would otherwise be blank.", + "", + "### Proof and cleanup", + "- When the user asks you to capture, send, attach, or provide proof, use the appropriate computer-use/browser/app-control tool to produce evidence, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane.", + "- When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task.", ].join("\n"); -export const ADE_CLI_INLINE_GUIDANCE = - "`ade` is the default control plane for ADE-managed sessions: lanes, missions, PRs, chats/sessions, memory, proof, config, and process state. If `command -v ade` fails, try `${ADE_CLI_PATH:-}`, then `${ADE_CLI_BIN_DIR:-}/ade`, and in an ADE source checkout `node apps/ade-cli/dist/cli.cjs ...` after confirming it exists. The only normal reason to skip ADE CLI for an ADE action is that the user truly does not have it installed or reachable after those fallbacks. For ADE work beyond the immediate local edit, shell command, or repository inspection in front of you, check ADE CLI first: try `ade doctor`, typed `ade ... --text` commands, `ade help `, `ade help `, or `ade actions list --text` / `ade actions run ...`. iOS Simulator and Preview Lab control is only supported from ADE desktop chats; if you are in a standalone CLI session outside an ADE chat, do not try to drive `ade ios-sim`, and tell the user to open or rerun the request in an ADE chat. For App Control work on Electron apps, prefer desktop socket mode so the CLI and drawer share the same managed app session: run `ade help app-control`, `ade --socket app-control status --text`, launch with `ade --socket app-control launch --command \"npm run dev\" --text`, or connect with `connect --cdp-port --text`. To reuse a Run-tab process, discover its command and cwd via `ade --socket settings get --text`, then launch with `--command` and a matching `--cwd` (relative cwds resolve against the lane root and a cwd outside the lane is rejected). App Control launches run in the visible chat terminal; for terminal/log questions first use `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control terminal write --data \"y\\n\"`, or `ade --socket app-control terminal signal --signal SIGINT`, and only fall back to `ade --socket terminal read --chat-session \"$ADE_CHAT_SESSION_ID\" --text` or `terminal list` when no active App Control terminal exists. ADE sets ADE_APP_CONTROL_CDP_PORT and ADE_APP_CONTROL_DEBUG_FLAGS and auto-forwards debug flags for common npm/pnpm/yarn/bun script launches and direct electron commands; custom launchers should forward one of those values to Electron's --remote-debugging-port. After launch, use `snapshot --text` / `elements --text`, Control mode or `click`/`type` to drive the app, and Inspect mode or `select --x --y ` to attach screenshot-backed DOM/selector/source context to chat. For ADE browser work, use `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `switch --tab `, `screenshot --text`, and `inspect-start` / `select-current`; it is a global browser tab set shared with chat and terminal link clicks, not lane-scoped. For iOS Simulator work inside an ADE chat, use desktop socket mode when drawer state matters and discover precise flags with `ade help ios-sim` plus focused pages such as `ade help ios-sim launch`, `snapshot`, `previews`, `preview-render`, `select`, `tap`, `drag`, `type`, and `shutdown`; raw actions are listed by `ade --socket ios-sim actions --text`. Start with `ade --socket ios-sim status --text`, `devices --text`, `apps --device --text`, launch with `ade --socket ios-sim launch --target --text`, then use `snapshot --text` / `elements --text`, `select --x --y `, `tap`, `drag`/`swipe`, and `type` against the active launched app; stream commands are `window-start`, `live-start`, `preview-start`, `stream-status`, and `stream-stop`. Use `stream-status --text` to explain which backend/input path is active, any fallback reason, helper pid, and latency; low idle fps is normal on iosurface-indigo because frames are event-driven when the simulator is still. Tear down with `ade --socket ios-sim shutdown`. If the simulator is not running or no active session/snapshot is available, warn the user with the exact blocker instead of guessing the screen. For ADE Preview Lab, check `preview-status --text`, discover with `previews --source --text`, and finish with `preview-render --source --index --text`; add a preview only when no matching preview already exists. When changing SwiftUI from simulator or preview context, keep the affected UI previewable: add or repair nearby `#Preview` definitions and deterministic mock fixtures in feature sidecar files or DEBUG-only preview support, using visible UI context as representative data when useful and avoiding live sync/keychain/network/database dependencies. When preview appearance matters, add named light/dark preview variants with `.preferredColorScheme(.light)` or `.preferredColorScheme(.dark)` and prefer adaptive system colors over hardcoded light-only or dark-only values. When asked to make a SwiftUI preview reachable in the live simulator, add a DEBUG-only route, deep link, launch-argument handler, or small preview host that presents the same view with the same deterministic fixtures. ADE may pass `ADE_PREVIEW_*` environment values when launching from a selected preview target; use them when helpful, but keep the implementation app-local and optional. When an iOS visual inspect packet is attached, ensure the affected source file or a nearby related Swift file contains a renderable `#Preview`/`PreviewProvider`, and add mock data or a preview harness if the screen would otherwise be blank. When the user asks you to capture, send, attach, or provide proof, use whatever computer-use or browser tool is appropriate to produce the evidence, then register it with ADE via `ade proof ...` so it appears in the ADE proof drawer for the active chat, mission, or lane. When you run processes of any kind, track what you started and clean up old, stale, or finished processes before leaving the task."; +export const ADE_CLI_INLINE_GUIDANCE = [ + "ADE quick orientation:", + "- ADE is a local-first desktop development environment for lanes (git worktrees), native chats, terminals, missions, PRs, memory, proof/artifacts, App Control, and iOS Simulator/Preview Lab state.", + "- `ade` is the default control plane for ADE-managed sessions. Use shell for the immediate repo edit/test; use ADE CLI for ADE state, drawer/session state, proof registration, memory, missions, PR metadata, and managed app/simulator control.", + "- First checks: `ade doctor --text`, `ade help `, typed `ade ... --text` commands, and `ade actions list --text`. Common starts: `ade lanes list --text`, `ade chats list --text`, `ade proof status --text`.", + "- If `command -v ade` fails, try `${ADE_CLI_PATH:-}`, then `${ADE_CLI_BIN_DIR:-}/ade`, then `node apps/ade-cli/dist/cli.cjs ...` in an ADE source checkout after confirming it exists. The only normal reason to skip ADE CLI for an ADE action is that it is truly unreachable.", + "- Use `--socket` when the CLI and ADE desktop drawer need the same live state: App Control, iOS Simulator, Preview Lab, terminal logs, selections/context, and proof drawer updates.", + "- iOS Simulator and Preview Lab control is only supported from ADE desktop chats. For iOS work use `ade help ios-sim`, `ade --socket ios-sim status --text`, `ade --socket ios-sim snapshot --text`, `ade --socket ios-sim select --x --y `, `ade --socket ios-sim stream-status --text`, and Preview Lab commands such as `ade --socket ios-sim preview-status --text`, `ade --socket ios-sim previews --source --text`, `ade --socket ios-sim preview-render --source --index --text`.", + "- For App Control on Electron apps use `ade help app-control`, `ade --socket app-control status --text`, `ade --socket app-control launch --command ... --text`, `ade --socket app-control logs --text --max-bytes 8388608`, `ade --socket app-control snapshot --text`, `ade --socket app-control select --x --y `, `ade --socket app-control click`, and `ade --socket app-control type`.", + "- For ADE browser use `ade help browser`, `ade --socket browser status --text`, `ade --socket browser open --new-tab --text`, `ade --socket browser switch --tab --text`, `ade --socket browser screenshot --text`, and `ade --socket browser inspect-start --text` / `ade --socket browser select-current --text`. The ADE browser is global, not lane-scoped.", + "- If a live app/simulator/session is missing, report the exact blocker instead of guessing. When asked for proof, register artifacts with `ade proof ...` so they appear in the ADE proof drawer, and clean up old, stale, or finished processes before leaving the task.", +].join("\n"); diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index 8c9726745..4171883f1 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -28,6 +28,8 @@ export type FileTreeNode = { children?: FileTreeNode[]; changeStatus?: FileTreeChangeStatus; size?: number; + // Set on a directory node when its children list was truncated at MAX_TREE_CHILDREN_PER_DIRECTORY. + childrenTruncated?: boolean; }; export type FilesListTreeArgs = { @@ -48,6 +50,8 @@ export type FileContent = { previewKind?: FilePreviewKind; mimeType?: string | null; dataUrl?: string; + contentOmitted?: boolean; + omittedReason?: "too_large" | "unsupported_binary"; }; export type FilesReadFileArgs = { diff --git a/apps/desktop/src/shared/types/git.ts b/apps/desktop/src/shared/types/git.ts index 2c68a3368..a15152479 100644 --- a/apps/desktop/src/shared/types/git.ts +++ b/apps/desktop/src/shared/types/git.ts @@ -162,6 +162,8 @@ export type GetFileDiffArgs = { export type DiffSide = { exists: boolean; text: string; + size?: number; + isTruncated?: boolean; }; export type FileDiff = { diff --git a/apps/desktop/src/shared/types/iosSimulator.ts b/apps/desktop/src/shared/types/iosSimulator.ts index 968b4e41d..e638b8a4b 100644 --- a/apps/desktop/src/shared/types/iosSimulator.ts +++ b/apps/desktop/src/shared/types/iosSimulator.ts @@ -391,7 +391,16 @@ export type IosSimulatorSelectResult = { source: "ade-inspector" | "accessibility" | "coordinate-fallback"; }; +export type IosSimulatorDrawerMode = "interact" | "inspect" | "preview"; + export type IosSimulatorEventPayload = + | { + type: "drawer-open-requested"; + action: string; + mode: IosSimulatorDrawerMode; + chatSessionId?: string | null; + laneId?: string | null; + } | { type: "session-started"; session: IosSimulatorSession } | { type: "session-updated"; session: IosSimulatorSession | null } | { type: "session-released"; previousSession: IosSimulatorSession | null } diff --git a/docs/features/files-and-editor/README.md b/docs/features/files-and-editor/README.md index 2f485e822..36d771cb0 100644 --- a/docs/features/files-and-editor/README.md +++ b/docs/features/files-and-editor/README.md @@ -198,15 +198,18 @@ For deeper detail on the watcher + trust boundary, see ## Gotchas -- The file tree is always listed with `includeIgnored: true` in the - renderer, so dotfiles and `node_modules` show up by default. Pair - callers that pass `includeIgnored: false` (search indexing, - watcher default mode) with the corresponding start/stop pair — the - watcher refcounts are per-mode. -- `fileService.readFile` has a 5 MB read cap - (`MAX_EDITOR_READ_BYTES`). Files over the cap return a truncated - `FileContent` with the binary flag set; Monaco will render a warning - instead of the content. +- The file tree is listed with `includeIgnored: true` in the renderer, + so dotfiles show up by default, but volatile ADE runtime paths such + as `.ade/worktrees/`, `.ade/cache/`, transcripts, secrets, and the + SQLite DB are still filtered out. Pair callers that pass + `includeIgnored: false` (search indexing, watcher default mode) with + the corresponding start/stop pair — the watcher refcounts are + per-mode. +- `fileService.readFile` sends inline text previews up to 1 MB, inline + image previews up to 1 MB, and small unsupported binary payloads up + to 256 KB. Larger files return metadata-only `FileContent` with + `contentOmitted`, so Monaco is not mounted for payloads that would + spike renderer memory. - `writeTextAtomic` creates a temp file in the target's directory. If the directory has no write permission, the operation throws, which surfaces as an IPC rejection at the editor tab. diff --git a/docs/features/files-and-editor/file-watcher-and-trust.md b/docs/features/files-and-editor/file-watcher-and-trust.md index 78f0c8cba..31c114510 100644 --- a/docs/features/files-and-editor/file-watcher-and-trust.md +++ b/docs/features/files-and-editor/file-watcher-and-trust.md @@ -207,7 +207,7 @@ All registered in `registerIpc.ts`: |---|---| | `ade.files.listWorkspaces` | calls `laneService.getFilesWorkspaces`, sorts primary first | | `ade.files.listTree` | resolves workspace, optionally lazy per `parentPath`/`depth`, returns `FileTreeNode[]` | -| `ade.files.readFile` | atomic read up to `MAX_EDITOR_READ_BYTES = 5 MB`, detects binary, picks `languageId` | +| `ade.files.readFile` | bounded preview read: inline text/image up to 1 MB, small unsupported binaries up to 256 KB, metadata-only `contentOmitted` response above those caps | | `ade.files.writeTextAtomic` | temp file + rename | | `ade.files.writeText` | plain write | | `ade.files.createFile` | throws if exists, otherwise writes empty or provided content |