diff --git a/.ade/ade.yaml b/.ade/ade.yaml index 3749bf06c..ba417967b 100644 --- a/.ade/ade.yaml +++ b/.ade/ade.yaml @@ -1,11 +1,12 @@ version: 1 processes: - - id: hiwo8mbf - name: dogfood.sh droid + - id: dbun9idy + name: dogfood code review command: - - scripts/dogfood.sh - - droid-chat - cwd: ./ + - /Users/admin/Projects/ADE/scripts/dogfood.sh + - code-review + cwd: /Users/admin/Projects/ADE + gracefulShutdownMs: 7000 stackButtons: [] testSuites: [] laneOverlayPolicies: [] diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 6114715d9..c83f8f44f 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, nativeImage, protocol, shell } from "electron"; +import { app, BrowserWindow, dialog, nativeImage, protocol, shell } from "electron"; import path from "node:path"; type NodePtyType = typeof import("node-pty"); import { registerIpc } from "./services/ipc/registerIpc"; @@ -167,6 +167,8 @@ const defaultEnabledBackgroundTaskFlags = new Set([ "ADE_ENABLE_MEMORY_STARTUP_SWEEP", "ADE_ENABLE_MEMORY_CONSOLIDATION", "ADE_ENABLE_EMBEDDING_WORKER", + "ADE_ENABLE_MEMORY_FILE_SYNC", + "ADE_ENABLE_SYNC_INIT", ]); function isBackgroundTaskEnabled(enableFlag?: string): boolean { @@ -202,7 +204,10 @@ function getRendererUrl(): string { return `file://${path.join(__dirname, "../renderer/index.html")}`; } -async function createWindow(logger?: Logger): Promise { +async function createWindow(args: { + logger?: Logger; + onCloseRequested?: (win: BrowserWindow, event: Electron.Event) => void; +} = {}): Promise { // Load the app icon from the build directory. const iconDir = path.join(__dirname, "../../build"); const pngPath = path.join(iconDir, "icon.png"); @@ -273,22 +278,26 @@ async function createWindow(logger?: Logger): Promise { }); }); + win.on("close", (event) => { + args.onCloseRequested?.(win, event); + }); + win.on("unresponsive", () => { - logger?.warn("window.unresponsive", { + args.logger?.warn("window.unresponsive", { windowId: win.id, url: win.webContents.getURL(), }); }); win.on("responsive", () => { - logger?.info("window.responsive", { + args.logger?.info("window.responsive", { windowId: win.id, url: win.webContents.getURL(), }); }); win.webContents.on("render-process-gone", (_event, details) => { - logger?.error("window.render_process_gone", { + args.logger?.error("window.render_process_gone", { windowId: win.id, reason: details.reason, exitCode: details.exitCode, @@ -297,7 +306,7 @@ async function createWindow(logger?: Logger): Promise { }); win.webContents.on("preload-error", (_event, preloadPath, error) => { - logger?.error("window.preload_error", { + args.logger?.error("window.preload_error", { windowId: win.id, preloadPath, err: toErrorMessage(error), @@ -307,7 +316,7 @@ async function createWindow(logger?: Logger): Promise { win.webContents.on( "did-fail-load", (_event, errorCode, errorDescription, validatedURL, isMainFrame) => { - logger?.error("window.did_fail_load", { + args.logger?.error("window.did_fail_load", { windowId: win.id, errorCode, errorDescription, @@ -320,7 +329,7 @@ async function createWindow(logger?: Logger): Promise { win.webContents.on( "did-start-navigation", (_event, url, isInPlace, isMainFrame) => { - logger?.info("window.did_start_navigation", { + args.logger?.info("window.did_start_navigation", { windowId: win.id, url, isInPlace, @@ -330,7 +339,7 @@ async function createWindow(logger?: Logger): Promise { ); win.webContents.on("did-navigate-in-page", (_event, url, isMainFrame) => { - logger?.info("window.did_navigate_in_page", { + args.logger?.info("window.did_navigate_in_page", { windowId: win.id, url, isMainFrame, @@ -338,21 +347,21 @@ async function createWindow(logger?: Logger): Promise { }); win.webContents.on("did-finish-load", () => { - logger?.info("window.did_finish_load", { + args.logger?.info("window.did_finish_load", { windowId: win.id, url: win.webContents.getURL(), }); }); win.webContents.on("did-stop-loading", () => { - logger?.info("window.did_stop_loading", { + args.logger?.info("window.did_stop_loading", { windowId: win.id, url: win.webContents.getURL(), }); }); win.webContents.on("dom-ready", () => { - logger?.info("window.dom_ready", { + args.logger?.info("window.dom_ready", { windowId: win.id, url: win.webContents.getURL(), }); @@ -369,14 +378,14 @@ async function createWindow(logger?: Logger): Promise { sourceId, }; if (level >= 2) { - logger?.error("window.console", payload); + args.logger?.error("window.console", payload); return; } if (level === 1) { - logger?.warn("window.console", payload); + args.logger?.warn("window.console", payload); return; } - logger?.info("window.console", payload); + args.logger?.info("window.console", payload); }, ); @@ -387,7 +396,7 @@ async function createWindow(logger?: Logger): Promise { storages: ["serviceworkers", "cachestorage"], }); } catch (error) { - logger?.warn("renderer.dev_cache_clear_failed", { + args.logger?.warn("renderer.dev_cache_clear_failed", { err: error instanceof Error ? error.message : String(error), }); } @@ -420,7 +429,7 @@ async function createWindow(logger?: Logger): Promise { if (!isOutdatedOptimizeDep) return; recoveredOutdatedOptimizeDep = true; - logger?.warn("renderer.optimize_dep_outdated", { + args.logger?.warn("renderer.optimize_dep_outdated", { statusCode: details.statusCode, url: details.url, }); @@ -430,7 +439,7 @@ async function createWindow(logger?: Logger): Promise { } const rendererUrl = getRendererUrl(); - logger?.info("window.loading_url", { + args.logger?.info("window.loading_url", { windowId: win.id, url: rendererUrl, }); @@ -438,7 +447,7 @@ async function createWindow(logger?: Logger): Promise { try { await win.loadURL(rendererUrl); } catch (error) { - logger?.error("window.load_url_failed", { + args.logger?.error("window.load_url_failed", { windowId: win.id, url: rendererUrl, err: toErrorMessage(error), @@ -988,7 +997,25 @@ app.whenReady().then(async () => { logger.info("project.init", { projectRoot, baseRef, ensureExclude }); - const db = await openKvDb(adePaths.dbPath, logger); + const measureProjectInitStep = async ( + step: string, + task: () => Promise | T, + ): Promise => { + const startedAt = Date.now(); + try { + return await task(); + } finally { + logger.info("project.init_step", { + projectRoot, + step, + durationMs: Date.now() - startedAt, + }); + } + }; + + const db = await measureProjectInitStep("db_open", () => + openKvDb(adePaths.dbPath, logger), + ); const keybindingsService = createKeybindingsService({ db }); const agentToolsService = createAgentToolsService({ logger }); const devToolsService = createDevToolsService({ logger }); @@ -1109,7 +1136,9 @@ app.whenReady().then(async () => { }, logger, }); - await laneService.ensurePrimaryLane(); + await measureProjectInitStep("lane.ensure_primary", () => + laneService.ensurePrimaryLane(), + ); const laneEnvironmentService = createLaneEnvironmentService({ projectRoot, @@ -1743,14 +1772,6 @@ app.whenReady().then(async () => { memoryService, }); ctoStateServiceRef = ctoStateService; - try { - memoryFilesService.sync(); - } catch (err) { - logger.warn("memory_files.sync_failed", { - projectRoot, - error: err instanceof Error ? err.message : String(err), - }); - } const workerAgentService = createWorkerAgentService({ db, @@ -2154,6 +2175,23 @@ app.whenReady().then(async () => { delayMs, ); }; + + scheduleBackgroundProjectTask( + "memory.files.initial_sync", + () => + measureProjectInitStep("memory.files.initial_sync", () => { + memoryFilesService.sync(); + }), + (error) => { + logger.warn("memory_files.sync_failed", { + projectRoot, + error: error instanceof Error ? error.message : String(error), + }); + }, + 0, + "ADE_ENABLE_MEMORY_FILE_SYNC", + ); + const externalConnectionAuthService = createExternalConnectionAuthService({ adeDir: adePaths.adeDir, logger, @@ -2327,7 +2365,18 @@ app.whenReady().then(async () => { }), }); syncServiceRef = syncService; - await syncService.initialize(); + scheduleBackgroundProjectTask( + "sync.initialize", + () => measureProjectInitStep("sync.initialize", () => syncService.initialize()), + (error) => { + logger.warn("sync.initialize_failed", { + projectRoot, + error: error instanceof Error ? error.message : String(error), + }); + }, + 0, + "ADE_ENABLE_SYNC_INIT", + ); scheduleBackgroundProjectTask( "missions.process_queue", () => { @@ -2908,19 +2957,21 @@ app.whenReady().then(async () => { }); conn.on("error", () => {}); // ignore connection errors }); - await new Promise((resolve, reject) => { - const handleListening = () => { - mcpSocketServer.off("error", handleError); - resolve(); - }; - const handleError = (error: Error) => { - mcpSocketServer.off("listening", handleListening); - reject(error); - }; - mcpSocketServer.once("listening", handleListening); - mcpSocketServer.once("error", handleError); - mcpSocketServer.listen(mcpSocketPath); - }); + await measureProjectInitStep("mcp.socket_server_start", () => + new Promise((resolve, reject) => { + const handleListening = () => { + mcpSocketServer.off("error", handleError); + resolve(); + }; + const handleError = (error: Error) => { + mcpSocketServer.off("listening", handleListening); + reject(error); + }; + mcpSocketServer.once("listening", handleListening); + mcpSocketServer.once("error", handleError); + mcpSocketServer.listen(mcpSocketPath); + }), + ); logger.info("mcp.socket_server_started", { socketPath: mcpSocketPath }); return { @@ -3397,14 +3448,205 @@ app.whenReady().then(async () => { dormantContext = createDormantProjectContext(); + let autoUpdateService: ReturnType | null = null; + let shutdownPromise: Promise | null = null; + let shutdownRequested = false; + let shutdownFinalized = false; + let quitWarningAcknowledged = false; + let shutdownForceTimer: NodeJS.Timeout | null = null; + + const shutdownOpenCodeServersBestEffort = (): void => { + try { + const { shutdownOpenCodeServers } = require("./services/opencode/openCodeServerManager"); + shutdownOpenCodeServers(); + } catch { + // ignore if module not loaded + } + }; + + const runImmediateProcessCleanup = (reason: string): void => { + try { + autoUpdateService?.dispose(); + } catch { + // ignore + } + + const contexts = new Set(projectContexts.values()); + contexts.add(getActiveContext()); + + for (const ctx of contexts) { + try { + ctx.aiOrchestratorService?.dispose?.(); + } catch { + // ignore + } + try { + ctx.automationService?.dispose?.(); + } catch { + // ignore + } + try { + ctx.testService?.disposeAll?.(); + } catch { + // ignore + } + try { + ctx.processService?.disposeAll?.(); + } catch { + // ignore + } + try { + ctx.ptyService?.disposeAll?.(); + } catch { + // ignore + } + try { + ctx.agentChatService?.forceDisposeAll?.(); + } catch { + // ignore + } + try { + ctx.db?.flushNow?.(); + } catch { + // ignore + } + try { + ctx.logger.info("app.process_cleanup_now", { + reason, + projectRoot: ctx.project?.rootPath ?? null, + }); + } catch { + // ignore + } + } + + shutdownOpenCodeServersBestEffort(); + }; + + const finalizeAppExit = (exitCode: number): void => { + if (shutdownFinalized) return; + shutdownFinalized = true; + if (shutdownForceTimer) { + clearTimeout(shutdownForceTimer); + shutdownForceTimer = null; + } + runImmediateProcessCleanup("process_exit_finalize"); + if (app.isReady()) { + app.exit(exitCode); + return; + } + process.exit(exitCode); + }; + + const requestAppShutdown = (args: { + reason: string; + exitCode?: number; + fastKillFirst?: boolean; + forceAfterMs?: number; + }): void => { + if (shutdownFinalized || shutdownPromise) return; + shutdownRequested = true; + quitWarningAcknowledged = true; + + const exitCode = args.exitCode ?? 0; + const shutdownLogger = getActiveContext().logger; + const previousRoot = getActiveContext().project?.rootPath ?? ""; + + if (args.fastKillFirst) { + runImmediateProcessCleanup(`fast_kill:${args.reason}`); + } + + const forceAfterMs = args.forceAfterMs ?? 8_000; + shutdownForceTimer = setTimeout(() => { + shutdownLogger.error("app.shutdown_force_exit", { + reason: args.reason, + forceAfterMs, + }); + runImmediateProcessCleanup(`forced:${args.reason}`); + finalizeAppExit(exitCode); + }, forceAfterMs); + shutdownForceTimer.unref?.(); + + shutdownPromise = (async () => { + shutdownLogger.info("app.shutdown_start", { + reason: args.reason, + exitCode, + fastKillFirst: args.fastKillFirst ?? false, + }); + + try { + autoUpdateService?.dispose(); + } catch { + // ignore + } + setActiveProject(null); + dormantContext = createDormantProjectContext(previousRoot); + + try { + await closeAllProjectContexts(); + } catch (error) { + shutdownLogger.error("app.shutdown_cleanup_failed", { + reason: args.reason, + error: error instanceof Error ? error.message : String(error), + }); + } finally { + runImmediateProcessCleanup(`complete:${args.reason}`); + } + })().finally(() => { + finalizeAppExit(exitCode); + }); + }; + + const confirmQuitWarning = (): boolean => { + if (quitWarningAcknowledged || shutdownRequested) return true; + const options = { + type: "warning" as const, + buttons: ["Keep ADE open", "Quit ADE"], + defaultId: 0, + cancelId: 0, + noLink: true, + title: "Quit ADE?", + message: "Save your work before closing ADE.", + detail: + "Quitting ADE will end any running agents and stop background processes started by ADE, including OpenCode servers, terminal sessions, and test runs.", + }; + const parentWindow = BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const response = parentWindow + ? dialog.showMessageBoxSync(parentWindow, options) + : dialog.showMessageBoxSync(options); + if (response !== 1) { + return false; + } + quitWarningAcknowledged = true; + return true; + }; + + const handleMainWindowCloseRequested = ( + _win: BrowserWindow, + event: Electron.Event, + ): void => { + if (shutdownRequested) return; + if (BrowserWindow.getAllWindows().length > 1) return; + event.preventDefault(); + if (!confirmQuitWarning()) return; + requestAppShutdown({ reason: "window_close", exitCode: 0 }); + }; + const FILE_LIMIT_CODES = new Set(["EMFILE", "ENFILE"]); let emfileWarned = false; process.on("uncaughtException", (err) => { if (FILE_LIMIT_CODES.has((err as NodeJS.ErrnoException).code ?? "")) return; - getActiveContext().logger.error("process.uncaught_exception", { + const logger = getActiveContext().logger; + logger.error("process.uncaught_exception", { err: String(err), stack: err instanceof Error ? err.stack : undefined, }); + requestAppShutdown({ + reason: "uncaught_exception", + exitCode: 1, + fastKillFirst: true, + forceAfterMs: 5_000, + }); }); process.on("unhandledRejection", (reason) => { const msg = String(reason); @@ -3430,6 +3672,28 @@ app.whenReady().then(async () => { name: details.name ?? null, }); }); + process.once("SIGINT", () => { + requestAppShutdown({ + reason: "signal_sigint", + exitCode: 130, + fastKillFirst: true, + forceAfterMs: 5_000, + }); + }); + process.once("SIGTERM", () => { + requestAppShutdown({ + reason: "signal_sigterm", + exitCode: 143, + fastKillFirst: true, + forceAfterMs: 5_000, + }); + }); + process.once("exit", () => { + runImmediateProcessCleanup("process_exit"); + }); + app.on("will-quit", () => { + runImmediateProcessCleanup("will_quit"); + }); // --- Auto-update service (global, not per-project) --- const updateLogger = createFileLogger( @@ -3439,11 +3703,19 @@ app.whenReady().then(async () => { tempRoot: app.getPath("temp"), logger: updateLogger, }); - const autoUpdateService = createAutoUpdateService({ + autoUpdateService = createAutoUpdateService({ logger: updateLogger, currentVersion: app.getVersion(), globalStatePath, }); + try { + const { recoverManagedOpenCodeOrphans } = require("./services/opencode/openCodeServerManager"); + await recoverManagedOpenCodeOrphans({ force: true, logger: getActiveContext().logger }); + } catch (error) { + getActiveContext().logger.warn("opencode.orphan_recovery_failed", { + error: error instanceof Error ? error.message : String(error), + }); + } autoUpdateService.onStateChange((snapshot) => { BrowserWindow.getAllWindows().forEach((win) => { win.webContents.send(IPC.updateEvent, snapshot); @@ -3476,36 +3748,26 @@ app.whenReady().then(async () => { } } - await createWindow(getActiveContext().logger); + await createWindow({ + logger: getActiveContext().logger, + onCloseRequested: handleMainWindowCloseRequested, + }); app.on("activate", async () => { if (BrowserWindow.getAllWindows().length === 0) { - await createWindow(getActiveContext().logger); + await createWindow({ + logger: getActiveContext().logger, + onCloseRequested: handleMainWindowCloseRequested, + }); } }); - let quitAfterCleanup = false; app.on("before-quit", (event) => { - if (quitAfterCleanup) return; - quitAfterCleanup = true; + if (shutdownFinalized) return; event.preventDefault(); - const current = getActiveContext(); - const previousRoot = current.project?.rootPath; - current.logger.info("app.before_quit"); - // Kill any remaining OpenCode servers before quitting. - try { - const { shutdownOpenCodeServers } = require("./services/opencode/openCodeServerManager"); - shutdownOpenCodeServers(); - } catch { /* ignore if module not loaded */ } - setActiveProject(null); - dormantContext = createDormantProjectContext(previousRoot); - void closeAllProjectContexts() - .catch(() => { - // ignore - }) - .finally(() => { - app.quit(); - }); + if (shutdownRequested) return; + if (!confirmQuitWarning()) return; + requestAppShutdown({ reason: "before_quit", exitCode: 0 }); }); }); diff --git a/apps/desktop/src/main/services/ai/claudeModelUtils.test.ts b/apps/desktop/src/main/services/ai/claudeModelUtils.test.ts new file mode 100644 index 000000000..30d184161 --- /dev/null +++ b/apps/desktop/src/main/services/ai/claudeModelUtils.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from "vitest"; +import { resolveClaudeCliModel } from "./claudeModelUtils"; + +describe("resolveClaudeCliModel", () => { + it("normalizes the Opus 1M aliases without matching larger numeric suffixes", () => { + expect(resolveClaudeCliModel("claude-opus-4-6-1m")).toBe("opus[1m]"); + expect(resolveClaudeCliModel("claude-opus-4-6[1m]")).toBe("opus[1m]"); + expect(resolveClaudeCliModel("opus-11m")).toBe("opus"); + }); +}); diff --git a/apps/desktop/src/main/services/ai/claudeModelUtils.ts b/apps/desktop/src/main/services/ai/claudeModelUtils.ts index d51e93907..1b6161769 100644 --- a/apps/desktop/src/main/services/ai/claudeModelUtils.ts +++ b/apps/desktop/src/main/services/ai/claudeModelUtils.ts @@ -1,6 +1,6 @@ import { getDefaultModelDescriptor, getModelById, resolveModelAlias } from "../../../shared/modelRegistry"; -export type ClaudeCliModelAlias = "opus" | "sonnet" | "haiku"; +export type ClaudeCliModelAlias = "opus" | "opus[1m]" | "sonnet" | "haiku"; const CLAUDE_CLI_MODEL_ALIAS_MAP: Record = { opus: "opus", @@ -8,6 +8,12 @@ const CLAUDE_CLI_MODEL_ALIAS_MAP: Record = { "claude-opus-4-6": "opus", "anthropic/claude-opus-4-6": "opus", "anthropic/claude-opus-4-6-api": "opus", + "opus[1m]": "opus[1m]", + "opus-1m": "opus[1m]", + "opus-4-6-1m": "opus[1m]", + "claude-opus-4-6[1m]": "opus[1m]", + "claude-opus-4-6-1m": "opus[1m]", + "anthropic/claude-opus-4-6-1m": "opus[1m]", sonnet: "sonnet", "sonnet-4-6": "sonnet", "sonnet-4-5": "sonnet", @@ -26,7 +32,7 @@ const CLAUDE_CLI_MODEL_ALIAS_MAP: Record = { /** * Normalize arbitrary Claude model strings into the CLI-safe aliases expected - * by Claude Code (`opus`, `sonnet`, `haiku`) where possible. + * by Claude Code (`opus`, `opus[1m]`, `sonnet`, `haiku`) where possible. */ export function resolveClaudeCliModel(model: string | null | undefined): string { const raw = String(model ?? "").trim(); @@ -36,6 +42,11 @@ export function resolveClaudeCliModel(model: string | null | undefined): string const mapped = CLAUDE_CLI_MODEL_ALIAS_MAP[normalized]; if (mapped) return mapped; + const hasOpus1mToken = + normalized.includes("[1m]") || /(^|[^0-9])1m($|[^0-9])/.test(normalized); + if (normalized.includes("opus") && hasOpus1mToken) { + return "opus[1m]"; + } if (normalized.includes("sonnet")) return "sonnet"; if (normalized.includes("opus")) return "opus"; if (normalized.includes("haiku")) return "haiku"; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 82ff6f258..18afef966 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3,6 +3,11 @@ import os from "node:os"; import path from "node:path"; import { unstable_v2_createSession, unstable_v2_resumeSession } from "@anthropic-ai/claude-agent-sdk"; import { buildOpenCodePromptParts, startOpenCodeSession } from "../opencode/openCodeRuntime"; +import { + clearOpenCodeInventoryCache, + peekOpenCodeInventoryCache, + probeOpenCodeProviderInventory, +} from "../opencode/openCodeInventory"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const generateText = vi.fn(); @@ -336,6 +341,18 @@ vi.mock("../opencode/openCodeRuntime", () => ({ }), })); +vi.mock("../opencode/openCodeInventory", () => ({ + clearOpenCodeInventoryCache: vi.fn(), + shutdownInventoryServer: vi.fn(), + peekOpenCodeInventoryCache: vi.fn(() => null), + probeOpenCodeProviderInventory: vi.fn(async () => ({ + modelIds: ["opencode/openai/gpt-5.4"], + providers: [], + error: null, + descriptors: [], + })), +})); + vi.mock("../ai/tools/universalTools", () => ({ createUniversalToolSet: vi.fn((): Record => ({ readFile: { description: "stub", parameters: { type: "object", properties: {} }, execute: vi.fn() }, @@ -824,6 +841,16 @@ beforeEach(() => { vi.mocked(unstable_v2_createSession).mockReset(); vi.mocked(detectAllAuth).mockResolvedValue([]); vi.mocked(parseAgentChatTranscript).mockReturnValue([]); + vi.mocked(clearOpenCodeInventoryCache).mockClear(); + vi.mocked(peekOpenCodeInventoryCache).mockReset(); + vi.mocked(peekOpenCodeInventoryCache).mockReturnValue(null); + vi.mocked(probeOpenCodeProviderInventory).mockReset(); + vi.mocked(probeOpenCodeProviderInventory).mockResolvedValue({ + modelIds: ["opencode/openai/gpt-5.4"], + providers: [], + error: null, + descriptors: [], + }); replaceDynamicOpenCodeModelDescriptors([]); }); @@ -3010,6 +3037,43 @@ describe("createAgentChatService", () => { }); }); + describe("forceDisposeAll", () => { + it("rejects active runSessionTurn calls during shutdown", async () => { + let releaseStream!: () => void; + const streamGate = new Promise((resolve) => { + releaseStream = () => resolve(); + }); + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () { + yield { type: "text-delta", textDelta: "Still working" }; + await streamGate; + })(), + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "", + modelId: "opencode/anthropic/claude-sonnet-4-6", + }); + + const turn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep running", + timeoutMs: null, + }); + const turnExpectation = expect(turn).rejects.toThrow(/shutdown/i); + + try { + service.forceDisposeAll(); + await turnExpectation; + } finally { + releaseStream(); + } + }); + }); + describe("deleteSession", () => { it("removes persisted chat artifacts and the stored session row", async () => { const { service, sessionService } = createService(); @@ -4074,10 +4138,14 @@ describe("createAgentChatService", () => { // -------------------------------------------------------------------------- describe("getAvailableModels", () => { - it("returns an array for opencode provider", async () => { + it("warms OpenCode models on a passive cache miss", async () => { + clearOpenCodeInventoryCache(); const { service } = createService(); const models = await service.getAvailableModels({ provider: "opencode" }); - expect(Array.isArray(models)).toBe(true); + + expect(peekOpenCodeInventoryCache).toHaveBeenCalled(); + expect(probeOpenCodeProviderInventory).toHaveBeenCalled(); + expect(models.map((model) => model.id)).toContain("opencode/openai/gpt-5.4"); }); it("returns an array for codex provider", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ff2e421c9..74d33e819 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -172,7 +172,7 @@ import { type DiscoveredLocalModelEntry, type OpenCodeSessionHandle, } from "../opencode/openCodeRuntime"; -import { probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; +import { peekOpenCodeInventoryCache, probeOpenCodeProviderInventory } from "../opencode/openCodeInventory"; import { inspectLocalProvider, type DiscoveredLocalModel } from "../ai/localModelDiscovery"; import { resolveAdeMcpServerLaunch, resolveOpenCodeRuntimeRoot } from "../orchestrator/providerOrchestratorAdapter"; import type { McpServer, PermissionOption, RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; @@ -893,7 +893,8 @@ const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; // stream events are emitted while the SDK waits for tool results. The user // can always interrupt manually if something is genuinely stuck. const SESSION_INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes -const SESSION_CLEANUP_INTERVAL_MS = 60 * 1000; // check every 60 seconds +const OPENCODE_SESSION_INACTIVITY_TIMEOUT_MS = 60 * 1000; // 1 minute +const SESSION_CLEANUP_INTERVAL_MS = 15 * 1000; // check every 15 seconds const MAX_CONCURRENT_ACTIVE_RUNTIMES = 5; const MAX_RECENT_CONVERSATION_ENTRIES = 50; const MAX_SESSION_MAP_ENTRIES = 200; @@ -4862,6 +4863,16 @@ export function createAgentChatService(args: { } }; + const rejectActiveSessionTurnCollector = (sessionId: string, message: string): void => { + const activeCollector = sessionTurnCollectors.get(sessionId); + if (!activeCollector) return; + if (activeCollector.timeout) { + clearTimeout(activeCollector.timeout); + } + sessionTurnCollectors.delete(sessionId); + activeCollector.reject(new Error(message)); + }; + const persistChatState = (managed: ManagedChatSession): void => { // Tombstoned sessions (deleted while async work was in flight) must not be // re-persisted — otherwise the file recreates after deleteSession removed it. @@ -5668,7 +5679,7 @@ export function createAgentChatService(args: { /** Tear down the active runtime, releasing all resources and cancelling pending approvals. */ const teardownRuntime = ( managed: ManagedChatSession, - openCodeReason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "project_close" | "budget_eviction" | "paused_run" | "shutdown" = "handle_close", + openCodeReason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "project_close" | "budget_eviction" | "pool_compaction" | "paused_run" | "shutdown" = "handle_close", ): void => { flushBufferedReasoning(managed); flushBufferedText(managed); @@ -5784,6 +5795,13 @@ export function createAgentChatService(args: { persistChatState(managed); }; + const getSessionInactivityTimeoutMs = (managed: ManagedChatSession): number => { + if (managed.runtime?.kind === "opencode") { + return OPENCODE_SESSION_INACTIVITY_TIMEOUT_MS; + } + return SESSION_INACTIVITY_TIMEOUT_MS; + }; + const maybeGenerateSessionSummary = async ( managed: ManagedChatSession, deterministicSummary: string | null @@ -13050,9 +13068,13 @@ export function createAgentChatService(args: { }); }; - const availableModelsRequests = new Map>(); + const availableModelsRequests = new Map>(); - const loadAvailableModels = async (provider: AgentChatProvider): Promise => { + const loadAvailableModels = async (args: { + provider: AgentChatProvider; + activateRuntime?: boolean; + }): Promise => { + const provider = args.provider; if (provider === "codex") { return listCodexModelsFromAppServer(); } @@ -13082,12 +13104,37 @@ export function createAgentChatService(args: { if (provider === "opencode") { try { - const { modelIds, error } = await probeOpenCodeProviderInventory({ - projectRoot, - projectConfig: projectConfigService.get().effective, - logger, - force: false, - }); + const effectiveConfig = projectConfigService.get().effective; + let modelIds: string[]; + let error: string | null; + if (args.activateRuntime) { + const inventory = await probeOpenCodeProviderInventory({ + projectRoot, + projectConfig: effectiveConfig, + logger, + force: false, + }); + modelIds = inventory.modelIds; + error = inventory.error; + } else { + const peeked = peekOpenCodeInventoryCache({ + projectRoot, + projectConfig: effectiveConfig, + }); + if (peeked) { + modelIds = peeked.modelIds; + error = peeked.error; + } else { + const inventory = await probeOpenCodeProviderInventory({ + projectRoot, + projectConfig: effectiveConfig, + logger, + force: false, + }); + modelIds = inventory.modelIds; + error = inventory.error; + } + } if (error) { logger.warn("agent_chat.opencode_inventory_empty", { error }); } @@ -13150,19 +13197,26 @@ export function createAgentChatService(args: { return []; }; - const getAvailableModels = async ({ provider }: { provider: AgentChatProvider }): Promise => { - const existingRequest = availableModelsRequests.get(provider); + const getAvailableModels = async ({ + provider, + activateRuntime, + }: { + provider: AgentChatProvider; + activateRuntime?: boolean; + }): Promise => { + const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}`; + const existingRequest = availableModelsRequests.get(requestKey); if (existingRequest) { return existingRequest; } - const request = loadAvailableModels(provider); - availableModelsRequests.set(provider, request); + const request = loadAvailableModels({ provider, activateRuntime }); + availableModelsRequests.set(requestKey, request); try { return await request; } finally { - if (availableModelsRequests.get(provider) === request) { - availableModelsRequests.delete(provider); + if (availableModelsRequests.get(requestKey) === request) { + availableModelsRequests.delete(requestKey); } } }; @@ -13255,14 +13309,7 @@ export function createAgentChatService(args: { const current = sessionService.get(trimmedSessionId); if (!current) return; - const activeCollector = sessionTurnCollectors.get(trimmedSessionId); - if (activeCollector) { - if (activeCollector.timeout) { - clearTimeout(activeCollector.timeout); - } - sessionTurnCollectors.delete(trimmedSessionId); - activeCollector.reject(new Error(`Chat session '${trimmedSessionId}' was deleted.`)); - } + rejectActiveSessionTurnCollector(trimmedSessionId, `Chat session '${trimmedSessionId}' was deleted.`); const managed = managedSessions.get(trimmedSessionId); if (managed) { @@ -13309,6 +13356,30 @@ export function createAgentChatService(args: { } }; + const forceDisposeAll = (): void => { + clearInterval(sessionCleanupTimer); + for (const sessionId of [...sessionTurnCollectors.keys()]) { + rejectActiveSessionTurnCollector(sessionId, `Chat session '${sessionId}' was closed during shutdown.`); + } + for (const [sessionId, managed] of managedSessions) { + try { + managed.deleted = true; + clearSubagentSnapshots(sessionId); + for (const pending of managed.localPendingInputs.values()) { + pending.resolve({ decision: "cancel" }); + } + managed.localPendingInputs.clear(); + managed.closed = true; + managed.endedNotified = true; + managed.ctoSessionStartedAt = null; + teardownRuntime(managed, "shutdown"); + } catch { + // ignore emergency shutdown failures + } + } + managedSessions.clear(); + }; + // --- Session inactivity cleanup --- const sessionCleanupTimer = setInterval(() => { const now = Date.now(); @@ -13318,7 +13389,7 @@ export function createAgentChatService(args: { && !managed.closed && managed.session.status === "idle" && !hasLivePendingInput(managed) - && now - managed.lastActivityTimestamp > SESSION_INACTIVITY_TIMEOUT_MS + && now - managed.lastActivityTimestamp > getSessionInactivityTimeoutMs(managed) ) { teardownRuntime(managed, "idle_ttl"); } @@ -14094,6 +14165,7 @@ export function createAgentChatService(args: { dispose, deleteSession, disposeAll, + forceDisposeAll, updateSession, warmupModel, listSubagents, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 68d27328d..3abdb682f 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -10,6 +10,8 @@ import { getModelById } from "../../../shared/modelRegistry"; import { buildPrAiResolutionContextKey } from "../../../shared/types"; import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../prs/prIssueResolver"; import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; +import { browseProjectDirectories } from "../projects/projectBrowserService"; +import { getProjectDetail } from "../projects/projectDetailService"; import { runGit } from "../git/git"; import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types"; import { toRecentProjectSummary } from "../projects/recentProjectSummary"; @@ -235,6 +237,9 @@ import type { ProjectConfigSnapshot, ProjectConfigTrust, ProjectConfigValidationResult, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, ProjectInfo, RecentProjectSummary, PtyCreateArgs, @@ -2047,6 +2052,21 @@ export function registerIpc({ } ); + ipcMain.handle( + IPC.projectBrowseDirectories, + async (_event, args: ProjectBrowseInput = {}): Promise => + browseProjectDirectories(args) + ); + + ipcMain.handle( + IPC.projectGetDetail, + async (_event, args: { rootPath: string }): Promise => { + const rootPath = typeof args?.rootPath === "string" ? args.rootPath.trim() : ""; + if (!rootPath) throw new Error("rootPath is required"); + return getProjectDetail(rootPath, { globalStatePath }); + } + ); + ipcMain.handle(IPC.projectOpenAdeFolder, async (): Promise => { const ctx = getCtx(); await shell.openPath(ctx.adeDir); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts index 0ac113c00..6d793b653 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.test.ts @@ -58,6 +58,7 @@ vi.mock("./openCodeServerManager", () => ({ import { clearOpenCodeInventoryCache, + peekOpenCodeInventoryCache, probeOpenCodeProviderInventory, shutdownInventoryServer, } from "./openCodeInventory"; @@ -123,4 +124,65 @@ describe("openCodeInventory", () => { expect(result.modelIds).toContain("opencode/ollama/llama-3.1"); expect(result.descriptors).toHaveLength(1); }); + + it("allows passive cache reads after a probe warmed inventory with discovered local models", async () => { + const logger = { warn: vi.fn() } as any; + mockState.providerList.mockResolvedValueOnce({ + data: { + connected: ["openai", "ollama"], + all: [ + { + id: "openai", + name: "OpenAI", + models: { + "gpt-5.4": { + id: "gpt-5.4", + name: "GPT-5.4", + tool_call: true, + reasoning: true, + limit: { context: 200000, output: 4000 }, + }, + }, + }, + { + id: "ollama", + name: "Ollama", + models: { + "llama-3.1": { + id: "llama-3.1", + name: "Llama 3.1", + tool_call: true, + reasoning: true, + limit: { context: 128000, output: 4096 }, + }, + }, + }, + ], + }, + } as any); + + await probeOpenCodeProviderInventory({ + projectRoot: "/repo", + projectConfig: { ai: { localProviders: { ollama: { enabled: true } } } }, + logger, + force: true, + discoveredLocalModels: [ + { + provider: "ollama", + modelId: "llama-3.1", + loaded: true, + }, + ], + }); + + expect(peekOpenCodeInventoryCache({ + projectRoot: "/repo", + projectConfig: { ai: { localProviders: { ollama: { enabled: true } } } }, + })).toEqual(expect.objectContaining({ + modelIds: expect.arrayContaining([ + "opencode/openai/gpt-5.4", + "opencode/ollama/llama-3.1", + ]), + })); + }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeInventory.ts b/apps/desktop/src/main/services/opencode/openCodeInventory.ts index 9fd15b3c9..89cd59051 100644 --- a/apps/desktop/src/main/services/opencode/openCodeInventory.ts +++ b/apps/desktop/src/main/services/opencode/openCodeInventory.ts @@ -18,7 +18,7 @@ import { acquireSharedOpenCodeServer, shutdownOpenCodeServers } from "./openCode const TTL_MS = 60_000; /** How long an idle inventory server stays alive before being killed. */ -const SERVER_IDLE_TTL_MS = 30_000; +const SERVER_IDLE_TTL_MS = 10_000; /** Metadata for an OpenCode provider as returned by provider.list(). */ export type OpenCodeProviderInfo = { @@ -32,6 +32,7 @@ type CacheEntry = { cachedAt: number; projectRoot: string; configFingerprint: string; + passiveConfigFingerprint: string; modelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null; @@ -72,7 +73,7 @@ function extractVariantKeys(model: Record): string[] { /** * Lists connected providers/models via a shared OpenCode server, updates dynamic registry entries, and caches results. - * Reuses a single server across probes (30s idle TTL). Concurrent calls are deduplicated. + * Reuses a single server across probes (see SERVER_IDLE_TTL_MS for the idle TTL). Concurrent calls are deduplicated. */ export async function probeOpenCodeProviderInventory(args: { projectRoot: string; @@ -89,6 +90,7 @@ export async function probeOpenCodeProviderInventory(args: { } const fp = fingerprintOpenCodeConfig(args.projectConfig, args.discoveredLocalModels); + const passiveFp = fingerprintOpenCodeConfig(args.projectConfig); const now = Date.now(); if ( !args.force @@ -224,6 +226,7 @@ export async function probeOpenCodeProviderInventory(args: { cachedAt: Date.now(), projectRoot: args.projectRoot, configFingerprint: fp, + passiveConfigFingerprint: passiveFp, modelIds, providers: providerInfos, error: null, @@ -240,6 +243,7 @@ export async function probeOpenCodeProviderInventory(args: { cachedAt: Date.now(), projectRoot: args.projectRoot, configFingerprint: fp, + passiveConfigFingerprint: passiveFp, modelIds: [], providers: [], error: message, @@ -261,6 +265,7 @@ export function peekOpenCodeInventoryCache(args: { }): { modelIds: string[]; providers: OpenCodeProviderInfo[]; error: string | null } | null { const fp = fingerprintOpenCodeConfig(args.projectConfig); if (!inventoryCache) return null; - if (inventoryCache.projectRoot !== args.projectRoot || inventoryCache.configFingerprint !== fp) return null; + if (inventoryCache.projectRoot !== args.projectRoot) return null; + if (inventoryCache.passiveConfigFingerprint !== fp && inventoryCache.configFingerprint !== fp) return null; return { modelIds: inventoryCache.modelIds, providers: inventoryCache.providers, error: inventoryCache.error }; } diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts index 4cba94752..10764ec64 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.test.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mockState = vi.hoisted(() => ({ @@ -11,10 +12,12 @@ vi.mock("./openCodeBinaryManager", () => ({ import { __buildOpenCodeServeLaunchSpecForTests, __resetOpenCodeServerManagerForTests, + __setOpenCodeProcessControllerForTests, __setOpenCodeServerLauncherForTests, acquireDedicatedOpenCodeServer, acquireSharedOpenCodeServer, getOpenCodeRuntimeDiagnostics, + recoverManagedOpenCodeOrphans, } from "./openCodeServerManager"; describe("openCodeServerManager", () => { @@ -41,6 +44,12 @@ describe("openCodeServerManager", () => { vi.useFakeTimers(); mockState.created.length = 0; __resetOpenCodeServerManagerForTests(); + __setOpenCodeProcessControllerForTests({ + listProcesses: () => [], + isProcessAlive: () => false, + killProcess: () => {}, + waitForMs: async () => {}, + }); __setOpenCodeServerLauncherForTests(async ({ port }) => { const close = vi.fn(); const entry = { @@ -93,6 +102,44 @@ describe("openCodeServerManager", () => { expect(getOpenCodeRuntimeDiagnostics().sharedCount).toBe(0); }); + it("coalesces parallel shared acquires into a single launched server", async () => { + let releaseCreate!: () => void; + const createGate = new Promise((resolve) => { + releaseCreate = resolve; + }); + __setOpenCodeServerLauncherForTests(async ({ port }) => { + await createGate; + const close = vi.fn(); + const entry = { + close, + url: `http://127.0.0.1:${port}`, + }; + mockState.created.push(entry); + return entry; + }); + + const config = { share: "disabled", autoupdate: false, snapshot: false } as const; + const leasePromiseA = acquireSharedOpenCodeServer({ + config, + key: "shared:parallel", + ownerKind: "chat", + }); + const leasePromiseB = acquireSharedOpenCodeServer({ + config, + key: "shared:parallel", + ownerKind: "chat", + }); + + releaseCreate(); + const [leaseA, leaseB] = await Promise.all([leasePromiseA, leasePromiseB]); + + expect(mockState.created).toHaveLength(1); + expect(leaseA.url).toBe(leaseB.url); + + leaseA.release("handle_close"); + leaseB.release("handle_close"); + }); + it("treats semantically identical shared configs as the same runtime even when key order differs", async () => { const configA = { share: "disabled", @@ -182,6 +229,45 @@ describe("openCodeServerManager", () => { leaseB.release("handle_close"); }); + it("compacts idle shared servers from older configs as soon as a new shared runtime is acquired", async () => { + const configA = { + share: "disabled", + autoupdate: false, + snapshot: false, + provider: { + openai: { options: { apiKey: "one" } }, + }, + } as const; + const configB = { + share: "disabled", + autoupdate: false, + snapshot: false, + provider: { + openai: { options: { apiKey: "two" } }, + }, + } as const; + + const leaseA = await acquireSharedOpenCodeServer({ + config: configA, + key: "shared:a", + ownerKind: "chat", + idleTtlMs: 60_000, + }); + leaseA.release("handle_close"); + expect(mockState.created[0]?.close).not.toHaveBeenCalled(); + + const leaseB = await acquireSharedOpenCodeServer({ + config: configB, + key: "shared:b", + ownerKind: "chat", + idleTtlMs: 60_000, + }); + + expect(mockState.created[0]?.close).toHaveBeenCalledTimes(1); + expect(mockState.created).toHaveLength(2); + leaseB.release("handle_close"); + }); + it("shuts down a shared server immediately when its last lease closes with an error", async () => { const config = { share: "disabled", autoupdate: false, snapshot: false } as const; const lease = await acquireSharedOpenCodeServer({ @@ -295,7 +381,134 @@ describe("openCodeServerManager", () => { expect(spec.env.OPENCODE_CONFIG_DIR).toBe("/tmp/ade-opencode-test-home/xdg-v1/config/opencode"); expect(spec.env.OPENCODE_DISABLE_PROJECT_CONFIG).toBe("1"); expect(spec.env.OPENCODE_CONFIG_CONTENT).toBe(JSON.stringify(config)); + expect(spec.env.ADE_OPENCODE_MANAGED).toBe("1"); + expect(spec.env.ADE_OPENCODE_OWNER_PID).toBe(String(process.pid)); expect(spec.env.OPENCODE_API_KEY).toBeUndefined(); expect(spec.env.OPENCODE_BIN_PATH).toBeUndefined(); }); + + it("reaps orphaned ADE-managed OpenCode processes and skips ones with a live owner", async () => { + let orphanAlive = true; + const killProcess = vi.fn((pid: number, signal: NodeJS.Signals) => { + if (pid === 4101 && signal === "SIGKILL") { + orphanAlive = false; + } + }); + const homeDir = os.homedir(); + __setOpenCodeProcessControllerForTests({ + listProcesses: () => ([ + { + pid: 4101, + ppid: 1, + command: [ + "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62298", + "OPENCODE_DISABLE_PROJECT_CONFIG=1", + `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, + ].join(" "), + }, + { + pid: 4102, + ppid: 1, + command: [ + "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62299", + "OPENCODE_DISABLE_PROJECT_CONFIG=1", + "ADE_OPENCODE_MANAGED=1", + "ADE_OPENCODE_OWNER_PID=7788", + `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, + ].join(" "), + }, + ]), + isProcessAlive: (pid) => { + if (pid === 4101) return orphanAlive; + return pid === 4102 || pid === 7788; + }, + killProcess, + }); + const result = await recoverManagedOpenCodeOrphans(); + + expect(result.recoveredPids).toEqual([4101]); + expect(result.skippedPids).toEqual([4102]); + expect(killProcess).toHaveBeenCalledWith(4101, "SIGTERM"); + expect(killProcess).toHaveBeenCalledWith(4101, "SIGKILL"); + expect(killProcess).not.toHaveBeenCalledWith(4102, "SIGTERM"); + }); + + it("does not mark stubborn orphaned processes as recovered", async () => { + const logger = { warn: vi.fn() } as any; + const killProcess = vi.fn(); + const homeDir = os.homedir(); + __setOpenCodeProcessControllerForTests({ + listProcesses: () => ([ + { + pid: 6101, + ppid: 1, + command: [ + "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62301", + "OPENCODE_DISABLE_PROJECT_CONFIG=1", + `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, + ].join(" "), + }, + ]), + isProcessAlive: (pid) => pid === 6101, + killProcess, + }); + + const result = await recoverManagedOpenCodeOrphans({ force: true, logger }); + + expect(result.recoveredPids).toEqual([]); + expect(result.skippedPids).toEqual([6101]); + expect(killProcess).toHaveBeenCalledWith(6101, "SIGTERM"); + expect(killProcess).toHaveBeenCalledWith(6101, "SIGKILL"); + expect(logger.warn).toHaveBeenCalledWith( + "opencode.server_orphan_recovery_failed", + expect.objectContaining({ pid: 6101 }), + ); + }); + + it("waits for an in-flight forced recovery before starting another forced scan", async () => { + let releaseFirstWait!: () => void; + const firstWaitGate = new Promise((resolve) => { + releaseFirstWait = resolve; + }); + let orphanAlive = true; + const homeDir = os.homedir(); + const listProcesses = vi.fn() + .mockImplementationOnce(() => ([ + { + pid: 5101, + ppid: 1, + command: [ + "/Users/admin/.opencode/bin/opencode serve --hostname=127.0.0.1 --port=62301", + "OPENCODE_DISABLE_PROJECT_CONFIG=1", + `XDG_CONFIG_HOME=${homeDir}/.ade/opencode-runtime/xdg-v1/config`, + ].join(" "), + }, + ])) + .mockImplementationOnce(() => []); + const killProcess = vi.fn(); + __setOpenCodeProcessControllerForTests({ + listProcesses, + isProcessAlive: (pid) => pid === 5101 && orphanAlive, + killProcess, + waitForMs: async () => { + await firstWaitGate; + orphanAlive = false; + }, + }); + + const firstRecovery = recoverManagedOpenCodeOrphans({ force: true }); + const secondRecovery = recoverManagedOpenCodeOrphans({ force: true }); + + expect(listProcesses).toHaveBeenCalledTimes(1); + + releaseFirstWait(); + + const [firstResult, secondResult] = await Promise.all([firstRecovery, secondRecovery]); + + expect(firstResult.recoveredPids).toEqual([5101]); + expect(secondResult.recoveredPids).toEqual([]); + expect(listProcesses).toHaveBeenCalledTimes(2); + expect(killProcess).toHaveBeenCalledTimes(1); + expect(killProcess).toHaveBeenCalledWith(5101, "SIGTERM"); + }); }); diff --git a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts index 96e91c0c2..84baf4724 100644 --- a/apps/desktop/src/main/services/opencode/openCodeServerManager.ts +++ b/apps/desktop/src/main/services/opencode/openCodeServerManager.ts @@ -19,6 +19,7 @@ export type OpenCodeServerShutdownReason = | "model_switch" | "project_close" | "budget_eviction" + | "pool_compaction" | "shutdown" | "config_changed" | "error"; @@ -50,6 +51,24 @@ type OpenCodeServeLaunchSpec = { xdgPaths: OpenCodeIsolationPaths; }; +type OpenCodeProcessSnapshot = { + pid: number; + ppid: number; + command: string; +}; + +type OpenCodeProcessController = { + listProcesses(): OpenCodeProcessSnapshot[]; + isProcessAlive(pid: number): boolean; + killProcess(pid: number, signal: NodeJS.Signals): void; + waitForMs(ms: number): Promise; +}; + +export type OpenCodeOrphanRecoveryResult = { + recoveredPids: number[]; + skippedPids: number[]; +}; + type OpenCodeServerLauncher = (args: OpenCodeServerLaunchArgs) => Promise; type ElectronLikeModule = { app?: { @@ -98,10 +117,13 @@ export type OpenCodeRuntimeDiagnosticsEntry = { }; const PORT_RETRY_ATTEMPTS = 3; -const DEFAULT_SHARED_IDLE_TTL_MS = 60_000; +const DEFAULT_SHARED_IDLE_TTL_MS = 15_000; const MAX_DEDICATED_OPENCODE_SERVERS = 6; const OPEN_CODE_SERVER_START_TIMEOUT_MS = 15_000; +const ORPHAN_RECOVERY_TERM_GRACE_MS = 250; const ADE_OPENCODE_XDG_LAYOUT_VERSION = 1; +const ADE_OPENCODE_MANAGED_ENV = "ADE_OPENCODE_MANAGED"; +const ADE_OPENCODE_OWNER_PID_ENV = "ADE_OPENCODE_OWNER_PID"; const sharedEntries = new Map(); const dedicatedEntries = new Map(); @@ -109,6 +131,76 @@ const inFlightEntries = new Map>(); const acquireQueues = new Map void>>(); let openCodeServerLauncher: OpenCodeServerLauncher = defaultOpenCodeServerLauncher; +function readLinuxProcessEnvironment(pid: number): string[] { + try { + const raw = fs.readFileSync(`/proc/${pid}/environ`, "utf8"); + return raw + .split("\0") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } catch { + return []; + } +} + +const defaultOpenCodeProcessController: OpenCodeProcessController = { + listProcesses(): OpenCodeProcessSnapshot[] { + if (process.platform === "win32") return []; + const psArgs = process.platform === "linux" + ? ["-ww", "-axo", "pid=,ppid=,command="] + : ["-wwE", "-axo", "pid=,ppid=,command="]; + const result = spawnSync("ps", psArgs, { + encoding: "utf8", + windowsHide: true, + }); + if (result.error || result.status !== 0 || typeof result.stdout !== "string") { + return []; + } + const rows: OpenCodeProcessSnapshot[] = []; + for (const line of result.stdout.split(/\r?\n/)) { + const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/); + if (!match) continue; + rows.push({ + pid: Number(match[1]), + ppid: Number(match[2]), + command: process.platform === "linux" + ? [match[3], ...readLinuxProcessEnvironment(Number(match[1]))].join(" ") + : match[3], + }); + } + return rows; + }, + isProcessAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + }, + killProcess(pid: number, signal: NodeJS.Signals): void { + if (!Number.isInteger(pid) || pid <= 0) return; + try { + process.kill(pid, signal); + } catch { + // ignore + } + }, + waitForMs(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + }, +}; +let openCodeProcessController: OpenCodeProcessController = defaultOpenCodeProcessController; +let orphanRecoveryPromise: Promise | null = null; +let lastOrphanRecoveryResult: OpenCodeOrphanRecoveryResult = { + recoveredPids: [], + skippedPids: [], +}; +let orphanRecoveryCompleted = false; + function serializeConfigFingerprint(config: OpenCodeConfig): string { return createHash("sha256").update(stableStringify(config)).digest("hex"); } @@ -203,6 +295,20 @@ function resolveAdeManagedOpenCodeRoot(): string { return path.resolve(os.tmpdir(), "ade-opencode-runtime"); } +function resolveHomeManagedOpenCodeRoot(): string | null { + const homeDir = os.homedir().trim(); + if (!homeDir.length) return null; + return path.resolve(homeDir, ".ade", "opencode-runtime"); +} + +function resolveKnownAdeManagedOpenCodeRoots(): string[] { + const roots = new Set(); + roots.add(resolveAdeManagedOpenCodeRoot()); + const homeRoot = resolveHomeManagedOpenCodeRoot(); + if (homeRoot) roots.add(homeRoot); + return [...roots]; +} + function resolveOpenCodeIsolationPaths(): OpenCodeIsolationPaths { const root = path.join( resolveAdeManagedOpenCodeRoot(), @@ -251,9 +357,131 @@ function buildIsolatedOpenCodeEnv( OPENCODE_CONFIG_DIR: path.join(paths.configHome, "opencode"), OPENCODE_CONFIG_CONTENT: JSON.stringify(config ?? {}), OPENCODE_DISABLE_PROJECT_CONFIG: "1", + [ADE_OPENCODE_MANAGED_ENV]: "1", + [ADE_OPENCODE_OWNER_PID_ENV]: String(process.pid), }; } +function buildManagedConfigMarkers(): string[] { + const markers = new Set(); + for (const root of resolveKnownAdeManagedOpenCodeRoots()) { + const xdgRoot = path.join(root, `xdg-v${ADE_OPENCODE_XDG_LAYOUT_VERSION}`); + markers.add(`XDG_CONFIG_HOME=${path.join(xdgRoot, "config")}`); + markers.add(`OPENCODE_CONFIG_DIR=${path.join(xdgRoot, "config", "opencode")}`); + } + return [...markers]; +} + +function isManagedOpenCodeServeCommand(command: string, configMarkers: string[]): boolean { + if (!/\bopencode(?:\.cmd|\.exe)?\b\s+serve\b/i.test(command)) return false; + if (!command.includes("OPENCODE_DISABLE_PROJECT_CONFIG=1")) return false; + if (command.includes(`${ADE_OPENCODE_MANAGED_ENV}=1`)) return true; + return configMarkers.some((marker) => command.includes(marker)); +} + +function parseManagedOwnerPid(command: string): number | null { + const match = command.match(new RegExp(`\\b${ADE_OPENCODE_OWNER_PID_ENV}=(\\d+)\\b`)); + if (!match) return null; + const pid = Number(match[1]); + return Number.isInteger(pid) && pid > 0 ? pid : null; +} + +async function waitForProcessExit(pid: number, timeoutMs: number): Promise { + const attempts = Math.max(1, Math.ceil(timeoutMs / 50)); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (!openCodeProcessController.isProcessAlive(pid)) { + return true; + } + await openCodeProcessController.waitForMs(50); + } + return !openCodeProcessController.isProcessAlive(pid); +} + +function pruneIdleSharedEntries( + excludeKey: string | null, + logger?: Logger | null, +): void { + for (const entry of [...sharedEntries.values()]) { + if (excludeKey && entry.key === excludeKey) continue; + if (entry.refCount > 0) continue; + shutdownEntry(entry, "pool_compaction", logger); + } +} + +export async function recoverManagedOpenCodeOrphans(args: { + force?: boolean; + logger?: Logger | null; +} = {}): Promise { + if (orphanRecoveryPromise) { + const inFlightResult = await orphanRecoveryPromise; + if (!args.force) { + return inFlightResult; + } + } + + if (!args.force && orphanRecoveryCompleted) { + return lastOrphanRecoveryResult; + } + + const recoveryPromise = (async () => { + const configMarkers = buildManagedConfigMarkers(); + const recoveredPids: number[] = []; + const skippedPids: number[] = []; + + for (const proc of openCodeProcessController.listProcesses()) { + if (proc.pid === process.pid) continue; + if (!isManagedOpenCodeServeCommand(proc.command, configMarkers)) continue; + + const ownerPid = parseManagedOwnerPid(proc.command); + if (ownerPid === process.pid) { + skippedPids.push(proc.pid); + continue; + } + const ownerAlive = ownerPid != null + && openCodeProcessController.isProcessAlive(ownerPid); + const isOrphan = ownerPid != null + ? !ownerAlive + : proc.ppid === 1; + + if (!isOrphan) { + skippedPids.push(proc.pid); + continue; + } + + openCodeProcessController.killProcess(proc.pid, "SIGTERM"); + const exitedGracefully = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedGracefully && openCodeProcessController.isProcessAlive(proc.pid)) { + openCodeProcessController.killProcess(proc.pid, "SIGKILL"); + const exitedAfterKill = await waitForProcessExit(proc.pid, ORPHAN_RECOVERY_TERM_GRACE_MS); + if (!exitedAfterKill && openCodeProcessController.isProcessAlive(proc.pid)) { + skippedPids.push(proc.pid); + args.logger?.warn("opencode.server_orphan_recovery_failed", { + pid: proc.pid, + ownerPid, + ppid: proc.ppid, + }); + continue; + } + } + recoveredPids.push(proc.pid); + args.logger?.warn("opencode.server_orphan_recovered", { + pid: proc.pid, + ownerPid, + ppid: proc.ppid, + }); + } + + lastOrphanRecoveryResult = { recoveredPids, skippedPids }; + orphanRecoveryCompleted = true; + return lastOrphanRecoveryResult; + })().finally(() => { + orphanRecoveryPromise = null; + }); + + orphanRecoveryPromise = recoveryPromise; + return await recoveryPromise; +} + function buildOpenCodeServeLaunchSpec(args: OpenCodeServerLaunchArgs): OpenCodeServeLaunchSpec { const executable = resolveOpenCodeBinaryPath(); if (!executable) { @@ -524,6 +752,7 @@ async function createEntry(args: { if (existingPromise) return await existingPromise; const createPromise = (async () => { + await recoverManagedOpenCodeOrphans({ logger: args.logger }); const server = await createOpencodeServerWithRetry(args.config); const entry: OpenCodeServerEntry = { id: randomUUID(), @@ -569,6 +798,7 @@ export async function acquireSharedOpenCodeServer(args: { existing.refCount += 1; existing.lastUsedAt = Date.now(); logRuntimeEvent(args.logger, "opencode.server_reused", existing, { refCount: existing.refCount }); + pruneIdleSharedEntries(key, args.logger); return buildLease(existing, args.logger); } if (existing && existing.refCount > 0) { @@ -599,6 +829,7 @@ export async function acquireSharedOpenCodeServer(args: { } entry.refCount = 1; sharedEntries.set(key, entry); + pruneIdleSharedEntries(key, args.logger); return buildLease(entry, args.logger); } }); @@ -705,6 +936,10 @@ export function __resetOpenCodeServerManagerForTests(): void { inFlightEntries.clear(); acquireQueues.clear(); openCodeServerLauncher = defaultOpenCodeServerLauncher; + openCodeProcessController = defaultOpenCodeProcessController; + orphanRecoveryPromise = null; + lastOrphanRecoveryResult = { recoveredPids: [], skippedPids: [] }; + orphanRecoveryCompleted = false; } export function __setOpenCodeServerLauncherForTests( @@ -713,6 +948,22 @@ export function __setOpenCodeServerLauncherForTests( openCodeServerLauncher = launcher ?? defaultOpenCodeServerLauncher; } +export function __setOpenCodeProcessControllerForTests( + controller: Partial | null, +): void { + openCodeProcessController = controller + ? { + listProcesses: controller.listProcesses ?? (() => []), + isProcessAlive: controller.isProcessAlive ?? (() => false), + killProcess: controller.killProcess ?? (() => {}), + waitForMs: controller.waitForMs ?? (async () => {}), + } + : defaultOpenCodeProcessController; + orphanRecoveryPromise = null; + lastOrphanRecoveryResult = { recoveredPids: [], skippedPids: [] }; + orphanRecoveryCompleted = false; +} + export function __buildOpenCodeServeLaunchSpecForTests(args: { config: OpenCodeConfig; port?: number; diff --git a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts index ef1f245e7..913cb741e 100644 --- a/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts +++ b/apps/desktop/src/main/services/orchestrator/aiOrchestratorService.test.ts @@ -3172,8 +3172,9 @@ describe("aiOrchestratorService", () => { ["2000-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z", attempt.id] ); - const sweep = await fixture.aiOrchestratorService.runHealthSweep("test"); - expect(sweep.staleRecovered).toBeGreaterThanOrEqual(1); + // The explicit sweep may do the recovery itself, or a background startup/interval + // sweep may have already reconciled the stale attempt before this call returns. + await fixture.aiOrchestratorService.runHealthSweep("test"); const refreshedGraph = fixture.orchestratorService.getRunGraph({ runId }); const refreshedAttempt = refreshedGraph.attempts.find((entry) => entry.id === attempt.id); diff --git a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts index ec971c4a0..ea064555f 100644 --- a/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts +++ b/apps/desktop/src/main/services/orchestrator/coordinatorAgent.ts @@ -613,7 +613,7 @@ export class CoordinatorAgent { } private releaseOpenCodeCoordinatorSession( - reason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "paused_run" | "project_close" | "budget_eviction" | "shutdown", + reason: "handle_close" | "idle_ttl" | "ended_session" | "model_switch" | "paused_run" | "project_close" | "budget_eviction" | "pool_compaction" | "shutdown", ): void { this.clearOpenCodeIdleTimer(); const handle = this.openCodeHandle; diff --git a/apps/desktop/src/main/services/projects/projectBrowserService.test.ts b/apps/desktop/src/main/services/projects/projectBrowserService.test.ts new file mode 100644 index 000000000..f1616a8df --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectBrowserService.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { browseProjectDirectories } from "./projectBrowserService"; + +vi.mock("./projectService", () => ({ + resolveRepoRoot: vi.fn(async (selectedPath: string) => { + const normalized = path.resolve(selectedPath); + if (normalized.endsWith(path.join("alpha", "nested"))) { + return path.dirname(normalized); + } + if (normalized.endsWith("alpha")) { + return normalized; + } + throw new Error("Not a git repository"); + }), +})); + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + vi.clearAllMocks(); + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("browseProjectDirectories", () => { + it("returns matching directories for a partial path", async () => { + const root = makeTempDir("ade-project-browse-"); + fs.mkdirSync(path.join(root, "alpha")); + fs.mkdirSync(path.join(root, "alpine")); + fs.writeFileSync(path.join(root, "alpha.txt"), "ignore me", "utf8"); + + const result = await browseProjectDirectories({ + partialPath: path.join(root, "alp"), + }); + + expect(result.exactDirectoryPath).toBeNull(); + expect(result.directoryPath).toBe(root); + expect(result.openableProjectRoot).toBeNull(); + expect(result.entries.map((entry) => entry.name)).toEqual(["alpha", "alpine"]); + }); + + it("lists directory contents and reports an openable repo for an exact directory", async () => { + const root = makeTempDir("ade-project-browse-dir-"); + const repoRoot = path.join(root, "alpha"); + fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".config"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, "src"), { recursive: true }); + + const result = await browseProjectDirectories({ + partialPath: repoRoot, + }); + + expect(result.exactDirectoryPath).toBe(repoRoot); + expect(result.openableProjectRoot).toBe(repoRoot); + expect(result.entries.map((entry) => entry.name)).toEqual([".config", ".git", "nested", "src"]); + }); + + it("supports relative paths when cwd is provided", async () => { + const root = makeTempDir("ade-project-browse-rel-"); + const repoRoot = path.join(root, "alpha"); + fs.mkdirSync(path.join(repoRoot, "nested"), { recursive: true }); + fs.mkdirSync(path.join(repoRoot, ".git"), { recursive: true }); + + const result = await browseProjectDirectories({ + cwd: repoRoot, + partialPath: "./nested", + }); + + expect(result.exactDirectoryPath).toBe(path.join(repoRoot, "nested")); + expect(result.openableProjectRoot).toBe(repoRoot); + }); + + it("marks entries with a .git directory as git repos", async () => { + const root = makeTempDir("ade-project-browse-flags-"); + fs.mkdirSync(path.join(root, "alpha", ".git"), { recursive: true }); + fs.mkdirSync(path.join(root, "alpine")); + + const result = await browseProjectDirectories({ + partialPath: withTrailingSlash(root), + }); + + const byName = new Map(result.entries.map((entry) => [entry.name, entry])); + expect(byName.get("alpha")?.isGitRepo).toBe(true); + expect(byName.get("alpine")?.isGitRepo).toBe(false); + }); +}); + +function withTrailingSlash(input: string): string { + return input.endsWith(path.sep) ? input : `${input}${path.sep}`; +} diff --git a/apps/desktop/src/main/services/projects/projectBrowserService.ts b/apps/desktop/src/main/services/projects/projectBrowserService.ts new file mode 100644 index 000000000..f46244c83 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectBrowserService.ts @@ -0,0 +1,139 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { ProjectBrowseEntry, ProjectBrowseInput, ProjectBrowseResult } from "../../../shared/types"; +import { resolveRepoRoot } from "./projectService"; + +function expandHomePath(input: string): string { + if (input === "~") return os.homedir(); + if (input.startsWith("~/") || input.startsWith("~\\")) { + return path.join(os.homedir(), input.slice(2)); + } + return input; +} + +function isExplicitRelativePath(input: string): boolean { + return input === "." || input === ".." || input.startsWith("./") || input.startsWith(".\\") || input.startsWith("../") || input.startsWith("..\\"); +} + +function isWindowsAbsolutePath(input: string): boolean { + return /^[a-zA-Z]:[\\/]/.test(input) || input.startsWith("\\\\"); +} + +function parentPathOf(input: string): string | null { + const parent = path.dirname(input); + return parent === input ? null : parent; +} + +async function resolveOpenableProjectRoot(candidatePath: string | null): Promise { + if (!candidatePath) return null; + try { + return path.resolve(await resolveRepoRoot(candidatePath)); + } catch { + return null; + } +} + +async function isGitRepoAt(candidatePath: string): Promise { + try { + const stat = await fs.stat(path.join(candidatePath, ".git")); + return stat.isDirectory() || stat.isFile(); + } catch { + return false; + } +} + +async function mapWithConcurrency(items: T[], limit: number, worker: (item: T, index: number) => Promise): Promise { + const results: R[] = new Array(items.length); + let cursor = 0; + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (true) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await worker(items[index]!, index); + } + }); + await Promise.all(runners); + return results; +} + +export async function browseProjectDirectories(args: ProjectBrowseInput = {}): Promise { + const cwd = typeof args.cwd === "string" && args.cwd.trim().length > 0 ? args.cwd.trim() : null; + const limit = Number.isFinite(args.limit) && (args.limit ?? 0) > 0 ? Math.min(args.limit ?? 200, 500) : 200; + const rawInput = typeof args.partialPath === "string" && args.partialPath.trim().length > 0 + ? args.partialPath.trim() + : cwd ?? "~/"; + + if (process.platform !== "win32" && isWindowsAbsolutePath(rawInput)) { + throw new Error("Windows-style paths are only supported on Windows."); + } + + if (isExplicitRelativePath(rawInput) && !cwd) { + throw new Error("Relative paths require an active project."); + } + + const resolvedPath = isExplicitRelativePath(rawInput) + ? path.resolve(expandHomePath(cwd!), rawInput) + : path.resolve(expandHomePath(rawInput)); + const treatAsDirectory = /[\\/]$/.test(rawInput) || rawInput === "~"; + + let exactDirectoryPath: string | null = null; + let directoryPath = resolvedPath; + let prefix = ""; + + try { + const stat = await fs.stat(resolvedPath); + if (stat.isDirectory()) { + exactDirectoryPath = resolvedPath; + directoryPath = resolvedPath; + } else { + directoryPath = path.dirname(resolvedPath); + prefix = path.basename(resolvedPath); + } + } catch { + if (treatAsDirectory) { + directoryPath = resolvedPath; + } else { + directoryPath = path.dirname(resolvedPath); + prefix = path.basename(resolvedPath); + } + } + + const showHidden = exactDirectoryPath !== null || prefix.startsWith("."); + const normalizedPrefix = prefix.toLowerCase(); + let entries: ProjectBrowseEntry[] = []; + + try { + const dirents = await fs.readdir(directoryPath, { withFileTypes: true }); + const candidates = dirents + .filter((dirent) => dirent.isDirectory()) + .filter((dirent) => showHidden || !dirent.name.startsWith(".")) + .filter((dirent) => dirent.name.toLowerCase().startsWith(normalizedPrefix)) + .sort((left, right) => left.name.localeCompare(right.name)) + .slice(0, limit) + .map((dirent) => ({ + name: dirent.name, + fullPath: path.join(directoryPath, dirent.name), + })); + const gitFlags = await mapWithConcurrency(candidates, 16, (candidate) => isGitRepoAt(candidate.fullPath)); + entries = candidates.map((candidate, index) => ({ + ...candidate, + isGitRepo: gitFlags[index] ?? false, + })); + } catch (error) { + const code = error instanceof Error && "code" in error ? String((error as NodeJS.ErrnoException).code ?? "") : ""; + if (code !== "ENOENT" && code !== "ENOTDIR") { + throw error; + } + } + + return { + inputPath: rawInput, + resolvedPath, + directoryPath, + parentPath: parentPathOf(directoryPath), + exactDirectoryPath, + openableProjectRoot: await resolveOpenableProjectRoot(exactDirectoryPath), + entries, + }; +} diff --git a/apps/desktop/src/main/services/projects/projectDetailService.test.ts b/apps/desktop/src/main/services/projects/projectDetailService.test.ts new file mode 100644 index 000000000..9aa4a6d99 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectDetailService.test.ts @@ -0,0 +1,76 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { __internal, getProjectDetail } from "./projectDetailService"; + +const { parseLastCommitLine, parseAheadBehind } = __internal; +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + } +}); + +describe("parseLastCommitLine", () => { + it("splits subject, iso date, and short sha on the unit separator", () => { + const parsed = parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z\u001fabcdef1\n"); + expect(parsed).toEqual({ + subject: "Fix thing", + isoDate: "2026-04-15T09:00:00Z", + shortSha: "abcdef1", + }); + }); + + it("returns null when any segment is missing", () => { + expect(parseLastCommitLine("")).toBeNull(); + expect(parseLastCommitLine("Fix thing\u001f2026-04-15T09:00:00Z")).toBeNull(); + expect(parseLastCommitLine("Fix thing\u001f\u001fabcdef1")).toBeNull(); + }); +}); + +describe("parseAheadBehind", () => { + it("reads the left-right count output (behind, ahead)", () => { + expect(parseAheadBehind("3\t2\n")).toEqual({ ahead: 2, behind: 3 }); + expect(parseAheadBehind("0\t0")).toEqual({ ahead: 0, behind: 0 }); + }); + + it("returns null when the output cannot be parsed", () => { + expect(parseAheadBehind("")).toBeNull(); + expect(parseAheadBehind("abc")).toBeNull(); + }); +}); + +describe("getProjectDetail", () => { + it("rejects paths that are not existing directories", async () => { + const root = makeTempDir("ade-project-detail-"); + const filePath = path.join(root, "README.md"); + fs.writeFileSync(filePath, "# hello\n", "utf8"); + + await expect(getProjectDetail(filePath)).rejects.toThrow(/existing directory/i); + await expect(getProjectDetail(path.join(root, "missing-project"))).rejects.toThrow(/existing directory/i); + }); + + it("strips repeated leading HTML comments from README excerpts", async () => { + const root = makeTempDir("ade-project-detail-"); + fs.writeFileSync( + path.join(root, "README.md"), + "\n\n# Hello\n\nVisible body.\n", + "utf8", + ); + + const detail = await getProjectDetail(root); + + expect(detail.readmeExcerpt).toBe("# Hello\n\nVisible body."); + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectDetailService.ts b/apps/desktop/src/main/services/projects/projectDetailService.ts new file mode 100644 index 000000000..d8e26415e --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectDetailService.ts @@ -0,0 +1,275 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ProjectDetail, ProjectLanguageShare, ProjectLastCommit, RecentProjectSummary } from "../../../shared/types"; +import { runGit } from "../git/git"; +import { readGlobalState } from "../state/globalState"; +import { toRecentProjectSummary } from "./recentProjectSummary"; + +const README_CANDIDATES = ["README.md", "readme.md", "Readme.md", "README", "readme"]; +const README_EXCERPT_CHARS = 1600; +const LANGUAGE_SCAN_FILE_CAP = 2000; +const LANGUAGE_SCAN_DEPTH = 2; + +const EXTENSION_TO_LANGUAGE: Record = { + ts: "TypeScript", + tsx: "TypeScript", + js: "JavaScript", + jsx: "JavaScript", + mjs: "JavaScript", + cjs: "JavaScript", + py: "Python", + rs: "Rust", + go: "Go", + rb: "Ruby", + java: "Java", + kt: "Kotlin", + swift: "Swift", + m: "Objective-C", + mm: "Objective-C++", + c: "C", + h: "C", + hpp: "C++", + cc: "C++", + cpp: "C++", + cs: "C#", + php: "PHP", + lua: "Lua", + sh: "Shell", + bash: "Shell", + zsh: "Shell", + fish: "Shell", + ps1: "PowerShell", + sql: "SQL", + html: "HTML", + css: "CSS", + scss: "SCSS", + less: "Less", + vue: "Vue", + svelte: "Svelte", + astro: "Astro", + json: "JSON", + yml: "YAML", + yaml: "YAML", + toml: "TOML", + md: "Markdown", + mdx: "Markdown", +}; + +const EXCLUDED_DIRS = new Set([ + "node_modules", + ".git", + ".next", + ".turbo", + ".ade", + "dist", + "build", + "out", + "target", + "vendor", + ".venv", + "__pycache__", + ".cache", + "coverage", +]); + +function parseLastCommitLine(raw: string): ProjectLastCommit | null { + const line = raw.trim(); + if (!line) return null; + const parts = line.split("\u001f"); + if (parts.length < 3) return null; + const [subject, isoDate, shortSha] = parts; + if (!subject || !isoDate || !shortSha) return null; + return { subject, isoDate, shortSha }; +} + +function parseAheadBehind(raw: string): { ahead: number; behind: number } | null { + const line = raw.trim(); + if (!line) return null; + const [behindStr, aheadStr] = line.split(/\s+/); + const behind = Number.parseInt(behindStr ?? "", 10); + const ahead = Number.parseInt(aheadStr ?? "", 10); + if (!Number.isFinite(behind) || !Number.isFinite(ahead)) return null; + return { ahead, behind }; +} + +async function readReadmeExcerpt(rootPath: string): Promise { + for (const candidate of README_CANDIDATES) { + try { + const filePath = path.join(rootPath, candidate); + const handle = await fs.open(filePath, "r"); + try { + const buffer = Buffer.alloc(README_EXCERPT_CHARS * 2); + const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0); + const raw = buffer.slice(0, bytesRead).toString("utf8"); + const cleaned = raw.replace(/^(\s*\s*)+/, "").trim(); + if (!cleaned) return null; + if (cleaned.length <= README_EXCERPT_CHARS) return cleaned; + const truncated = cleaned.slice(0, README_EXCERPT_CHARS); + const lastBreak = Math.max(truncated.lastIndexOf("\n\n"), truncated.lastIndexOf(". ")); + const boundary = lastBreak > README_EXCERPT_CHARS * 0.6 ? lastBreak : truncated.length; + return `${truncated.slice(0, boundary).trimEnd()}\n\n_…continues_`; + } finally { + await handle.close(); + } + } catch { + // keep trying the next candidate + } + } + return null; +} + +async function countFilesByLanguage(rootPath: string): Promise { + const counts = new Map(); + let totalRecognized = 0; + let filesVisited = 0; + + const walk = async (dir: string, depth: number): Promise => { + if (filesVisited >= LANGUAGE_SCAN_FILE_CAP) return; + let dirents; + try { + dirents = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const dirent of dirents) { + if (filesVisited >= LANGUAGE_SCAN_FILE_CAP) return; + if (dirent.name.startsWith(".") && dirent.name !== ".github") { + continue; + } + if (dirent.isDirectory()) { + if (EXCLUDED_DIRS.has(dirent.name)) continue; + if (depth >= LANGUAGE_SCAN_DEPTH) continue; + await walk(path.join(dir, dirent.name), depth + 1); + continue; + } + if (!dirent.isFile()) continue; + filesVisited += 1; + const dotIndex = dirent.name.lastIndexOf("."); + if (dotIndex <= 0) continue; + const ext = dirent.name.slice(dotIndex + 1).toLowerCase(); + const language = EXTENSION_TO_LANGUAGE[ext]; + if (!language) continue; + counts.set(language, (counts.get(language) ?? 0) + 1); + totalRecognized += 1; + } + }; + + await walk(rootPath, 0); + + if (totalRecognized === 0) return []; + return [...counts.entries()] + .map(([name, count]) => ({ name, fraction: count / totalRecognized })) + .sort((a, b) => b.fraction - a.fraction) + .slice(0, 4); +} + +async function countSubdirectories(rootPath: string): Promise { + try { + const dirents = await fs.readdir(rootPath, { withFileTypes: true }); + return dirents.filter((dirent) => dirent.isDirectory() && !dirent.name.startsWith(".")).length; + } catch { + return null; + } +} + +async function isGitRepo(rootPath: string): Promise { + try { + const stat = await fs.stat(path.join(rootPath, ".git")); + return stat.isDirectory() || stat.isFile(); + } catch { + return false; + } +} + +async function readGitMetadata(rootPath: string): Promise> { + const [branchRes, dirtyRes, lastCommitRes, aheadBehindRes] = await Promise.all([ + runGit(["rev-parse", "--abbrev-ref", "HEAD"], { cwd: rootPath, timeoutMs: 5_000 }), + runGit(["status", "--porcelain"], { cwd: rootPath, timeoutMs: 8_000 }), + runGit(["log", "-1", "--format=%s%x1f%cI%x1f%h"], { cwd: rootPath, timeoutMs: 6_000 }), + runGit(["rev-list", "--left-right", "--count", "@{u}...HEAD"], { cwd: rootPath, timeoutMs: 6_000 }), + ]); + + const branchName = branchRes.exitCode === 0 ? branchRes.stdout.trim() || null : null; + const dirtyCount = dirtyRes.exitCode === 0 + ? dirtyRes.stdout.split("\n").filter((line) => line.trim().length > 0).length + : null; + const lastCommit = lastCommitRes.exitCode === 0 ? parseLastCommitLine(lastCommitRes.stdout) : null; + const aheadBehind = aheadBehindRes.exitCode === 0 ? parseAheadBehind(aheadBehindRes.stdout) : null; + + return { branchName, dirtyCount, lastCommit, aheadBehind }; +} + +export type GetProjectDetailOptions = { + globalStatePath?: string | null; +}; + +async function resolveProjectDetailScanRoot(rootPath: string): Promise<{ + requestedRoot: string; + scanRoot: string; +}> { + const requestedRoot = path.resolve(rootPath); + let scanRoot: string; + try { + scanRoot = await fs.realpath(requestedRoot); + } catch { + throw new Error(`Project detail requires an existing directory: ${requestedRoot}`); + } + + let stat; + try { + stat = await fs.stat(scanRoot); + } catch { + throw new Error(`Project detail requires an existing directory: ${requestedRoot}`); + } + if (!stat.isDirectory()) { + throw new Error(`Project detail requires an existing directory: ${requestedRoot}`); + } + + return { requestedRoot, scanRoot }; +} + +function lookupRecentProjectEntry(globalStatePath: string | null | undefined, rootPath: string): RecentProjectSummary | null { + if (!globalStatePath) return null; + try { + const state = readGlobalState(globalStatePath); + const entry = (state.recentProjects ?? []).find((rp) => rp.rootPath === rootPath); + return entry ? toRecentProjectSummary(entry) : null; + } catch { + return null; + } +} + +export async function getProjectDetail(rootPath: string, options: GetProjectDetailOptions = {}): Promise { + const { requestedRoot, scanRoot } = await resolveProjectDetailScanRoot(rootPath); + const [gitRepo, readmeExcerpt, languages, subdirectoryCount] = await Promise.all([ + isGitRepo(scanRoot), + readReadmeExcerpt(scanRoot), + countFilesByLanguage(scanRoot), + countSubdirectories(scanRoot), + ]); + + const gitMeta = gitRepo + ? await readGitMetadata(scanRoot) + : { branchName: null, dirtyCount: null, lastCommit: null, aheadBehind: null }; + + const recent = lookupRecentProjectEntry(options.globalStatePath ?? null, requestedRoot); + + return { + rootPath: requestedRoot, + isGitRepo: gitRepo, + branchName: gitMeta.branchName, + dirtyCount: gitMeta.dirtyCount, + aheadBehind: gitMeta.aheadBehind, + lastCommit: gitMeta.lastCommit, + readmeExcerpt, + languages, + laneCount: recent?.laneCount ?? null, + lastOpenedAt: recent?.lastOpenedAt ?? null, + subdirectoryCount, + }; +} + +export const __internal = { + parseLastCommitLine, + parseAheadBehind, +}; diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index 2a1527f53..674109b06 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -568,9 +568,10 @@ function parseConflictLaneArgs(value: Record, action: string): }; } -function parseChatModelsArgs(value: Record): { provider: AgentChatProvider } { +function parseChatModelsArgs(value: Record): { provider: AgentChatProvider; activateRuntime?: boolean } { return { provider: (asTrimmedString(value.provider) ?? "codex") as AgentChatProvider, + ...(value.activateRuntime === true ? { activateRuntime: true } : {}), }; } @@ -724,7 +725,10 @@ async function resolveChatCreateArgs( payload: AgentChatCreateArgs, ): Promise { if (payload.model.trim().length > 0) return payload; - const available = await service.getAvailableModels({ provider: payload.provider }); + const available = await service.getAvailableModels({ + provider: payload.provider, + ...(payload.provider === "opencode" ? { activateRuntime: true } : {}), + }); const chosen = available[0]; if (!chosen) { throw new Error(`No configured ${payload.provider} chat model is available on the host.`); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index e09139fd0..31fb6059d 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -2,6 +2,9 @@ import type { AdeCleanupResult, AdeProjectEvent, AdeProjectSnapshot, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, BatchAssessmentResult, ApplyConflictProposalArgs, AttachLaneArgs, @@ -610,6 +613,11 @@ declare global { title?: string; defaultPath?: string; }) => Promise; + browseDirectories: ( + args?: ProjectBrowseInput, + ) => Promise; + getDetail: (rootPath: string) => Promise; + getDroppedPath: (file: File) => string; openAdeFolder: () => Promise; clearLocalData: ( args?: ClearLocalAdeDataArgs, diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index c33a8ad83..f50d8a06b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1,9 +1,12 @@ -import { contextBridge, ipcRenderer, webFrame } from "electron"; +import { contextBridge, ipcRenderer, webFrame, webUtils } from "electron"; import { IPC } from "../shared/ipc"; import type { AdeCleanupResult, AdeProjectEvent, AdeProjectSnapshot, + ProjectBrowseInput, + ProjectBrowseResult, + ProjectDetail, } from "../shared/types"; import type { BatchAssessmentResult, @@ -619,6 +622,19 @@ contextBridge.exposeInMainWorld("ade", { args: { title?: string; defaultPath?: string } = {}, ): Promise => ipcRenderer.invoke(IPC.projectChooseDirectory, args), + browseDirectories: async ( + args: ProjectBrowseInput = {}, + ): Promise => + ipcRenderer.invoke(IPC.projectBrowseDirectories, args), + getDetail: async (rootPath: string): Promise => + ipcRenderer.invoke(IPC.projectGetDetail, { rootPath }), + getDroppedPath: (file: File): string => { + try { + return webUtils.getPathForFile(file); + } catch { + return ""; + } + }, openAdeFolder: async (): Promise => ipcRenderer.invoke(IPC.projectOpenAdeFolder), clearLocalData: async ( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index c6006d92d..468a1d6cd 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1662,6 +1662,35 @@ if (typeof window !== "undefined" && !(window as any).ade) { project: { openRepo: resolved(MOCK_PROJECT), chooseDirectory: resolvedArg(null), + browseDirectories: async (args?: { inputPath?: string }) => { + const inputPath = + typeof args?.inputPath === "string" && args.inputPath.trim().length > 0 + ? args.inputPath + : "~/"; + return { + inputPath, + resolvedPath: "/tmp/mock", + directoryPath: "/tmp/mock", + parentPath: "/tmp", + exactDirectoryPath: "/tmp/mock", + openableProjectRoot: "/tmp/mock", + entries: [], + }; + }, + getDetail: resolvedArg({ + rootPath: "/tmp/mock", + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: null, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: null, + lastOpenedAt: null, + subdirectoryCount: null, + }), + getDroppedPath: (_file: unknown) => "", openAdeFolder: resolved(undefined), clearLocalData: resolved({ deletedPaths: [], diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index caeb20963..b4bff6a06 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -49,6 +49,7 @@ import { listActionableContextDocs, listContextDocsByHealth, } from "../context/contextShared"; +import { disposeTerminalRuntimesForProjectChange } from "../terminals/TerminalView"; type PrToast = { id: string; @@ -143,6 +144,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const keybindings = useAppStore((s) => s.keybindings); const lanes = useAppStore((s) => s.lanes); const project = useAppStore((s) => s.project); + const projectRevision = useAppStore((s) => s.projectRevision); const setShowWelcome = useAppStore((s) => s.setShowWelcome); const showWelcome = useAppStore((s) => s.showWelcome); const openRepo = useAppStore((s) => s.openRepo); @@ -204,6 +206,10 @@ export function AppShell({ children }: { children: React.ReactNode }) { ); }, [location.pathname, project?.rootPath, showWelcome]); + useEffect(() => { + disposeTerminalRuntimesForProjectChange(project?.rootPath ?? null, projectRevision); + }, [project?.rootPath, projectRevision]); + useEffect(() => { let cancelled = false; let laneRefreshTimer: number | null = null; diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx new file mode 100644 index 000000000..5820987d6 --- /dev/null +++ b/apps/desktop/src/renderer/components/app/CommandPalette.test.tsx @@ -0,0 +1,227 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { CommandPalette } from "./CommandPalette"; +import { useAppStore } from "../../state/appStore"; + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function seedStore(overrides: Record = {}) { + useAppStore.setState({ + project: { + rootPath: "/Users/admin/Projects/ADE", + displayName: "ADE", + baseRef: "main", + }, + lanes: [], + selectedLaneId: null, + selectLane: vi.fn(), + switchProjectToPath: vi.fn(async () => {}), + ...overrides, + } as any); +} + +describe("CommandPalette", () => { + const browseDirectories = vi.fn(); + const chooseDirectory = vi.fn(); + const getDetail = vi.fn(); + const getDroppedPath = vi.fn(() => ""); + + beforeEach(() => { + browseDirectories.mockReset(); + chooseDirectory.mockReset(); + getDetail.mockReset(); + getDetail.mockResolvedValue({ + rootPath: "/Users/admin/Projects/Versic", + isGitRepo: true, + branchName: "main", + dirtyCount: 0, + aheadBehind: null, + lastCommit: null, + readmeExcerpt: null, + languages: [], + laneCount: null, + lastOpenedAt: null, + subdirectoryCount: null, + }); + seedStore(); + globalThis.window.ade = { + app: { + ping: vi.fn(async () => "pong"), + }, + project: { + browseDirectories, + chooseDirectory, + getDetail, + getDroppedPath, + }, + } as any; + }); + + it("opens the ADE project browser in browse intent mode", async () => { + browseDirectories.mockResolvedValue({ + inputPath: "../", + resolvedPath: "/Users/admin/Projects", + directoryPath: "/Users/admin/Projects", + parentPath: "/Users/admin", + exactDirectoryPath: "/Users/admin/Projects", + openableProjectRoot: null, + entries: [ + { + name: "Versic", + fullPath: "/Users/admin/Projects/Versic", + isGitRepo: true, + }, + ], + }); + + render( + + + + ); + + await waitFor(() => { + expect(browseDirectories).toHaveBeenCalledWith({ + partialPath: "../", + cwd: "/Users/admin/Projects/ADE", + limit: 200, + }); + }); + + expect(await screen.findByRole("button", { name: /open directory/i })).toBeTruthy(); + expect(screen.getByText("Versic")).toBeTruthy(); + }); + + it("can fall back to the directory picker from the browser footer", async () => { + const switchProjectToPath = vi.fn(async () => {}); + seedStore({ switchProjectToPath }); + browseDirectories.mockResolvedValue({ + inputPath: "/Users/admin/Projects/", + resolvedPath: "/Users/admin/Projects", + directoryPath: "/Users/admin/Projects", + parentPath: "/Users/admin", + exactDirectoryPath: "/Users/admin/Projects", + openableProjectRoot: null, + entries: [], + }); + chooseDirectory.mockResolvedValue("/Users/admin/Projects/Versic"); + + render( + + + + ); + + const button = await screen.findByRole("button", { name: /open directory/i }); + fireEvent.click(button); + + await waitFor(() => { + expect(chooseDirectory).toHaveBeenCalledWith({ + title: "Open project", + defaultPath: "/Users/admin/Projects", + }); + expect(switchProjectToPath).toHaveBeenCalledWith( + "/Users/admin/Projects/Versic" + ); + }); + }); + + it("opens the latest dropped folder and ignores stale browse results", async () => { + const switchProjectToPath = vi.fn(async () => {}); + seedStore({ switchProjectToPath }); + + const initialBrowseResult = { + inputPath: "../", + resolvedPath: "/Users/admin/Projects", + directoryPath: "/Users/admin/Projects", + parentPath: "/Users/admin", + exactDirectoryPath: "/Users/admin/Projects", + openableProjectRoot: null, + entries: [], + }; + const staleDrop = deferred(); + const latestDrop = deferred(); + + browseDirectories + .mockResolvedValueOnce(initialBrowseResult) + .mockImplementationOnce(() => staleDrop.promise) + .mockImplementationOnce(() => latestDrop.promise) + .mockResolvedValue({ + inputPath: "/Users/admin/Projects/FreshFolder/", + resolvedPath: "/Users/admin/Projects/FreshFolder", + directoryPath: "/Users/admin/Projects/FreshFolder", + parentPath: "/Users/admin/Projects", + exactDirectoryPath: "/Users/admin/Projects/FreshFolder", + openableProjectRoot: null, + entries: [], + }); + + getDroppedPath + .mockImplementationOnce(() => "/Users/admin/Projects/StaleRepo") + .mockImplementationOnce(() => "/Users/admin/Projects/FreshFolder"); + + render( + + + + ); + + const inputs = await screen.findAllByPlaceholderText(/paste a path, type to filter, or drop a folder anywhere/i); + const input = inputs.at(-1) as HTMLInputElement; + fireEvent.drop(input, { + dataTransfer: { files: [new File(["stale"], "stale")] }, + }); + fireEvent.drop(input, { + dataTransfer: { files: [new File(["fresh"], "fresh")] }, + }); + + staleDrop.resolve({ + inputPath: "/Users/admin/Projects/StaleRepo/", + resolvedPath: "/Users/admin/Projects/StaleRepo", + directoryPath: "/Users/admin/Projects/StaleRepo", + parentPath: "/Users/admin/Projects", + exactDirectoryPath: "/Users/admin/Projects/StaleRepo", + openableProjectRoot: "/Users/admin/Projects/StaleRepo", + entries: [], + }); + latestDrop.resolve({ + inputPath: "/Users/admin/Projects/FreshFolder/", + resolvedPath: "/Users/admin/Projects/FreshFolder", + directoryPath: "/Users/admin/Projects/FreshFolder", + parentPath: "/Users/admin/Projects", + exactDirectoryPath: "/Users/admin/Projects/FreshFolder", + openableProjectRoot: null, + entries: [], + }); + + await waitFor(() => { + expect(switchProjectToPath).toHaveBeenCalledWith("/Users/admin/Projects/FreshFolder"); + expect(switchProjectToPath).toHaveBeenCalledTimes(1); + expect(browseDirectories).toHaveBeenCalledTimes(3); + }); + }); +}); diff --git a/apps/desktop/src/renderer/components/app/CommandPalette.tsx b/apps/desktop/src/renderer/components/app/CommandPalette.tsx index 00bdd4a8e..a2a0c178b 100644 --- a/apps/desktop/src/renderer/components/app/CommandPalette.tsx +++ b/apps/desktop/src/renderer/components/app/CommandPalette.tsx @@ -1,11 +1,27 @@ -import React, { useCallback, useMemo, useRef, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Dialog from "@radix-ui/react-dialog"; -import { MagnifyingGlass, ArrowRight } from "@phosphor-icons/react"; +import ReactMarkdown, { type Components } from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + ArrowRight, + CircleNotch, + Clock, + Folder, + FolderOpen, + GitBranch, + MagnifyingGlass, + Stack, + Warning, +} from "@phosphor-icons/react"; import { motion, AnimatePresence } from "motion/react"; import { useNavigate } from "react-router-dom"; -import { cn } from "../ui/cn"; -import { useAppStore } from "../../state/appStore"; +import type { ProjectBrowseResult, ProjectDetail } from "../../../shared/types"; +import { extractError } from "../../lib/format"; import { fadeScale } from "../../lib/motion"; +import { useAppStore } from "../../state/appStore"; +import { cn } from "../ui/cn"; + +export type CommandPaletteIntent = "default" | "project-browse"; type Command = { id: string; @@ -13,22 +29,173 @@ type Command = { hint?: string; shortcut?: string; group?: string; - run: () => void; + closeOnRun?: boolean; + run: () => void | Promise; +}; + +type BrowseRow = { + id: string; + title: string; + hint: string; + path: string; + kind: "parent" | "directory"; + isGitRepo: boolean; }; -export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (v: boolean) => void }) { +function stripTrailingSeparator(input: string): string { + if (input.length <= 1) return input; + return input.endsWith("/") || input.endsWith("\\") ? input.slice(0, -1) : input; +} + +function relativeFromNow(iso: string | null | undefined): string | null { + if (!iso) return null; + const then = new Date(iso).getTime(); + if (!Number.isFinite(then)) return null; + const diffMs = Date.now() - then; + if (diffMs < 0) return "just now"; + const minutes = Math.floor(diffMs / 60_000); + if (minutes < 1) return "just now"; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 5) return `${weeks}w ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(days / 365); + return `${years}y ago`; +} + +const LANGUAGE_SWATCHES: Record = { + TypeScript: "#3178C6", + JavaScript: "#F7DF1E", + Python: "#3776AB", + Rust: "#DE6F1B", + Go: "#00ADD8", + Ruby: "#CC342D", + Java: "#B07219", + Kotlin: "#A97BFF", + Swift: "#F05138", + "Objective-C": "#438EFF", + "Objective-C++": "#6866FB", + C: "#555555", + "C++": "#F34B7D", + "C#": "#178600", + PHP: "#4F5D95", + Lua: "#000080", + Shell: "#89E051", + PowerShell: "#012456", + SQL: "#E38C00", + HTML: "#E34C26", + CSS: "#563D7C", + SCSS: "#C6538C", + Less: "#1D365D", + Vue: "#41B883", + Svelte: "#FF3E00", + Astro: "#FF5D01", + JSON: "#8FB1D9", + YAML: "#CB171E", + TOML: "#9C4221", + Markdown: "#A78BFA", +}; + +function withTrailingSeparator(input: string): string { + if (input.endsWith("/") || input.endsWith("\\")) return input; + return `${input}${input.includes("\\") ? "\\" : "/"}`; +} + +function defaultBrowseInput(projectRoot: string | null | undefined): string { + return projectRoot ? "../" : "~/"; +} + +function pathLabel(input: string | null | undefined): string { + if (!input) return ""; + const segments = input.split(/[\\/]/).filter(Boolean); + return segments[segments.length - 1] ?? input; +} + +export function CommandPalette({ + open, + onOpenChange, + intent = "default", +}: { + open: boolean; + onOpenChange: (v: boolean) => void; + intent?: CommandPaletteIntent; +}) { const navigate = useNavigate(); const lanes = useAppStore((s) => s.lanes); const selectedLaneId = useAppStore((s) => s.selectedLaneId); const project = useAppStore((s) => s.project); const selectLane = useAppStore((s) => s.selectLane); + const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); + const hasActiveProject = Boolean(project?.rootPath); + + const [mode, setMode] = useState("default"); const [q, setQ] = useState(""); const [selectedIdx, setSelectedIdx] = useState(0); + const [browseInput, setBrowseInput] = useState(defaultBrowseInput(project?.rootPath)); + const [browseResult, setBrowseResult] = useState(null); + const [browseSelectedIdx, setBrowseSelectedIdx] = useState(0); + const [browseLoading, setBrowseLoading] = useState(false); + const [browseError, setBrowseError] = useState(null); + const [openProjectPending, setOpenProjectPending] = useState(false); + const [systemPickerPending, setSystemPickerPending] = useState(false); + const [detail, setDetail] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [detailPath, setDetailPath] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const listRef = useRef(null); - const hasActiveProject = Boolean(project?.rootPath); + const browseRequestRef = useRef(0); + const detailRequestRef = useRef(0); + const dragCounterRef = useRef(0); + + const startProjectBrowse = useCallback(() => { + setMode("project-browse"); + setQ(""); + setSelectedIdx(0); + setBrowseInput(defaultBrowseInput(project?.rootPath)); + setBrowseResult(null); + setBrowseError(null); + setBrowseSelectedIdx(0); + }, [project?.rootPath]); + + useEffect(() => { + if (!open) { + setMode("default"); + setQ(""); + setSelectedIdx(0); + setBrowseError(null); + setBrowseLoading(false); + setOpenProjectPending(false); + setSystemPickerPending(false); + return; + } + + if (intent === "project-browse") { + startProjectBrowse(); + return; + } + + setMode("default"); + setQ(""); + setSelectedIdx(0); + setBrowseError(null); + }, [intent, open, startProjectBrowse]); const commands: Command[] = useMemo(() => { const next: Command[] = [ + { + id: "project-browse", + title: hasActiveProject ? "Open another project" : "Open project", + hint: "Browse folders in ADE before opening a repo", + group: "Projects", + closeOnRun: false, + run: startProjectBrowse, + }, { id: "go-project", title: "Go to Run", shortcut: "G 1", group: "Navigation", run: () => navigate("/project") }, { id: "go-lanes", title: "Go to Lanes", shortcut: "G L", group: "Navigation", run: () => navigate("/lanes") }, { id: "go-files", title: "Go to Files", shortcut: "G F", group: "Navigation", run: () => navigate("/files") }, @@ -49,31 +216,28 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh title: "Create Lane", hint: "Create a new development lane", group: "Actions", - run: () => navigate("/lanes") + run: () => navigate("/lanes"), }, { id: "action-open-terminal", title: "Open Terminal", hint: "Switch to work / terminals view", group: "Actions", - run: () => navigate("/work") + run: () => navigate("/work"), }, { id: "action-refresh-packs", title: "Refresh Packs", hint: "Refresh AI context packs", group: "Actions", - run: () => { - // Navigate to lanes where packs can be refreshed - navigate("/lanes"); - } + run: () => navigate("/lanes"), }, { id: "action-open-graph", title: "Open Workspace Graph", hint: "Visual dependency graph", group: "Actions", - run: () => navigate("/graph") + run: () => navigate("/graph"), }, { id: "lane-next", @@ -83,11 +247,11 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh run: () => { if (!lanes.length) return; const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const next = lanes[(currentIdx + 1 + lanes.length) % lanes.length]; - if (!next) return; - selectLane(next.id); - navigate(`/lanes?laneId=${encodeURIComponent(next.id)}`); - } + const nextLane = lanes[(currentIdx + 1 + lanes.length) % lanes.length]; + if (!nextLane) return; + selectLane(nextLane.id); + navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); + }, }, { id: "lane-prev", @@ -97,11 +261,11 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh run: () => { if (!lanes.length) return; const currentIdx = lanes.findIndex((lane) => lane.id === selectedLaneId); - const next = lanes[(currentIdx - 1 + lanes.length) % lanes.length]; - if (!next) return; - selectLane(next.id); - navigate(`/lanes?laneId=${encodeURIComponent(next.id)}`); - } + const nextLane = lanes[(currentIdx - 1 + lanes.length) % lanes.length]; + if (!nextLane) return; + selectLane(nextLane.id); + navigate(`/lanes?laneId=${encodeURIComponent(nextLane.id)}`); + }, }, { id: "lane-filter", @@ -117,7 +281,7 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh input.select(); } }, 30); - } + }, }, { id: "ping", @@ -126,227 +290,742 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh group: "Debug", run: async () => { await window.ade.app.ping(); - } - } + }, + }, ]; if (!hasActiveProject) { - return next.filter((command) => command.id === "go-project" || command.id === "ping"); + return next.filter((command) => command.id === "project-browse" || command.id === "go-project" || command.id === "ping"); } + return next; - }, [hasActiveProject, lanes, navigate, selectLane, selectedLaneId]); + }, [hasActiveProject, lanes, navigate, selectLane, selectedLaneId, startProjectBrowse]); const filtered = useMemo(() => { const needle = q.trim().toLowerCase(); if (!needle) return commands; - return commands.filter((c) => c.title.toLowerCase().includes(needle) || (c.hint ?? "").toLowerCase().includes(needle)); + return commands.filter((command) => + command.title.toLowerCase().includes(needle) || (command.hint ?? "").toLowerCase().includes(needle) + ); }, [commands, q]); - // Group filtered results by their group label const grouped = useMemo(() => { const groups: { label: string; items: Command[] }[] = []; const seen = new Map(); - for (const cmd of filtered) { - const label = cmd.group ?? "Other"; + for (const command of filtered) { + const label = command.group ?? "Other"; if (seen.has(label)) { - groups[seen.get(label)!]!.items.push(cmd); + groups[seen.get(label)!]!.items.push(command); } else { seen.set(label, groups.length); - groups.push({ label, items: [cmd] }); + groups.push({ label, items: [command] }); } } return groups; }, [filtered]); - const runCommand = useCallback( - (cmd: Command) => { - Promise.resolve(cmd.run()).finally(() => { - onOpenChange(false); - setQ(""); - setSelectedIdx(0); + const browseRows = useMemo(() => { + if (!browseResult) return []; + const rows: BrowseRow[] = []; + if (browseResult.parentPath) { + rows.push({ + id: `parent:${browseResult.parentPath}`, + title: "Go up", + hint: browseResult.parentPath, + path: withTrailingSeparator(browseResult.parentPath), + kind: "parent", + isGitRepo: false, + }); + } + for (const entry of browseResult.entries) { + rows.push({ + id: `dir:${entry.fullPath}`, + title: entry.name, + hint: entry.fullPath, + path: withTrailingSeparator(entry.fullPath), + kind: "directory", + isGitRepo: entry.isGitRepo, + }); + } + return rows; + }, [browseResult]); + + const openableProjectRoot = browseResult?.openableProjectRoot ?? null; + const isCurrentProjectTarget = Boolean(openableProjectRoot && project?.rootPath === openableProjectRoot); + const canOpenProject = Boolean(openableProjectRoot) && !isCurrentProjectTarget; + const openProjectLabel = isCurrentProjectTarget ? "Already open" : "Open"; + + const highlightedRow = browseSelectedIdx >= 0 ? (browseRows[browseSelectedIdx] ?? null) : null; + const highlightedPath = useMemo(() => { + if (highlightedRow && highlightedRow.kind === "directory") { + return stripTrailingSeparator(highlightedRow.path); + } + if (openableProjectRoot) return openableProjectRoot; + if (browseResult?.exactDirectoryPath) return browseResult.exactDirectoryPath; + return null; + }, [browseResult?.exactDirectoryPath, highlightedRow, openableProjectRoot]); + + const highlightedIsRepo = highlightedRow?.kind === "directory" + ? highlightedRow.isGitRepo + : Boolean(openableProjectRoot && highlightedPath && highlightedPath === openableProjectRoot); + + const detailTarget = highlightedPath; + const openTarget = highlightedIsRepo && highlightedRow?.kind === "directory" && highlightedPath + ? highlightedPath + : openableProjectRoot; + const openTargetLabel = openTarget ? pathLabel(openTarget) : null; + const canOpenHighlighted = Boolean(openTarget) && openTarget !== project?.rootPath; + const isMac = typeof navigator !== "undefined" && /mac/i.test(navigator.platform); + const openShortcutLabel = `${isMac ? "⌘" : "Ctrl"}↵`; + + useEffect(() => { + if (!open || mode !== "project-browse") return; + const requestId = ++browseRequestRef.current; + setBrowseLoading(true); + setBrowseError(null); + void window.ade.project + .browseDirectories({ + partialPath: browseInput, + cwd: project?.rootPath ?? null, + limit: 200, + }) + .then((result) => { + if (browseRequestRef.current !== requestId) return; + setBrowseResult(result); + setBrowseSelectedIdx(result.openableProjectRoot ? -1 : (result.parentPath || result.entries.length > 0 ? 0 : -1)); + }) + .catch((error) => { + if (browseRequestRef.current !== requestId) return; + setBrowseResult(null); + setBrowseSelectedIdx(-1); + setBrowseError(extractError(error)); + }) + .finally(() => { + if (browseRequestRef.current !== requestId) return; + setBrowseLoading(false); }); + }, [browseInput, mode, open, project?.rootPath]); + + useEffect(() => { + if (mode !== "default") return; + if (filtered.length === 0) { + if (selectedIdx !== 0) setSelectedIdx(0); + return; + } + if (selectedIdx >= filtered.length) { + setSelectedIdx(Math.max(0, filtered.length - 1)); + } + }, [filtered.length, mode, selectedIdx]); + + useEffect(() => { + if (!open || mode !== "project-browse") { + return; + } + if (!detailTarget) { + setDetail(null); + setDetailPath(null); + setDetailLoading(false); + return; + } + if (detail && detail.rootPath === detailTarget) { + return; + } + const requestId = ++detailRequestRef.current; + setDetailLoading(true); + setDetailPath(detailTarget); + const timeout = window.setTimeout(() => { + void window.ade.project + .getDetail(detailTarget) + .then((result) => { + if (detailRequestRef.current !== requestId) return; + setDetail(result); + }) + .catch(() => { + if (detailRequestRef.current !== requestId) return; + setDetail(null); + }) + .finally(() => { + if (detailRequestRef.current !== requestId) return; + setDetailLoading(false); + }); + }, 140); + return () => { + window.clearTimeout(timeout); + }; + }, [detail, detailTarget, mode, open]); + + useEffect(() => { + if (mode !== "project-browse") return; + if (browseRows.length === 0 && !openableProjectRoot) { + if (browseSelectedIdx !== -1) setBrowseSelectedIdx(-1); + return; + } + if (openableProjectRoot && browseSelectedIdx < -1) { + setBrowseSelectedIdx(-1); + return; + } + if (!openableProjectRoot && browseSelectedIdx < 0 && browseRows.length > 0) { + setBrowseSelectedIdx(0); + return; + } + if (browseSelectedIdx >= browseRows.length) { + setBrowseSelectedIdx(openableProjectRoot ? -1 : Math.max(0, browseRows.length - 1)); + } + }, [browseRows.length, browseSelectedIdx, mode, openableProjectRoot]); + + const scrollToSelected = useCallback((idx: number) => { + if (!listRef.current || idx < 0) return; + const items = listRef.current.querySelectorAll("[data-cmd-item]"); + const target = items[idx]; + if (target instanceof HTMLElement && typeof target.scrollIntoView === "function") { + target.scrollIntoView({ block: "nearest" }); + } + }, []); + + useEffect(() => { + if (mode === "default") { + scrollToSelected(selectedIdx); + return; + } + scrollToSelected(browseSelectedIdx); + }, [browseSelectedIdx, mode, scrollToSelected, selectedIdx]); + + const runCommand = useCallback( + (command: Command) => { + void Promise.resolve(command.run()) + .then(() => { + if (command.closeOnRun === false) return; + onOpenChange(false); + }) + .catch((error) => { + console.error("Command palette command failed", error); + }); }, [onOpenChange] ); - const runSelected = useCallback(() => { - const cmd = filtered[selectedIdx]; - if (!cmd) return; - runCommand(cmd); - }, [filtered, selectedIdx, runCommand]); + const activateBrowseRow = useCallback((row: BrowseRow) => { + setBrowseError(null); + setBrowseInput(row.path); + }, []); - const handleKeyDown = useCallback( - (e: React.KeyboardEvent) => { - if (e.key === "ArrowDown") { - e.preventDefault(); + const handleOpenProject = useCallback( + async (targetPath: string | null | undefined) => { + const nextTarget = typeof targetPath === "string" ? targetPath.trim() : ""; + if (!nextTarget) return; + setBrowseError(null); + setOpenProjectPending(true); + try { + await switchProjectToPath(nextTarget); + onOpenChange(false); + } catch (error) { + setBrowseError(extractError(error)); + } finally { + setOpenProjectPending(false); + } + }, + [onOpenChange, switchProjectToPath] + ); + + const handleChooseInSystemPicker = useCallback(async () => { + setBrowseError(null); + setSystemPickerPending(true); + try { + const selected = await window.ade.project.chooseDirectory({ + title: "Open project", + defaultPath: browseResult?.exactDirectoryPath ?? browseResult?.directoryPath ?? undefined, + }); + if (!selected) return; + await handleOpenProject(selected); + } catch (error) { + setBrowseError(extractError(error)); + } finally { + setSystemPickerPending(false); + } + }, [browseResult?.directoryPath, browseResult?.exactDirectoryPath, handleOpenProject]); + + const handleDefaultKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + if (filtered.length === 0) return; + event.preventDefault(); setSelectedIdx((prev) => (prev + 1) % filtered.length); - } else if (e.key === "ArrowUp") { - e.preventDefault(); + return; + } + if (event.key === "ArrowUp") { + if (filtered.length === 0) return; + event.preventDefault(); setSelectedIdx((prev) => (prev - 1 + filtered.length) % filtered.length); - } else if (e.key === "Enter") { - e.preventDefault(); - runSelected(); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const command = filtered[selectedIdx]; + if (!command) return; + runCommand(command); } }, - [filtered.length, runSelected] + [filtered, runCommand, selectedIdx] ); - // Scroll selected item into view - const scrollToSelected = useCallback((idx: number) => { - if (!listRef.current) return; - const items = listRef.current.querySelectorAll("[data-cmd-item]"); - items[idx]?.scrollIntoView({ block: "nearest" }); + const handleBrowseKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "ArrowDown") { + if (browseRows.length === 0) return; + event.preventDefault(); + setBrowseSelectedIdx((prev) => { + if (prev < 0) return 0; + return (prev + 1) % browseRows.length; + }); + return; + } + if (event.key === "ArrowUp") { + if (browseRows.length === 0) return; + event.preventDefault(); + setBrowseSelectedIdx((prev) => { + if (prev < 0) return browseRows.length - 1; + return (prev - 1 + browseRows.length) % browseRows.length; + }); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + const isOpenShortcut = event.metaKey || event.ctrlKey; + if (isOpenShortcut && openTarget) { + void handleOpenProject(openTarget); + return; + } + if (browseSelectedIdx >= 0) { + const row = browseRows[browseSelectedIdx]; + if (row) activateBrowseRow(row); + return; + } + if (canOpenProject) { + void handleOpenProject(openableProjectRoot); + } + } + }, + [activateBrowseRow, browseRows, browseSelectedIdx, canOpenProject, handleOpenProject, openTarget, openableProjectRoot] + ); + + const handleDragEnter = useCallback((event: React.DragEvent) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + dragCounterRef.current += 1; + setIsDragging(true); }, []); - // Keep selected in bounds when filter changes - React.useEffect(() => { - if (selectedIdx >= filtered.length) { - setSelectedIdx(Math.max(0, filtered.length - 1)); - } - }, [filtered.length, selectedIdx]); + const handleDragOver = useCallback((event: React.DragEvent) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + }, []); - React.useEffect(() => { - scrollToSelected(selectedIdx); - }, [selectedIdx, scrollToSelected]); + const handleDragLeave = useCallback((event: React.DragEvent) => { + if (!event.dataTransfer?.types?.includes("Files")) return; + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); + if (dragCounterRef.current === 0) setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + dragCounterRef.current = 0; + setIsDragging(false); + const file = event.dataTransfer?.files?.[0]; + if (!file) return; + const droppedPath = window.ade.project.getDroppedPath(file); + if (!droppedPath) { + setBrowseError("Could not read the dropped folder path."); + return; + } + const nextBrowseInput = withTrailingSeparator(droppedPath); + const requestId = ++browseRequestRef.current; + setBrowseLoading(true); + setBrowseError(null); + void window.ade.project + .browseDirectories({ + partialPath: nextBrowseInput, + cwd: project?.rootPath ?? null, + limit: 200, + }) + .then((result) => { + if (browseRequestRef.current !== requestId) return; + const nextTarget = + result.openableProjectRoot + ?? result.exactDirectoryPath + ?? result.directoryPath + ?? droppedPath; + if (nextTarget) { + void handleOpenProject(nextTarget); + return; + } + setBrowseInput(nextBrowseInput); + }) + .catch((error) => { + if (browseRequestRef.current !== requestId) return; + setBrowseError(extractError(error)); + }) + .finally(() => { + if (browseRequestRef.current !== requestId) return; + setBrowseLoading(false); + }); + }, + [handleOpenProject, project?.rootPath] + ); + + const isBrowsing = mode === "project-browse"; + const resultHeightClass = isBrowsing ? "h-[620px] max-h-[86vh]" : "max-h-[400px]"; + const widthClass = isBrowsing ? "w-[1080px]" : "w-[680px]"; + const positionClass = isBrowsing + ? "fixed inset-0 z-[130] m-auto" + : "fixed left-1/2 top-[12%] z-[130] -translate-x-1/2"; + const inputPlaceholder = isBrowsing + ? "Paste a path, type to filter, or drop a folder anywhere…" + : "Search commands..."; return ( - { - onOpenChange(v); - if (!v) { - setQ(""); - setSelectedIdx(0); - } - }} - > + {open && ( - e.preventDefault()}> + event.preventDefault()}> - {/* Hidden accessible title */} - Command palette + {isBrowsing && ( +
+ )} + + {mode === "project-browse" ? "Project browser" : "Command palette"} + + + {mode === "project-browse" + ? "Browse folders in ADE and open a Git repository without leaving the app." + : "Search ADE commands and jump to actions quickly."} + - {/* Search input */}
- + { - setQ(e.target.value); + value={isBrowsing ? browseInput : q} + onChange={(event) => { + if (isBrowsing) { + setBrowseInput(event.target.value); + setBrowseSelectedIdx(0); + return; + } + setQ(event.target.value); setSelectedIdx(0); }} - onKeyDown={handleKeyDown} - placeholder="Search commands..." - className="h-12 w-full bg-transparent text-lg text-[#FAFAFA] outline-none placeholder:text-[#71717A] font-mono" - autoFocus - /> - + autoFocus + /> + ESC
- {/* Results */} -
- {filtered.length === 0 ? ( -
No matches.
- ) : ( -
    - {(() => { - let flatIdx = 0; - return grouped.map((group) => ( -
  • -
    - {group.label} -
    -
      - {group.items.map((cmd) => { - const idx = flatIdx++; - const isSelected = idx === selectedIdx; - return ( -
    • - -
    • - ); - })} -
    -
  • - )); - })()} -
- )} -
+
+ + + + ); + })} + + )} + + + + + + {isDragging && ( +
+
+ + Drop to open +
+
+ )} + +
+
+ {browseError ? ( + + + {browseError} + + ) : isCurrentProjectTarget ? ( + Already open. + ) : ( + <> + ↑↓ + navigate + + step in + {openShortcutLabel} + open directory + + )} +
+ +
+ + +
+
+ + ) : ( +
+ {filtered.length === 0 ? ( +
No matches.
+ ) : ( +
    + {(() => { + let flatIndex = 0; + return grouped.map((group) => ( +
  • +
    + {group.label} +
    +
      + {group.items.map((command) => { + const index = flatIndex++; + const isSelected = index === selectedIdx; + return ( +
    • + +
    • + ); + })} +
    +
  • + )); + })()} +
+ )} +
+ )}
@@ -355,3 +1034,338 @@ export function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenCh
); } + +type BrowsePreviewProps = { + detail: ProjectDetail | null; + detailLoading: boolean; + detailPath: string | null; + highlightedPath: string | null; + highlightedIsRepo: boolean; + browseResult: ProjectBrowseResult | null; + activeProjectPath: string | null; +}; + +function BrowsePreview({ + detail, + detailLoading, + detailPath, + highlightedPath, + highlightedIsRepo, + browseResult, + activeProjectPath, +}: BrowsePreviewProps) { + const showingDetailForPath = detailPath === highlightedPath ? detail : null; + const isLoading = detailLoading && detailPath === highlightedPath && !showingDetailForPath; + + if (!highlightedPath) { + return ( +
+
+
+ +
+

Pick a folder to see its repo details, or drop one here.

+
+
+ ); + } + + const displayName = pathLabel(highlightedPath) || highlightedPath; + const isActiveProject = activeProjectPath === highlightedPath; + + return ( +
+
+ +
+
+
+ {highlightedIsRepo ? ( + + + + ) : ( + + + + )} +

{displayName}

+ {isActiveProject && ( + + Open now + + )} +
+
{highlightedPath}
+
+ + {isLoading ? ( + + ) : highlightedIsRepo && showingDetailForPath ? ( + + ) : !highlightedIsRepo ? ( + + ) : null} +
+
+ ); +} + +function RepoDetailBlocks({ detail }: { detail: ProjectDetail }) { + const lastCommitRelative = detail.lastCommit ? relativeFromNow(detail.lastCommit.isoDate) : null; + const lastOpenedRelative = relativeFromNow(detail.lastOpenedAt); + + return ( + <> +
+ {detail.branchName && ( + } tone="accent"> + {detail.branchName} + + )} + {detail.aheadBehind && (detail.aheadBehind.ahead > 0 || detail.aheadBehind.behind > 0) && ( + + {detail.aheadBehind.ahead > 0 ? `↑${detail.aheadBehind.ahead} ` : ""} + {detail.aheadBehind.behind > 0 ? `↓${detail.aheadBehind.behind}` : ""} + + )} + {typeof detail.dirtyCount === "number" && detail.dirtyCount > 0 && ( + {detail.dirtyCount} uncommitted + )} + {typeof detail.dirtyCount === "number" && detail.dirtyCount === 0 && detail.branchName && ( + clean + )} + {typeof detail.laneCount === "number" && detail.laneCount > 0 && ( + } tone="muted"> + {detail.laneCount} lane{detail.laneCount === 1 ? "" : "s"} + + )} + {lastOpenedRelative && ( + } tone="muted"> + opened {lastOpenedRelative} + + )} +
+ + {detail.lastCommit && ( +
+
+ Last commit +
+
{detail.lastCommit.subject}
+
+ {detail.lastCommit.shortSha} + {lastCommitRelative && · {lastCommitRelative}} +
+
+ )} + + {detail.readmeExcerpt && ( +
+
+ Readme +
+ +
+ )} + + {detail.languages.length > 0 && ( +
+
+ Languages +
+
+ {detail.languages.map((lang) => { + const color = LANGUAGE_SWATCHES[lang.name] ?? "var(--color-accent)"; + return ( + + + {lang.name} + + {Math.round(lang.fraction * 100)}% + + + ); + })} +
+
+ )} + + ); +} + +function PlainDirectoryBlock({ + browseResult, + highlightedPath, + detail, +}: { + browseResult: ProjectBrowseResult | null; + highlightedPath: string; + detail: ProjectDetail | null; +}) { + const subCount = detail?.subdirectoryCount ?? (browseResult?.exactDirectoryPath === highlightedPath + ? browseResult.entries.length + : null); + return ( +
+
+ Plain folder + {typeof subCount === "number" && ( + {subCount} subfolder{subCount === 1 ? "" : "s"} + )} +
+

+ No git repository here. Step into a subfolder, paste a path, or drop a folder to force-open. +

+
+ ); +} + +const README_COMPONENTS: Components = { + h1: ({ children }) => ( +

{children}

+ ), + h2: ({ children }) => ( +

{children}

+ ), + h3: ({ children }) => ( +
{children}
+ ), + h4: ({ children }) => ( +
{children}
+ ), + p: ({ children }) =>

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + a: ({ children, href }) => ( + { + event.preventDefault(); + if (href) void window.ade?.app?.openExternal?.(href).catch(() => {}); + }} + className="text-[var(--color-accent)] underline decoration-[var(--color-accent)]/40 underline-offset-2 hover:decoration-[var(--color-accent)]" + > + {children} + + ), + code: ({ children, className }) => { + if (className && /language-/.test(className)) { + return {children}; + } + return ( + + {children} + + ); + }, + pre: ({ children }) => ( +
    +      {children}
    +    
    + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + {children} + ), + td: ({ children }) => {children}, + img: () => null, +}; + +function ReadmeMarkdown({ content }: { content: string }) { + return ( +
    + + {content} + +
    + ); +} + +function PreviewSkeleton() { + return ( +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} + +function StatusChip({ + children, + icon, + tone, +}: { + children: React.ReactNode; + icon?: React.ReactNode; + tone: "accent" | "muted" | "warn"; +}) { + const toneStyle = + tone === "accent" + ? { + background: "color-mix(in srgb, var(--color-accent) 14%, transparent)", + borderColor: "color-mix(in srgb, var(--color-accent) 40%, var(--color-border))", + color: "var(--color-accent)", + } + : tone === "warn" + ? { + background: "rgba(248, 113, 113, 0.12)", + borderColor: "rgba(248, 113, 113, 0.45)", + color: "#FCA5A5", + } + : { + background: "var(--color-surface)", + borderColor: "var(--color-border)", + color: "var(--color-muted-fg)", + }; + return ( + + {icon} + {children} + + ); +} diff --git a/apps/desktop/src/renderer/components/app/TopBar.tsx b/apps/desktop/src/renderer/components/app/TopBar.tsx index 5c35e5077..c5ee655e6 100644 --- a/apps/desktop/src/renderer/components/app/TopBar.tsx +++ b/apps/desktop/src/renderer/components/app/TopBar.tsx @@ -302,7 +302,7 @@ export function TopBar() { ) : ( <> - {recentProjects.map((rp, idx) => { + {recentProjects.map((rp, idx) => { const isCurrent = project?.rootPath === rp.rootPath; const isMissing = !rp.exists; const isRelocating = relocatingPath === rp.rootPath; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 5044d6bd0..80d892b69 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -8,7 +8,7 @@ import { AgentChatComposer } from "./AgentChatComposer"; afterEach(cleanup); -function renderComposer(overrides: Partial> = {}) { +function buildComposerProps(overrides: Partial> = {}) { const props: ComponentProps = { modelId: "openai/gpt-5.4-codex", availableModelIds: ["openai/gpt-5.4-codex"], @@ -50,6 +50,12 @@ function renderComposer(overrides: Partial> = {}) { + const props = buildComposerProps(overrides); + render(); return props; } @@ -91,7 +97,7 @@ describe("AgentChatComposer", () => { expect(props.onClearDraft).not.toHaveBeenCalled(); }); - it("renders Claude mode buttons without a Chat toggle", () => { + it("renders Claude mode dropdown without a Chat toggle", () => { renderComposer({ sessionProvider: "claude", modelId: "anthropic/claude-sonnet-4-6", @@ -99,10 +105,16 @@ describe("AgentChatComposer", () => { }); expect(screen.queryByRole("button", { name: "Chat" })).toBeNull(); - expect(screen.getByRole("button", { name: "Default" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Accept edits" })).toBeTruthy(); - expect(screen.getByRole("button", { name: "Bypass" })).toBeTruthy(); + const trigger = screen.getByRole("button", { name: "Claude permission mode" }); + expect(trigger.textContent).toContain("Ask permissions"); + + fireEvent.click(trigger); + + expect(screen.getByRole("listbox", { name: "Claude permission mode" })).toBeTruthy(); + expect(screen.getByRole("option", { name: /Ask permissions/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /Accept edits/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /Plan mode/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /Bypass permissions/ })).toBeTruthy(); }); it("routes Claude plan through both interaction and permission callbacks", () => { @@ -116,7 +128,8 @@ describe("AgentChatComposer", () => { onClaudePermissionModeChange, }); - fireEvent.click(screen.getByRole("button", { name: "Plan" })); + fireEvent.click(screen.getByRole("button", { name: "Claude permission mode" })); + fireEvent.click(screen.getByRole("option", { name: /Plan mode/ })); expect(onInteractionModeChange).toHaveBeenCalledWith("plan"); expect(onClaudePermissionModeChange).toHaveBeenCalledWith("plan"); @@ -135,7 +148,8 @@ describe("AgentChatComposer", () => { onClaudePermissionModeChange, }); - fireEvent.click(screen.getByRole("button", { name: "Plan" })); + fireEvent.click(screen.getByRole("button", { name: "Claude permission mode" })); + fireEvent.click(screen.getByRole("option", { name: /Plan mode/ })); expect(onClaudeModeChange).toHaveBeenCalledWith("plan"); expect(onInteractionModeChange).not.toHaveBeenCalled(); @@ -397,4 +411,37 @@ describe("AgentChatComposer", () => { expect(textarea.className).toContain("resize-none"); }); + it("focuses the grid composer when the tile becomes active", () => { + const props = buildComposerProps({ + layoutVariant: "grid-tile", + composerMaxHeightPx: 128, + isActive: false, + }); + const view = render(); + + const textarea = screen.getByPlaceholderText("Steer the active turn...") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(textarea); + + view.rerender(); + + expect(document.activeElement).toBe(textarea); + }); + + it("does not autofocus the grid composer when only hover state changes", () => { + const props = buildComposerProps({ + layoutVariant: "grid-tile", + composerMaxHeightPx: 128, + isActive: false, + shouldAutofocus: false, + }); + const view = render(); + + const textarea = screen.getByPlaceholderText("Steer the active turn...") as HTMLTextAreaElement; + expect(document.activeElement).not.toBe(textarea); + + view.rerender(); + + expect(document.activeElement).not.toBe(textarea); + }); + }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 6d74ceb00..7d6e1cc92 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -107,13 +107,62 @@ function buildSlashCommands(sdkCommands: AgentChatSlashCommand[], modelFamily?: return result; } -const CLAUDE_MODE_OPTIONS: Array<{ value: AgentChatClaudePermissionMode; label: string; detail: string; safety: "safe" | "semi-auto" | "danger" }> = [ - { value: "default", label: "Default", detail: "Claude uses the normal approval flow for reads, edits, and tools.", safety: "safe" }, - { value: "plan", label: "Plan", detail: "Read-only Claude turns for analysis and implementation planning.", safety: "safe" }, - { value: "acceptEdits", label: "Accept edits", detail: "File edits are auto-approved; higher-risk actions still prompt.", safety: "semi-auto" }, - { value: "bypassPermissions", label: "Bypass", detail: "Skip Claude permission prompts for this chat.", safety: "danger" }, +type ClaudeModeTone = "green" | "blue" | "purple" | "red"; + +type ClaudeModeOption = { + value: AgentChatClaudePermissionMode; + label: string; + detail: string; + tone: ClaudeModeTone; +}; + +const CLAUDE_MODE_OPTIONS: ClaudeModeOption[] = [ + { value: "default", label: "Ask permissions", detail: "Claude asks before edits, Bash, and other sensitive tools.", tone: "green" }, + { value: "acceptEdits", label: "Accept edits", detail: "File edits are auto-approved; higher-risk actions still prompt.", tone: "blue" }, + { value: "plan", label: "Plan mode", detail: "Read-only Claude turns for analysis and implementation planning.", tone: "purple" }, + { value: "bypassPermissions", label: "Bypass permissions", detail: "Skip every Claude permission prompt for this chat.", tone: "red" }, ]; +const CLAUDE_MODE_TONE_STYLES: Record< + ClaudeModeTone, + { + activeBg: string; + activeText: string; + activeBorder: string; + dot: string; + hoverBg: string; + } +> = { + green: { + activeBg: "bg-emerald-500/12", + activeText: "text-emerald-200", + activeBorder: "border-emerald-500/35", + dot: "bg-emerald-400", + hoverBg: "hover:bg-emerald-500/10 hover:text-emerald-100", + }, + blue: { + activeBg: "bg-sky-500/14", + activeText: "text-sky-200", + activeBorder: "border-sky-500/35", + dot: "bg-sky-400", + hoverBg: "hover:bg-sky-500/10 hover:text-sky-100", + }, + purple: { + activeBg: "bg-violet-500/14", + activeText: "text-violet-200", + activeBorder: "border-violet-500/35", + dot: "bg-violet-400", + hoverBg: "hover:bg-violet-500/10 hover:text-violet-100", + }, + red: { + activeBg: "bg-red-500/14", + activeText: "text-red-200", + activeBorder: "border-red-500/35", + dot: "bg-red-400", + hoverBg: "hover:bg-red-500/10 hover:text-red-100", + }, +}; + type CodexPermissionPreset = "plan" | "edit" | "full-auto" | "custom"; function resolveCodexPermissionPreset(args: { @@ -266,6 +315,8 @@ export function AgentChatComposer({ surfaceMode = "standard", layoutVariant = "standard", composerMaxHeightPx = null, + isActive = false, + shouldAutofocus = isActive, sdkSlashCommands = [], modelId, availableModelIds, @@ -331,6 +382,8 @@ export function AgentChatComposer({ surfaceMode?: ChatSurfaceMode; layoutVariant?: "standard" | "grid-tile"; composerMaxHeightPx?: number | null; + isActive?: boolean; + shouldAutofocus?: boolean; sdkSlashCommands?: AgentChatSlashCommand[]; modelId: string; availableModelIds?: string[]; @@ -409,6 +462,8 @@ export function AgentChatComposer({ const [slashCursor, setSlashCursor] = useState(0); const [hoveredClaudeMode, setHoveredClaudeMode] = useState(null); const [hoveredCodexPreset, setHoveredCodexPreset] = useState<"plan" | "edit" | "full-auto" | null>(null); + const [claudeModePickerOpen, setClaudeModePickerOpen] = useState(false); + const claudeModePickerRef = useRef(null); const [dragActive, setDragActive] = useState(false); const [commandMenuTrigger, setCommandMenuTrigger] = useState<{ type: "at" | "slash"; query: string; cursorIndex: number } | null>(null); @@ -430,6 +485,11 @@ export function AgentChatComposer({ el.style.height = `${next}px`; el.style.overflowY = el.scrollHeight > maxH ? "auto" : "hidden"; }, [layoutVariant, composerMaxHeightPx]); + useEffect(() => { + resizeTextarea(); + if (!shouldAutofocus) return; + textareaRef.current?.focus({ preventScroll: true }); + }, [resizeTextarea, shouldAutofocus]); useLayoutEffect(() => { resizeTextarea(); }, [draft, resizeTextarea]); @@ -600,6 +660,28 @@ export function AgentChatComposer({ const option = CLAUDE_MODE_OPTIONS.find((item) => item.value === (hoveredClaudeMode ?? claudeSelectionMode)); return option?.detail ?? null; }, [claudeSelectionMode, hoveredClaudeMode, sessionProvider]); + + useEffect(() => { + if (!claudeModePickerOpen) return; + const handleClick = (event: MouseEvent) => { + if (!claudeModePickerRef.current) return; + if (claudeModePickerRef.current.contains(event.target as Node)) return; + setClaudeModePickerOpen(false); + setHoveredClaudeMode(null); + }; + const handleKey = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setClaudeModePickerOpen(false); + setHoveredClaudeMode(null); + } + }; + window.addEventListener("mousedown", handleClick); + window.addEventListener("keydown", handleKey); + return () => { + window.removeEventListener("mousedown", handleClick); + window.removeEventListener("keydown", handleKey); + }; + }, [claudeModePickerOpen]); const codexCustomSummary = useMemo(() => { if (sessionProvider !== "codex" || codexPreset !== "custom") return null; if (codexConfigSource === "config-toml") { @@ -672,21 +754,94 @@ export function AgentChatComposer({ ); if (sessionProvider === "claude") { + const selectedOption = + CLAUDE_MODE_OPTIONS.find((option) => option.value === claudeSelectionMode) ?? CLAUDE_MODE_OPTIONS[0]; + const selectedTone = CLAUDE_MODE_TONE_STYLES[selectedOption.tone]; + const applyClaudeMode = (mode: AgentChatClaudePermissionMode) => { + if (onClaudeModeChange) { + onClaudeModeChange(mode); + return; + } + if (mode === "plan") { + onInteractionModeChange?.("plan"); + onClaudePermissionModeChange?.("plan"); + return; + } + onInteractionModeChange?.("default"); + onClaudePermissionModeChange?.(mode); + }; return (
    - {renderButtonGroup("Claude", claudeSelectionMode, CLAUDE_MODE_OPTIONS, (mode) => { - if (onClaudeModeChange) { - onClaudeModeChange(mode); - return; - } - if (mode === "plan") { - onInteractionModeChange?.("plan"); - onClaudePermissionModeChange?.("plan"); - return; - } - onInteractionModeChange?.("default"); - onClaudePermissionModeChange?.(mode); - }, nativeControlsDisabled, setHoveredClaudeMode)} +
    + + {claudeModePickerOpen ? ( +
    +
    + Mode +
    +
      + {CLAUDE_MODE_OPTIONS.map((option) => { + const tone = CLAUDE_MODE_TONE_STYLES[option.tone]; + const active = option.value === claudeSelectionMode; + return ( +
    • + +
    • + ); + })} +
    +
    + ) : null} +
    ); } @@ -848,6 +1003,7 @@ export function AgentChatComposer({ }, [ claudeSelectionMode, claudePermissionMode, + claudeModePickerOpen, applyCodexPreset, codexPreset, codexPresetOptions, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index b12c47eaa..c89f82d06 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -464,7 +464,8 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - fireEvent.click(await screen.findByRole("button", { name: "Plan" })); + fireEvent.click(await screen.findByRole("button", { name: "Claude permission mode" })); + fireEvent.click(await screen.findByRole("option", { name: "Plan mode" })); await waitFor(() => { expect(updateSession).toHaveBeenCalledWith(expect.objectContaining({ @@ -504,8 +505,8 @@ describe("AgentChatPane submit recovery", () => { renderPane(session); - const planButton = await screen.findByRole("button", { name: "Plan" }); - expect(planButton.getAttribute("aria-pressed")).toBe("false"); + const trigger = await screen.findByRole("button", { name: "Claude permission mode" }); + expect(trigger.textContent ?? "").not.toContain("Plan mode"); sessions[0] = { ...session, @@ -528,7 +529,7 @@ describe("AgentChatPane submit recovery", () => { }); await waitFor(() => { - expect(screen.getByRole("button", { name: "Plan" }).getAttribute("aria-pressed")).toBe("true"); + expect(trigger.textContent ?? "").toContain("Plan mode"); }); }); @@ -744,7 +745,9 @@ describe("AgentChatPane submit recovery", () => { , ); - fireEvent.click(await screen.findByRole("button", { name: "Handoff" })); + const handoffBtn = await screen.findByRole("button", { name: "Handoff" }) as HTMLButtonElement; + await waitFor(() => expect(handoffBtn.disabled).toBe(false)); + fireEvent.click(handoffBtn); fireEvent.click(await screen.findByRole("button", { name: "Create handoff chat" })); await waitFor(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index b537b40c2..72c0e55d2 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -678,6 +678,8 @@ export function AgentChatPane({ presentation, embeddedWorkLayout = false, layoutVariant = "standard", + isTileActive = false, + shouldAutofocusComposer = false, onSessionCreated, availableLanes, onLaneChange, @@ -697,6 +699,8 @@ export function AgentChatPane({ /** Work tab draft: flatter shell, no duplicate header chrome above the composer. */ embeddedWorkLayout?: boolean; layoutVariant?: "standard" | "grid-tile"; + isTileActive?: boolean; + shouldAutofocusComposer?: boolean; onSessionCreated?: (session: AgentChatSession) => void | Promise; /** Available lanes for the lane selector in empty state */ availableLanes?: Array<{ id: string; name: string; color?: string | null }>; @@ -769,8 +773,7 @@ export function AgentChatPane({ const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); const shellRef = useRef(null); - const [composerMaxHeightPx, setComposerMaxHeightPx] = useState(null); - const composerMaxHeightPxRef = useRef(null); + const composerMaxHeightPx = layoutVariant === "grid-tile" ? 144 : null; const sessionsRef = useRef(sessions); const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); @@ -967,10 +970,6 @@ export function AgentChatPane({ sessionsRef.current = sessions; }, [sessions]); - useEffect(() => { - composerMaxHeightPxRef.current = composerMaxHeightPx; - }, [composerMaxHeightPx]); - const modelSelectionDiffersFromSession = Boolean(selectedSession && selectedSessionModelId && selectedSessionModelId !== modelId); const sessionProvider = useMemo(() => { @@ -1097,8 +1096,12 @@ export function AgentChatPane({ }); const refreshAvailableModels = useCallback(async () => { + const shouldRefreshOpenCodeInventory = sessionProvider === "opencode"; try { - const status = await getAiStatusCached({ projectRoot }); + const status = await getAiStatusCached({ + projectRoot, + ...(shouldRefreshOpenCodeInventory ? { refreshOpenCodeInventory: true } : {}), + }); setAiStatus(status); setProviderConnections({ claude: status.providerConnections?.claude ?? null, @@ -1119,7 +1122,11 @@ export function AgentChatPane({ getAgentChatModelsCached({ projectRoot, provider: "codex" }).catch(() => []), getAgentChatModelsCached({ projectRoot, provider: "claude" }).catch(() => []), getAgentChatModelsCached({ projectRoot, provider: "cursor" }).catch(() => []), - getAgentChatModelsCached({ projectRoot, provider: "opencode" }).catch(() => []), + getAgentChatModelsCached({ + projectRoot, + provider: "opencode", + activateRuntime: shouldRefreshOpenCodeInventory, + }).catch(() => []), ]); const available = new Set(); @@ -1160,7 +1167,7 @@ export function AgentChatPane({ setAvailableModelIds([]); return []; } - }, [projectRoot]); + }, [projectRoot, sessionProvider]); const touchSession = useCallback((sessionId: string | null | undefined, touchedAt = new Date().toISOString()) => { if (!sessionId) return; @@ -1544,6 +1551,8 @@ export function AgentChatPane({ try { await Promise.all([refreshAvailableModels(), refreshSessions()]); + } catch { + // boot-time refresh errors are swallowed here; individual callbacks fall back to empty state } finally { if (!cancelled) { setLoading(false); @@ -2484,26 +2493,6 @@ export function AgentChatPane({ } }, [isPersistentIdentitySurface, patchSessionSummary, refreshComputerUseSnapshot, refreshSessions, selectedSessionId, sessionMutationKind]); - useEffect(() => { - if (layoutVariant !== "grid-tile") { - setComposerMaxHeightPx(null); - return; - } - const node = shellRef.current; - if (!node || typeof ResizeObserver === "undefined") return; - const observer = new ResizeObserver((entries) => { - const entry = entries[0]; - if (!entry) return; - const next = Math.max(96, Math.min(168, Math.floor(entry.contentRect.height * 0.28))); - if (composerMaxHeightPxRef.current !== next) { - composerMaxHeightPxRef.current = next; - setComposerMaxHeightPx(next); - } - }); - observer.observe(node); - return () => observer.disconnect(); - }, [layoutVariant]); - if (!laneId) { return ( @@ -2745,6 +2734,8 @@ export function AgentChatPane({ surfaceMode={surfaceMode} layoutVariant={layoutVariant} composerMaxHeightPx={composerMaxHeightPx} + isActive={layoutVariant === "grid-tile" ? isTileActive : false} + shouldAutofocus={layoutVariant === "grid-tile" ? shouldAutofocusComposer : false} sdkSlashCommands={sdkSlashCommands} modelId={modelId} availableModelIds={effectiveAvailableModelIds} diff --git a/apps/desktop/src/renderer/components/run/RunPage.tsx b/apps/desktop/src/renderer/components/run/RunPage.tsx index d98fd767e..770bb26d8 100644 --- a/apps/desktop/src/renderer/components/run/RunPage.tsx +++ b/apps/desktop/src/renderer/components/run/RunPage.tsx @@ -3,6 +3,7 @@ import { Play, Stop, Plus, X, FolderOpen, Folder, Terminal } from "@phosphor-ico import { useAppStore } from "../../state/appStore"; import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; import { CommandCard } from "./CommandCard"; +import { CommandPalette } from "../app/CommandPalette"; import { ProcessMonitor, type RunShellSession } from "./ProcessMonitor"; import { LaneRuntimeBar } from "./LaneRuntimeBar"; import { AddCommandDialog, type AddCommandInitialValues } from "./AddCommandDialog"; @@ -78,9 +79,9 @@ function buildReadinessConfig(args: { } function WelcomeScreen() { - const openRepo = useAppStore((s) => s.openRepo); const switchProjectToPath = useAppStore((s) => s.switchProjectToPath); const [recentProjects, setRecentProjects] = useState>([]); + const [projectBrowserOpen, setProjectBrowserOpen] = useState(false); useEffect(() => { window.ade.project.listRecent().then(setRecentProjects).catch(() => {}); @@ -115,7 +116,7 @@ function WelcomeScreen() {
    )} + +
    ); } diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx index 6d24719d5..80cd754f6 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.test.tsx @@ -166,6 +166,10 @@ describe("ProvidersSection", () => { expect(ade.ai.getStatus).toHaveBeenCalledTimes(1); expect(ade.ai.listApiKeys).toHaveBeenCalledTimes(1); }); + expect(ade.ai.getStatus).toHaveBeenNthCalledWith(1, { + force: false, + refreshOpenCodeInventory: false, + }); expect((await screen.findAllByText("/Users/arul/.local/bin/claude")).length).toBeGreaterThan(0); diff --git a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx index 0d4bf2c1a..f80bdee87 100644 --- a/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx +++ b/apps/desktop/src/renderer/components/settings/ProvidersSection.tsx @@ -36,6 +36,7 @@ import { primaryButton, } from "../lanes/laneDesignTokens"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; +import { invalidateAiDiscoveryCache } from "../../lib/aiDiscoveryCache"; type CliName = "claude" | "codex" | "cursor"; type ApiKeySource = "config" | "env" | "store"; @@ -303,7 +304,6 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh useEffect(() => { void refreshStatus({ force: forceRefreshOnMount, - refreshOpenCodeInventory: true, }); }, [forceRefreshOnMount, refreshStatus]); @@ -400,6 +400,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setNotice(null); try { await window.ade.ai.storeApiKey(provider, trimmed); + invalidateAiDiscoveryCache(); setVerificationByProvider((prev) => { const next = { ...prev }; delete next[provider]; @@ -418,6 +419,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh setNotice(null); try { await window.ade.ai.deleteApiKey(provider); + invalidateAiDiscoveryCache(); setNotice(`${provider} key removed.`); if (editingProvider === provider) cancelEditing(); setVerificationByProvider((prev) => { @@ -488,6 +490,7 @@ export function ProvidersSection({ forceRefreshOnMount = false }: { forceRefresh }, } as AiConfig["localProviders"], }); + invalidateAiDiscoveryCache(); setNotice(`${LOCAL_PROVIDER_LABELS[provider]} settings saved.`); setEditingLocalProvider(null); await refreshStatus({ force: true }); diff --git a/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.test.tsx b/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.test.tsx index 9753305f9..355887f6a 100644 --- a/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.test.tsx @@ -230,8 +230,51 @@ describe("PackedSessionGrid", () => { const lastCall = layoutSetMock.mock.calls.at(-1); expect(lastCall?.[0]).toBe("work:grid:test"); expect(lastCall?.[1]).toMatchObject({ - "tile-1:col": 36, - "tile-1:row": 3, + "tile-1:colStart": 1, + "tile-1:colSpan": 13, + "tile-1:col": 13, + "tile-2:colStart": 14, + "tile-2:colSpan": 11, }); }); + + it("keeps the dragged edge anchored when resizing from the west", async () => { + const { container } = renderGrid(2, 300, 220); + + await act(async () => { + await vi.runAllTimersAsync(); + }); + triggerResizeObservers(); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + layoutSetMock.mockClear(); + + const tile = container.querySelector('[data-grid-tile-id="tile-2"]'); + const handle = container.querySelector('[data-grid-tile-id="tile-2"] [data-grid-resize-handle="w"]'); + expect(tile).toBeTruthy(); + expect(handle).toBeTruthy(); + expect(tile?.getAttribute("data-grid-col-start")).toBe("13"); + expect(tile?.getAttribute("data-grid-col-end")).toBe("24"); + + fireEvent.pointerDown(handle!, { clientX: 0, clientY: 0 }); + fireEvent.pointerMove(window, { clientX: -42, clientY: 0 }); + fireEvent.pointerUp(window); + + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + }); + + expect(layoutSetMock).toHaveBeenCalledTimes(1); + const lastCall = layoutSetMock.mock.calls.at(-1); + expect(lastCall?.[1]).toMatchObject({ + "tile-1:colSpan": 11, + "tile-1:col": 11, + "tile-2:colStart": 12, + "tile-2:colSpan": 13, + "tile-2:col": 13, + }); + expect(container.querySelector('[data-grid-tile-id="tile-2"]')?.getAttribute("data-grid-col-start")).toBe("12"); + expect(container.querySelector('[data-grid-tile-id="tile-2"]')?.getAttribute("data-grid-col-end")).toBe("24"); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx b/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx index 33ae8fc49..1238f2a8e 100644 --- a/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx +++ b/apps/desktop/src/renderer/components/terminals/PackedSessionGrid.tsx @@ -5,7 +5,6 @@ import { GRID_GAP_PX, GRID_MAX_ROW_SPAN, GRID_COLUMN_SUBDIVISIONS, - clampPackedGridSpan, computeDefaultRowSpan, computeMinimumColSpan, computeGridColumnCount, @@ -13,10 +12,13 @@ import { computePackedGridRowHeight, computePackedSpanPixels, packGridItems, + readPackedGridPlacement, readPackedGridSpan, reconcilePackedGridLayout, + clampPackedGridSpan, type PackedGridPlacement, type PackedGridSpan, + resizePackedGridItem, } from "./packedSessionGridMath"; type ResizeDirection = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; @@ -27,6 +29,7 @@ type PackedSessionGridTile = { minHeight: number; selected?: boolean; onSelect?: () => void; + onHover?: () => void; header: React.ReactNode; children: React.ReactNode; className?: string; @@ -37,8 +40,8 @@ type ResizeState = { direction: ResizeDirection; startX: number; startY: number; - startSpan: PackedGridSpan; - currentSpan: PackedGridSpan; + startPlacementsById: Record; + currentPlacementsById: Record; pointerId: number; pointerTarget: HTMLElement | null; }; @@ -66,10 +69,12 @@ export function PackedSessionGrid({ layoutId, tiles, className, + onViewportMouseLeave, }: { layoutId: string; tiles: PackedSessionGridTile[]; className?: string; + onViewportMouseLeave?: () => void; }) { const { layout, loaded, saveLayout } = useDockLayout(layoutId, {}); const viewportRef = useRef(null); @@ -77,6 +82,7 @@ export function PackedSessionGrid({ const [viewportSize, setViewportSize] = useState({ width: 0, height: 0 }); const [resizingTileId, setResizingTileId] = useState(null); const [draftSpansById, setDraftSpansById] = useState>({}); + const [draftPlacementsById, setDraftPlacementsById] = useState>({}); useEffect(() => { const node = viewportRef.current; @@ -117,20 +123,6 @@ export function PackedSessionGrid({ return next; }, [defaultRowSpan, minRowSpans, tiles]); - useEffect(() => { - if (!loaded) return; - const nextLayout = reconcilePackedGridLayout({ - layout, - tileIds: tiles.map((tile) => tile.id), - defaultSpansById, - }); - const sameKeys = Object.keys(nextLayout).length === Object.keys(layout).length - && Object.entries(nextLayout).every(([key, value]) => layout[key] === value); - if (!sameKeys) { - saveLayout(nextLayout); - } - }, [defaultSpansById, layout, loaded, saveLayout, tiles]); - const columnCount = useMemo(() => { const minTileWidth = tiles.reduce((largest, tile) => Math.max(largest, tile.minWidth), 0); return computeGridColumnCount({ @@ -185,13 +177,38 @@ export function PackedSessionGrid({ return next; }, [draftSpansById, spansById]); + const placementsById = useMemo(() => { + const next: Record = {}; + for (const tile of tiles) { + const persisted = readPackedGridPlacement(layout, tile.id); + if (persisted) { + next[tile.id] = persisted; + } + } + return next; + }, [layout, tiles]); + + const effectivePlacementsById = useMemo(() => { + if (!Object.keys(draftPlacementsById).length) return placementsById; + return { + ...placementsById, + ...draftPlacementsById, + }; + }, [draftPlacementsById, placementsById]); + const packedItems = useMemo(() => { - return tiles.map((tile) => ({ + const next = tiles.map((tile) => ({ id: tile.id, minRowSpan: minRowSpans[tile.id] ?? 1, span: effectiveSpansById[tile.id] ?? defaultSpansById[tile.id] ?? { colSpan: 1, rowSpan: 1 }, + placement: effectivePlacementsById[tile.id], })); - }, [defaultSpansById, effectiveSpansById, minRowSpans, tiles]); + if (!resizingTileId) return next; + const activeIndex = next.findIndex((item) => item.id === resizingTileId); + if (activeIndex <= 0) return next; + const [active] = next.splice(activeIndex, 1); + return [active, ...next]; + }, [defaultSpansById, effectivePlacementsById, effectiveSpansById, minRowSpans, resizingTileId, tiles]); const packed = useMemo( () => packGridItems(packedItems, trackCount), @@ -215,6 +232,21 @@ export function PackedSessionGrid({ return next; }, [packed.placements]); + useEffect(() => { + if (!loaded) return; + const nextLayout = reconcilePackedGridLayout({ + layout, + tileIds: tiles.map((tile) => tile.id), + defaultSpansById, + columnCount: trackCount, + }); + const sameKeys = Object.keys(nextLayout).length === Object.keys(layout).length + && Object.entries(nextLayout).every(([key, value]) => layout[key] === value); + if (!sameKeys) { + saveLayout(nextLayout); + } + }, [defaultSpansById, layout, loaded, saveLayout, tiles, trackCount]); + const contentHeight = useMemo( () => computePackedSpanPixels(packed.totalRows, rowHeight), [packed.totalRows, rowHeight], @@ -229,6 +261,7 @@ export function PackedSessionGrid({ setResizingTileId(null); if (clearDraft) { setDraftSpansById({}); + setDraftPlacementsById({}); } document.body.style.userSelect = ""; document.body.style.cursor = ""; @@ -246,63 +279,83 @@ export function PackedSessionGrid({ if (!state || trackWidth <= 0 || rowHeight <= 0) return; const colUnit = trackWidth + GRID_GAP_PX; const rowUnit = rowHeight + GRID_GAP_PX; - - let nextColSpan = state.startSpan.colSpan; - let nextRowSpan = state.startSpan.rowSpan; - - if (hasHorizontalResize(state.direction)) { - const rawDelta = (event.clientX - state.startX) / colUnit; - const normalizedDelta = state.direction.includes("w") ? -rawDelta : rawDelta; - nextColSpan = state.startSpan.colSpan + Math.round(normalizedDelta); - } - - if (hasVerticalResize(state.direction)) { - const rawDelta = (event.clientY - state.startY) / rowUnit; - const normalizedDelta = state.direction.includes("n") ? -rawDelta : rawDelta; - nextRowSpan = state.startSpan.rowSpan + Math.round(normalizedDelta); - } - - const clamped = clampPackedGridSpan({ - span: { colSpan: nextColSpan, rowSpan: nextRowSpan }, + const deltaCols = hasHorizontalResize(state.direction) + ? Math.round((event.clientX - state.startX) / colUnit) + : 0; + const deltaRows = hasVerticalResize(state.direction) + ? Math.round((event.clientY - state.startY) / rowUnit) + : 0; + const nextPlacementsById = resizePackedGridItem({ + placementsById: state.startPlacementsById, + tileId: state.tileId, + direction: state.direction, + deltaCols, + deltaRows, columnCount: trackCount, - minColSpan: minColSpans[state.tileId] ?? 1, - minRowSpan: minRowSpans[state.tileId] ?? 1, + minColSpans, + minRowSpans, maxRowSpan: GRID_MAX_ROW_SPAN, }); - if ( - clamped.colSpan === state.currentSpan.colSpan - && clamped.rowSpan === state.currentSpan.rowSpan - ) { - return; + + const sameLayout = Object.keys(nextPlacementsById).length === Object.keys(state.currentPlacementsById).length + && Object.entries(nextPlacementsById).every(([tileId, nextPlacement]) => { + const currentPlacement = state.currentPlacementsById[tileId]; + return currentPlacement + && currentPlacement.column === nextPlacement.column + && currentPlacement.row === nextPlacement.row + && currentPlacement.colSpan === nextPlacement.colSpan + && currentPlacement.rowSpan === nextPlacement.rowSpan; + }); + if (sameLayout) return; + + const nextDraftSpansById: Record = {}; + const nextDraftPlacementsById: Record = {}; + for (const [tileId, nextPlacement] of Object.entries(nextPlacementsById)) { + nextDraftSpansById[tileId] = { + colSpan: nextPlacement.colSpan, + rowSpan: nextPlacement.rowSpan, + }; + nextDraftPlacementsById[tileId] = { + column: nextPlacement.column, + row: nextPlacement.row, + }; } resizeStateRef.current = { ...state, - currentSpan: clamped, + currentPlacementsById: nextPlacementsById, }; - setDraftSpansById((prev) => { - const current = prev[state.tileId]; - if (current?.colSpan === clamped.colSpan && current?.rowSpan === clamped.rowSpan) { - return prev; - } - return { - ...prev, - [state.tileId]: clamped, - }; - }); + setDraftSpansById(nextDraftSpansById); + setDraftPlacementsById(nextDraftPlacementsById); }; const handlePointerUp = () => { const state = resizeStateRef.current; - if (state && ( - state.currentSpan.colSpan !== state.startSpan.colSpan - || state.currentSpan.rowSpan !== state.startSpan.rowSpan - )) { - saveLayout((prev) => ({ - ...prev, - [`${state.tileId}:col`]: state.currentSpan.colSpan, - [`${state.tileId}:row`]: state.currentSpan.rowSpan, - })); + if (state) { + const changedTileIds = Object.keys(state.currentPlacementsById).filter((tileId) => { + const startPlacement = state.startPlacementsById[tileId]; + const currentPlacement = state.currentPlacementsById[tileId]; + return !startPlacement + || startPlacement.column !== currentPlacement.column + || startPlacement.row !== currentPlacement.row + || startPlacement.colSpan !== currentPlacement.colSpan + || startPlacement.rowSpan !== currentPlacement.rowSpan; + }); + if (changedTileIds.length > 0) { + saveLayout((prev) => { + const next = { ...prev }; + for (const tileId of changedTileIds) { + const currentPlacement = state.currentPlacementsById[tileId]; + next[`${tileId}:colStart`] = currentPlacement.column; + next[`${tileId}:rowStart`] = currentPlacement.row; + next[`${tileId}:colSpan`] = currentPlacement.colSpan; + next[`${tileId}:rowSpan`] = currentPlacement.rowSpan; + next[`${tileId}:col`] = currentPlacement.colSpan; + next[`${tileId}:row`] = currentPlacement.rowSpan; + } + return next; + }); + } } stopResize(); }; @@ -320,22 +373,30 @@ export function PackedSessionGrid({ event.stopPropagation(); event.currentTarget.setPointerCapture?.(event.pointerId); document.body.style.cursor = `${direction}-resize`; + const startPlacementsById: Record = {}; + for (const placement of packed.placements) { + startPlacementsById[placement.id] = { ...placement }; + } resizeStateRef.current = { tileId, direction, startX: event.clientX, startY: event.clientY, - startSpan: spansById[tileId] ?? { colSpan: 1, rowSpan: 1 }, - currentSpan: spansById[tileId] ?? { colSpan: 1, rowSpan: 1 }, + startPlacementsById, + currentPlacementsById: startPlacementsById, pointerId: event.pointerId, pointerTarget: event.currentTarget, }; setResizingTileId(tileId); document.body.style.userSelect = "none"; - }, [spansById]); + }, [packed.placements]); return ( -
    +
    onViewportMouseLeave?.()} + >
    tile.onSelect?.()} + onPointerEnter={() => { + if (resizeStateRef.current) return; + tile.onHover?.(); + }} >
    ({ lastContextLossHandler: null as (() => void) | null, ptyDataListeners: new Set<(event: { ptyId: string; sessionId?: string; data: string }) => void>(), ptyExitListeners: new Set<(event: { ptyId: string; sessionId?: string; exitCode: number | null }) => void>(), + projectRoot: "/project/a", + projectRevision: 0, theme: "dark" as const, terminalPreferences: { fontFamily: "monospace", @@ -51,9 +53,15 @@ vi.mock("../../state/appStore", () => ({ lineHeight: number; scrollback: number; }; + project: { rootPath: string; name: string } | null; + projectRevision: number; }) => unknown) => selector({ theme: mockState.theme, terminalPreferences: mockState.terminalPreferences, + project: mockState.projectRoot + ? { rootPath: mockState.projectRoot, name: "Project" } + : null, + projectRevision: mockState.projectRevision, })), DEFAULT_TERMINAL_FONT_FAMILY: MOCK_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_PREFERENCES: { @@ -133,7 +141,11 @@ vi.mock("@xterm/addon-webgl", () => ({ vi.mock("@xterm/xterm/css/xterm.css", () => ({})); -import { TerminalView, getTerminalRuntimeSnapshot } from "./TerminalView"; +import { + TerminalView, + disposeTerminalRuntimesForProjectChange, + getTerminalRuntimeSnapshot, +} from "./TerminalView"; function installWindowAde() { (window as any).ade = { @@ -264,6 +276,10 @@ describe("TerminalView", () => { vi.clearAllMocks(); cleanup(); installWindowAde(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "visible", + }); resizeObservers.length = 0; mockState.terminalInstances.length = 0; mockState.nextFitDims = { cols: 120, rows: 40 }; @@ -271,6 +287,8 @@ describe("TerminalView", () => { mockState.lastContextLossHandler = null; mockState.ptyDataListeners.clear(); mockState.ptyExitListeners.clear(); + mockState.projectRoot = "/project/a"; + mockState.projectRevision = 0; mockState.theme = "dark"; mockState.terminalPreferences = { fontFamily: MOCK_TERMINAL_FONT_FAMILY, @@ -436,6 +454,65 @@ describe("TerminalView", () => { expect(terminal?.dispose).not.toHaveBeenCalled(); }); + it("drops parked runtimes after switching away and back across projects before remounting", async () => { + const view = render(); + await flushAllTimers(); + + const readTranscriptTailMock = window.ade.sessions.readTranscriptTail as unknown as { mock: { calls: unknown[][] } }; + const firstTerminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + expect(firstTerminal).toBeTruthy(); + expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); + + view.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); + + mockState.projectRoot = "/project/b"; + mockState.projectRevision += 1; + mockState.projectRoot = "/project/a"; + mockState.projectRevision += 1; + + render(); + await flushAllTimers(); + + const secondTerminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + expect(mockState.terminalInstances).toHaveLength(2); + expect(secondTerminal).not.toBe(firstTerminal); + expect(firstTerminal?.dispose).toHaveBeenCalledTimes(1); + expect(readTranscriptTailMock.mock.calls).toHaveLength(2); + expect(getTerminalRuntimeSnapshot("session-switch")).not.toBeNull(); + }); + + it("disposes parked runtimes when the project changes without a mounted terminal view", async () => { + const view = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + dispose: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + expect(getTerminalRuntimeSnapshot("session-background")).not.toBeNull(); + + view.unmount(); + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000); + }); + expect(getTerminalRuntimeSnapshot("session-background")).not.toBeNull(); + + mockState.projectRoot = "/project/b"; + mockState.projectRevision += 1; + disposeTerminalRuntimesForProjectChange(mockState.projectRoot, mockState.projectRevision); + + expect(terminal?.dispose).toHaveBeenCalledTimes(1); + expect(getTerminalRuntimeSnapshot("session-background")).toBeNull(); + }); + it("writes PTY output into the parked runtime so the terminal state stays current", async () => { const firstView = render(); await flushAllTimers(); @@ -451,7 +528,9 @@ describe("TerminalView", () => { for (const listener of mockState.ptyDataListeners) { listener({ ptyId: "pty-buffered", sessionId: "session-buffered", data: "hello from background\n" }); } - await flushAnimationFrame(); + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); // xterm.write is safe on a parked runtime (host is detached but the // instance still owns a valid internal buffer). Writing through while // parked keeps the terminal state in sync so switching back shows the @@ -465,4 +544,35 @@ describe("TerminalView", () => { // via the parked-runtime path, so no further synchronous flush is needed. expect(terminal?.write).not.toHaveBeenCalledWith("hello from background\n"); }); + + it("uses a timer flush for parked runtimes while the document is hidden", async () => { + const rafSpy = vi.spyOn(globalThis, "requestAnimationFrame"); + const firstView = render(); + await flushAllTimers(); + + const terminal = mockState.terminalInstances.at(-1) as { + write: ReturnType; + } | undefined; + expect(terminal).toBeTruthy(); + + firstView.unmount(); + terminal?.write.mockClear(); + rafSpy.mockClear(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + value: "hidden", + }); + + for (const listener of mockState.ptyDataListeners) { + listener({ ptyId: "pty-hidden", sessionId: "session-hidden", data: "buffered while hidden\n" }); + } + + expect(rafSpy).not.toHaveBeenCalled(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(16); + }); + + expect(terminal?.write).toHaveBeenCalledWith("buffered while hidden\n"); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx index 2d1ae1786..dd7e89e70 100644 --- a/apps/desktop/src/renderer/components/terminals/TerminalView.tsx +++ b/apps/desktop/src/renderer/components/terminals/TerminalView.tsx @@ -36,6 +36,8 @@ type CachedRuntime = { key: string; ptyId: string; sessionId: string; + projectRoot: string | null; + projectRevision: number; term: Terminal; fit: FitAddon; host: HTMLDivElement; @@ -62,6 +64,7 @@ type CachedRuntime = { frameWriteChunks: string[]; frameWriteBytes: number; flushRafId: number | null; + flushTimer: ReturnType | null; disposeTimer: ReturnType | null; lastFitSafetyAt: number; ptyDataUnsub: (() => void) | null; @@ -283,6 +286,28 @@ function parkRuntime(runtime: CachedRuntime) { } } +function disposeStaleRuntimes(activeProjectRoot: string | null, activeProjectRevision: number) { + for (const runtime of runtimeCache.values()) { + if (activeProjectRoot == null) { + if (runtime.projectRoot != null) { + teardownRuntime(runtime); + } + continue; + } + + if (runtime.projectRoot !== activeProjectRoot || runtime.projectRevision !== activeProjectRevision) { + teardownRuntime(runtime); + } + } +} + +export function disposeTerminalRuntimesForProjectChange( + activeProjectRoot: string | null, + activeProjectRevision: number, +): void { + disposeStaleRuntimes(activeProjectRoot, activeProjectRevision); +} + function setRuntimeInteractionState(runtime: CachedRuntime, active: boolean) { runtime.active = active; runtime.inputEnabled = active; @@ -312,6 +337,7 @@ function teardownRuntime(runtime: CachedRuntime) { clearDisposeTimer(runtime); if (runtime.fitRafId != null) cancelAnimationFrame(runtime.fitRafId); if (runtime.flushRafId != null) cancelAnimationFrame(runtime.flushRafId); + if (runtime.flushTimer) clearTimeout(runtime.flushTimer); if (runtime.settleTimer1) clearTimeout(runtime.settleTimer1); if (runtime.settleTimer2) clearTimeout(runtime.settleTimer2); if (runtime.hydrateTimer) clearTimeout(runtime.hydrateTimer); @@ -488,6 +514,22 @@ function flushFrameWriteChunksSync(runtime: CachedRuntime) { } } +function clearFrameWriteSchedule(runtime: CachedRuntime) { + if (runtime.flushRafId != null) { + cancelAnimationFrame(runtime.flushRafId); + runtime.flushRafId = null; + } + if (runtime.flushTimer) { + clearTimeout(runtime.flushTimer); + runtime.flushTimer = null; + } +} + +function flushPendingFrameWrites(runtime: CachedRuntime) { + clearFrameWriteSchedule(runtime); + flushFrameWriteChunksSync(runtime); +} + function enqueueFrameWrite(runtime: CachedRuntime, chunk: string) { if (!chunk) return; runtime.frameWriteChunks.push(chunk); @@ -501,15 +543,21 @@ function enqueueFrameWrite(runtime: CachedRuntime, chunk: string) { } function scheduleFrameWriteFlush(runtime: CachedRuntime) { - if (runtime.flushRafId != null) return; - runtime.flushRafId = requestAnimationFrame(() => { + if (runtime.flushRafId != null || runtime.flushTimer) return; + const flush = () => { runtime.flushRafId = null; + runtime.flushTimer = null; // Write to xterm even while parked: xterm.write only updates the internal // buffer, so it is safe to call when the host is detached. Writing through // keeps the terminal state current so switching back shows the latest // output instead of a stale snapshot (issue #157). flushFrameWriteChunksSync(runtime); - }); + }; + if (runtime.refs === 0 || !runtime.visible || document.visibilityState !== "visible") { + runtime.flushTimer = setTimeout(flush, 16); + return; + } + runtime.flushRafId = requestAnimationFrame(flush); } function flushHydrationData(runtime: CachedRuntime, tail: string) { @@ -655,6 +703,8 @@ async function initRendererChain(runtime: CachedRuntime) { function createRuntime(args: { ptyId: string; sessionId: string; + projectRoot: string | null; + projectRevision: number; theme: XtermTheme; preferences: TerminalRenderPreferences; }): CachedRuntime { @@ -681,6 +731,8 @@ function createRuntime(args: { key: args.sessionId, ptyId: args.ptyId, sessionId: args.sessionId, + projectRoot: args.projectRoot, + projectRevision: args.projectRevision, term, fit, host, @@ -707,6 +759,7 @@ function createRuntime(args: { frameWriteChunks: [], frameWriteBytes: 0, flushRafId: null, + flushTimer: null, disposeTimer: null, lastFitSafetyAt: 0, ptyDataUnsub: null, @@ -855,12 +908,18 @@ function createRuntime(args: { function ensureRuntime(args: { ptyId: string; sessionId: string; + projectRoot: string | null; + projectRevision: number; theme: XtermTheme; preferences: TerminalRenderPreferences; }): CachedRuntime { const existing = runtimeCache.get(args.sessionId); if (existing && !existing.disposed) { - if (existing.ptyId === args.ptyId) { + if ( + existing.ptyId === args.ptyId + && existing.projectRoot === args.projectRoot + && existing.projectRevision === args.projectRevision + ) { clearDisposeTimer(existing); applyRuntimeVisualOptions(existing, { theme: args.theme, @@ -905,6 +964,8 @@ export function TerminalView({ }) { const appTheme = useAppStore((s) => s.theme); const terminalPreferences = useAppStore((s) => s.terminalPreferences); + const projectRoot = useAppStore((s) => s.project?.rootPath ?? null); + const projectRevision = useAppStore((s) => s.projectRevision); const wrapperRef = useRef(null); const containerRef = useRef(null); const runtimeRef = useRef(null); @@ -921,6 +982,10 @@ export function TerminalView({ const mountConfigRef = useRef(currentMountConfig); mountConfigRef.current = currentMountConfig; + useEffect(() => { + disposeTerminalRuntimesForProjectChange(projectRoot, projectRevision); + }, [projectRoot, projectRevision]); + useEffect(() => { const el = containerRef.current; if (!el) return; @@ -929,6 +994,8 @@ export function TerminalView({ const runtime = ensureRuntime({ ptyId, sessionId, + projectRoot, + projectRevision, theme: mountConfig.theme, preferences: mountConfig.preferences, }); @@ -950,11 +1017,14 @@ export function TerminalView({ // Drain any buffered output synchronously on remount so the user sees the // latest terminal state immediately when they switch back, even if a // previously-scheduled flush RAF got throttled while the host was parked. - if (runtime.flushRafId != null) { - cancelAnimationFrame(runtime.flushRafId); - runtime.flushRafId = null; + flushPendingFrameWrites(runtime); + if (runtime.term.rows > 0) { + try { + runtime.term.refresh(0, Math.max(0, runtime.term.rows - 1)); + } catch { + // ignore refresh failures after disposal + } } - flushFrameWriteChunksSync(runtime); const schedule = (forceResize = false) => scheduleFit(runtime, forceResize); @@ -1080,6 +1150,7 @@ export function TerminalView({ el.removeEventListener("wheel", onWheel); if (runtime.host.parentElement === el) { + flushPendingFrameWrites(runtime); try { runtime.term.blur(); } catch { @@ -1100,7 +1171,7 @@ export function TerminalView({ scheduleRuntimeDispose(runtime, EXITED_RUNTIME_KEEPALIVE_MS); } }; - }, [ptyId, sessionId]); + }, [projectRevision, projectRoot, ptyId, sessionId]); useEffect(() => { const runtime = runtimeRef.current ?? runtimeCache.get(sessionId); @@ -1122,6 +1193,8 @@ export function TerminalView({ } if (isVisible) { + clearTextureAtlas(runtime); + flushPendingFrameWrites(runtime); requestAnimationFrame(() => { scheduleFit(runtime, false); }); @@ -1139,7 +1212,7 @@ export function TerminalView({ // ignore } } - }, [isActive, isVisible, sessionId]); + }, [isActive, isVisible, projectRoot, sessionId]); useEffect(() => { const runtime = runtimeRef.current ?? runtimeCache.get(sessionId); @@ -1153,7 +1226,7 @@ export function TerminalView({ scheduleFit(runtime, true); }); return () => cancelAnimationFrame(id); - }, [resolvedPreferences, sessionId, termTheme]); + }, [projectRoot, resolvedPreferences, sessionId, termTheme]); // When this terminal becomes the active tab, force fit + focus + scroll useEffect(() => { @@ -1192,7 +1265,7 @@ export function TerminalView({ cancelAnimationFrame(raf); clearTimeout(timer); }; - }, [isActive, isVisible, sessionId]); + }, [isActive, isVisible, projectRoot, sessionId]); return (
    ({ - TerminalView: () =>
    , + TerminalView: ({ sessionId, isActive }: { sessionId: string; isActive: boolean }) => ( +
    + ), })); vi.mock("../chat/AgentChatPane", () => ({ @@ -18,9 +21,43 @@ vi.mock("./WorkStartSurface", () => ({ })); vi.mock("./PackedSessionGrid", () => ({ - PackedSessionGrid: () =>
    , + PackedSessionGrid: ({ + tiles, + onViewportMouseLeave, + }: { + tiles: Array<{ id: string; children: ReactNode; onHover?: () => void; onSelect?: () => void }>; + onViewportMouseLeave?: () => void; + }) => { + latestPackedSessionGridProps = { tiles, onViewportMouseLeave }; + return ( +
    + {tiles.map((tile) => ( +
    + {tile.children} +
    + ))} +
    + ); + }, })); +let latestPackedSessionGridProps: { + tiles: Array<{ id: string; children: ReactNode; onHover?: () => void; onSelect?: () => void }>; + onViewportMouseLeave?: () => void; +} | null = null; + +beforeEach(() => { + latestPackedSessionGridProps = null; +}); + vi.mock("./ToolLogos", () => ({ ToolLogo: () => , })); @@ -61,6 +98,18 @@ function makeSession(): TerminalSessionSummary { }; } +function makeRunningSession(id: string, ptyId: string): TerminalSessionSummary { + return { + ...makeSession(), + id, + ptyId, + status: "running", + endedAt: null, + exitCode: null, + runtimeState: "running", + }; +} + describe("WorkViewArea", () => { it("shows the draft surface when no tab is active, even if tabs are open", () => { const session = makeSession(); @@ -107,4 +156,111 @@ describe("WorkViewArea", () => { expect(screen.getByTestId("work-start-surface")).toBeTruthy(); expect(screen.queryByText("Session ended")).toBeNull(); }); + + it("keeps every running terminal tile mounted in grid mode", () => { + const first = makeRunningSession("session-1", "pty-1"); + const second = makeRunningSession("session-2", "pty-2"); + + render( + {}} + onSelectItem={() => {}} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + expect(screen.getAllByTestId("terminal-view")).toHaveLength(2); + }); + + it("focuses the hovered grid tile without persisting selection", async () => { + const first = makeRunningSession("session-1", "pty-1"); + const second = makeRunningSession("session-2", "pty-2"); + const onSelectItem = vi.fn(); + + const { container } = render( + {}} + onSelectItem={onSelectItem} + onCloseItem={() => {}} + onOpenChatSession={() => {}} + onLaunchPtySession={async () => ({})} + onShowDraftKind={() => {}} + onToggleTabGroupCollapsed={() => {}} + closingPtyIds={new Set()} + />, + ); + + expect(latestPackedSessionGridProps).not.toBeNull(); + await act(async () => { + latestPackedSessionGridProps?.tiles[1].onHover?.(); + }); + + expect(onSelectItem).not.toHaveBeenCalled(); + expect(container.querySelector('[data-session-id="session-1"]')?.getAttribute("data-active")).toBe("false"); + expect(container.querySelector('[data-session-id="session-2"]')?.getAttribute("data-active")).toBe("true"); + + await act(async () => { + latestPackedSessionGridProps?.onViewportMouseLeave?.(); + }); + + expect(container.querySelector('[data-session-id="session-1"]')?.getAttribute("data-active")).toBe("true"); + expect(container.querySelector('[data-session-id="session-2"]')?.getAttribute("data-active")).toBe("false"); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx index a6f1eb1bb..9968e2496 100644 --- a/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx +++ b/apps/desktop/src/renderer/components/terminals/WorkViewArea.tsx @@ -34,7 +34,7 @@ function isRunningPtySession( function SessionSurface({ session, isActive, - suspended = false, + shouldAutofocus = false, layoutVariant = "standard", terminalVisible = isActive, onOpenChatSession, @@ -42,43 +42,12 @@ function SessionSurface({ }: { session: TerminalSessionSummary; isActive: boolean; - suspended?: boolean; + shouldAutofocus?: boolean; layoutVariant?: "standard" | "grid-tile"; terminalVisible?: boolean; onOpenChatSession: (session: AgentChatSession) => void | Promise; onResume?: (session: TerminalSessionSummary) => void; }) { - if (suspended) { - const secondary = secondarySessionLabel(session); - const status = sessionStatusDot(session); - const preview = session.summary?.trim() - || session.lastOutputPreview?.trim() - || (isChatToolType(session.toolType) ? "Select this tile to resume the live chat view." : "Select this tile to resume the live terminal view."); - return ( -
    -
    -
    -
    - - {primarySessionLabel(session)} -
    -
    - {session.laneName} - {secondary ? ` • ${truncateSessionLabel(secondary, 40)}` : ""} -
    -
    - -
    -
    - {preview} -
    -
    - ); - } - const isChat = isChatToolType(session.toolType); if (isChat) { return ( @@ -89,6 +58,8 @@ function SessionSurface({ hideSessionTabs onSessionCreated={onOpenChatSession} layoutVariant={layoutVariant} + isTileActive={isActive} + shouldAutofocusComposer={shouldAutofocus} /> ); } @@ -325,13 +296,21 @@ export function WorkViewArea({ .filter((session): session is TerminalSessionSummary => session != null), [sessionsById, tabVisibleSessionIds, visibleSessions], ); + const [hoveredGridSessionId, setHoveredGridSessionId] = useState(null); const showingDraft = activeItemId == null; const activeSession = showingDraft ? null : sessionsById.get(activeItemId) ?? tabVisibleSessions[0] ?? visibleSessions[0] ?? null; const activeRunningTerminalSession = isRunningPtySession(activeSession) ? activeSession : null; + const handleContextMenu = useCallback((session: TerminalSessionSummary, e: React.MouseEvent): void => { + if (onContextMenu) { + e.preventDefault(); + onContextMenu(session, e); + } + }, [onContextMenu]); const packedGridTiles = useMemo(() => visibleSessions.map((session) => { - const isActive = activeSession?.id === session.id; + const isSelected = activeItemId === session.id; + const isActive = (hoveredGridSessionId ?? activeItemId) === session.id; const dot = sessionStatusDot(session); const isBusy = session.ptyId ? closingPtyIds.has(session.ptyId) : false; const primary = primarySessionLabel(session); @@ -348,6 +327,7 @@ export function WorkViewArea({ minHeight: isChatToolType(session.toolType) ? CHAT_TILE_MIN_HEIGHT : TERMINAL_TILE_MIN_HEIGHT, selected: isActive, onSelect: () => onSelectItem(session.id), + onHover: () => setHoveredGridSessionId(session.id), className: isActive ? "ade-work-glass-tile-active" : "ade-work-glass-tile", @@ -407,7 +387,7 @@ export function WorkViewArea({ ), }; - }), [activeSession?.id, closingPtyIds, handleContextMenu, onCloseItem, onOpenChatSession, onSelectItem, visibleSessions]); + }), [activeItemId, closingPtyIds, handleContextMenu, hoveredGridSessionId, onCloseItem, onOpenChatSession, onResumeSession, onSelectItem, visibleSessions]); const resolvedTabGroups = tabGroups ?? []; const hasGroupedTabs = resolvedTabGroups.length > 0; const toggleTabGroupCollapsed = onToggleTabGroupCollapsed ?? (() => {}); - function handleContextMenu(session: TerminalSessionSummary, e: React.MouseEvent): void { - if (onContextMenu) { - e.preventDefault(); - onContextMenu(session, e); + useEffect(() => { + if (viewMode !== "grid") { + setHoveredGridSessionId(null); } - } + }, [viewMode]); + + useEffect(() => { + if (hoveredGridSessionId && !sessionsById.has(hoveredGridSessionId)) { + setHoveredGridSessionId(null); + } + }, [hoveredGridSessionId, sessionsById]); if (viewMode === "grid") { return ( @@ -457,6 +442,7 @@ export function WorkViewArea({ setHoveredGridSessionId(null)} /> )}
    diff --git a/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.test.ts b/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.test.ts index 02a879ea5..ac353df63 100644 --- a/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.test.ts +++ b/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.test.ts @@ -5,6 +5,7 @@ import { computeMinimumRowSpan, packGridItems, reconcilePackedGridLayout, + resizePackedGridItem, } from "./packedSessionGridMath"; describe("packedSessionGridMath", () => { @@ -59,6 +60,28 @@ describe("packedSessionGridMath", () => { expect(packed.totalRows).toBe(2); }); + it("honors explicit placements before packing fallback tiles", () => { + const packed = packGridItems([ + { id: "fixed", minRowSpan: 1, span: { colSpan: 2, rowSpan: 1 }, placement: { column: 2, row: 1 } }, + { id: "fallback", minRowSpan: 1, span: { colSpan: 1, rowSpan: 1 } }, + ], 4); + + expect(packed.placements[0]).toEqual({ + id: "fixed", + column: 2, + row: 1, + colSpan: 2, + rowSpan: 1, + }); + expect(packed.placements[1]).toEqual({ + id: "fallback", + column: 1, + row: 1, + colSpan: 1, + rowSpan: 1, + }); + }); + it("preserves absent-tile layout entries and seeds missing tiles with defaults", () => { const reconciled = reconcilePackedGridLayout({ layout: { @@ -72,15 +95,87 @@ describe("packedSessionGridMath", () => { keep: { colSpan: 1, rowSpan: 2 }, new: { colSpan: 1, rowSpan: 3 }, }, + columnCount: 4, }); expect(reconciled).toEqual({ "absent:col": 4, "absent:row": 4, "keep:col": 2, + "keep:colSpan": 2, "keep:row": 3, + "keep:rowSpan": 3, + "keep:colStart": 1, + "keep:rowStart": 1, "new:col": 1, + "new:colSpan": 1, "new:row": 3, + "new:rowSpan": 3, + "new:colStart": 3, + "new:rowStart": 1, + }); + }); + + it("preserves active placement keys when they already exist", () => { + const reconciled = reconcilePackedGridLayout({ + layout: { + "keep:colStart": 5, + "keep:rowStart": 6, + "keep:colSpan": 2, + "keep:rowSpan": 3, + }, + tileIds: ["keep"], + defaultSpansById: { + keep: { colSpan: 1, rowSpan: 2 }, + }, + columnCount: 12, }); + + expect(reconciled).toEqual({ + "keep:col": 2, + "keep:colSpan": 2, + "keep:colStart": 5, + "keep:row": 3, + "keep:rowSpan": 3, + "keep:rowStart": 6, + }); + }); + + it("moves the neighboring pane when expanding east", () => { + const next = resizePackedGridItem({ + placementsById: { + a: { id: "a", column: 1, row: 1, colSpan: 12, rowSpan: 2 }, + b: { id: "b", column: 13, row: 1, colSpan: 12, rowSpan: 2 }, + }, + tileId: "a", + direction: "e", + deltaCols: 2, + deltaRows: 0, + columnCount: 24, + minColSpans: { a: 4, b: 4 }, + minRowSpans: { a: 1, b: 1 }, + }); + + expect(next.a).toEqual({ id: "a", column: 1, row: 1, colSpan: 14, rowSpan: 2 }); + expect(next.b).toEqual({ id: "b", column: 15, row: 1, colSpan: 10, rowSpan: 2 }); + }); + + it("keeps the east edge anchored when expanding west", () => { + const next = resizePackedGridItem({ + placementsById: { + left: { id: "left", column: 1, row: 1, colSpan: 12, rowSpan: 2 }, + right: { id: "right", column: 13, row: 1, colSpan: 12, rowSpan: 2 }, + }, + tileId: "right", + direction: "w", + deltaCols: -2, + deltaRows: 0, + columnCount: 24, + minColSpans: { left: 4, right: 4 }, + minRowSpans: { left: 1, right: 1 }, + }); + + expect(next.left).toEqual({ id: "left", column: 1, row: 1, colSpan: 10, rowSpan: 2 }); + expect(next.right).toEqual({ id: "right", column: 11, row: 1, colSpan: 14, rowSpan: 2 }); }); }); diff --git a/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts b/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts index 3cba614d3..cf3e061b9 100644 --- a/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts +++ b/apps/desktop/src/renderer/components/terminals/packedSessionGridMath.ts @@ -20,10 +20,381 @@ export type PackedGridItem = { id: string; minRowSpan: number; span: PackedGridSpan; + placement?: { + column: number; + row: number; + }; }; +export type PackedGridPlacementMap = Record; + +function layoutKey(id: string, suffix: string): string { + return `${id}:${suffix}`; +} + function spanKey(id: string, axis: "col" | "row"): string { - return `${id}:${axis}`; + return layoutKey(id, axis); +} + +function placementKey(id: string, axis: "colStart" | "rowStart" | "colSpan" | "rowSpan"): string { + return layoutKey(id, axis); +} + +function readLayoutNumber(layout: DockLayout, key: string): number | null { + const raw = layout[key]; + const value = Number(raw); + return Number.isFinite(value) ? value : null; +} + +function placementRight(rect: PackedGridPlacement): number { + return rect.column + rect.colSpan; +} + +function placementBottom(rect: PackedGridPlacement): number { + return rect.row + rect.rowSpan; +} + +function overlapsRows(left: PackedGridPlacement, right: PackedGridPlacement): boolean { + return left.row < placementBottom(right) && right.row < placementBottom(left); +} + +function overlapsColumns(left: PackedGridPlacement, right: PackedGridPlacement): boolean { + return left.column < placementRight(right) && right.column < placementRight(left); +} + +function clonePlacementMap(placementsById: PackedGridPlacementMap): PackedGridPlacementMap { + const next: PackedGridPlacementMap = {}; + for (const [tileId, placement] of Object.entries(placementsById)) { + next[tileId] = { ...placement }; + } + return next; +} + +function boundaryExpansionLimit(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + axis: "horizontal" | "vertical"; + edge: "start" | "end"; + columnCount: number; + minColSpans: Record; + minRowSpans: Record; + maxRowSpan: number; +}): number { + const active = args.placementsById[args.tileId]; + if (!active) return 0; + + if (args.axis === "horizontal") { + if (args.edge === "end") { + let allowed = args.columnCount + 1 - placementRight(active); + for (const [otherId, other] of Object.entries(args.placementsById)) { + if (otherId === args.tileId || !overlapsRows(active, other) || other.column < placementRight(active)) continue; + const gap = other.column - placementRight(active); + if (gap === 0) { + allowed = Math.min(allowed, Math.max(0, other.colSpan - (args.minColSpans[otherId] ?? 1))); + } else { + allowed = Math.min(allowed, gap); + } + } + return Math.max(0, allowed); + } + + let allowed = active.column - 1; + for (const [otherId, other] of Object.entries(args.placementsById)) { + if (otherId === args.tileId || !overlapsRows(active, other) || placementRight(other) > active.column) continue; + const gap = active.column - placementRight(other); + if (gap === 0) { + allowed = Math.min(allowed, Math.max(0, other.colSpan - (args.minColSpans[otherId] ?? 1))); + } else { + allowed = Math.min(allowed, gap); + } + } + return Math.max(0, allowed); + } + + if (args.edge === "end") { + let allowed = Math.max(0, args.maxRowSpan - active.rowSpan); + for (const [otherId, other] of Object.entries(args.placementsById)) { + if (otherId === args.tileId || !overlapsColumns(active, other) || other.row < placementBottom(active)) continue; + const gap = other.row - placementBottom(active); + if (gap === 0) { + allowed = Math.min(allowed, Math.max(0, other.rowSpan - (args.minRowSpans[otherId] ?? 1))); + } else { + allowed = Math.min(allowed, gap); + } + } + return Math.max(0, allowed); + } + + let allowed = Math.min(active.row - 1, Math.max(0, args.maxRowSpan - active.rowSpan)); + for (const [otherId, other] of Object.entries(args.placementsById)) { + if (otherId === args.tileId || !overlapsColumns(active, other) || placementBottom(other) > active.row) continue; + const gap = active.row - placementBottom(other); + if (gap === 0) { + allowed = Math.min(allowed, Math.max(0, other.rowSpan - (args.minRowSpans[otherId] ?? 1))); + } else { + allowed = Math.min(allowed, gap); + } + } + return Math.max(0, allowed); +} + +function moveEastEdge(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + delta: number; + columnCount: number; + minColSpans: Record; +}): void { + if (args.delta === 0) return; + const active = args.placementsById[args.tileId]; + if (!active) return; + const oldRight = placementRight(active); + const contiguousNeighbors = Object.values(args.placementsById).filter( + (other) => other.id !== args.tileId && other.column === oldRight && overlapsRows(active, other), + ); + + if (args.delta > 0) { + const allowed = Math.min( + args.delta, + boundaryExpansionLimit({ + placementsById: args.placementsById, + tileId: args.tileId, + axis: "horizontal", + edge: "end", + columnCount: args.columnCount, + minColSpans: args.minColSpans, + minRowSpans: {}, + maxRowSpan: GRID_MAX_ROW_SPAN, + }), + ); + if (allowed <= 0) return; + active.colSpan += allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.column += allowed; + neighbor.colSpan -= allowed; + } + return; + } + + const minActiveColSpan = args.minColSpans[args.tileId] ?? 1; + const allowed = Math.min(-args.delta, Math.max(0, active.colSpan - minActiveColSpan)); + if (allowed <= 0) return; + active.colSpan -= allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.column -= allowed; + neighbor.colSpan += allowed; + } +} + +function moveWestEdge(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + delta: number; + columnCount: number; + minColSpans: Record; +}): void { + if (args.delta === 0) return; + const active = args.placementsById[args.tileId]; + if (!active) return; + const oldLeft = active.column; + const contiguousNeighbors = Object.values(args.placementsById).filter( + (other) => other.id !== args.tileId && placementRight(other) === oldLeft && overlapsRows(active, other), + ); + + if (args.delta > 0) { + const minActiveColSpan = args.minColSpans[args.tileId] ?? 1; + const allowed = Math.min(args.delta, Math.max(0, active.colSpan - minActiveColSpan)); + if (allowed <= 0) return; + active.column += allowed; + active.colSpan -= allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.colSpan += allowed; + } + return; + } + + const allowed = Math.min( + -args.delta, + boundaryExpansionLimit({ + placementsById: args.placementsById, + tileId: args.tileId, + axis: "horizontal", + edge: "start", + columnCount: args.columnCount, + minColSpans: args.minColSpans, + minRowSpans: {}, + maxRowSpan: GRID_MAX_ROW_SPAN, + }), + ); + if (allowed <= 0) return; + active.column -= allowed; + active.colSpan += allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.colSpan -= allowed; + } +} + +function moveSouthEdge(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + delta: number; + columnCount: number; + minRowSpans: Record; + maxRowSpan: number; +}): void { + if (args.delta === 0) return; + const active = args.placementsById[args.tileId]; + if (!active) return; + const oldBottom = placementBottom(active); + const contiguousNeighbors = Object.values(args.placementsById).filter( + (other) => other.id !== args.tileId && other.row === oldBottom && overlapsColumns(active, other), + ); + + if (args.delta > 0) { + const allowed = Math.min( + args.delta, + boundaryExpansionLimit({ + placementsById: args.placementsById, + tileId: args.tileId, + axis: "vertical", + edge: "end", + columnCount: args.columnCount, + minColSpans: {}, + minRowSpans: args.minRowSpans, + maxRowSpan: args.maxRowSpan, + }), + ); + if (allowed <= 0) return; + active.rowSpan += allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.row += allowed; + neighbor.rowSpan -= allowed; + } + return; + } + + const minActiveRowSpan = args.minRowSpans[args.tileId] ?? 1; + const allowed = Math.min(-args.delta, Math.max(0, active.rowSpan - minActiveRowSpan)); + if (allowed <= 0) return; + active.rowSpan -= allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.row -= allowed; + neighbor.rowSpan += allowed; + } +} + +function moveNorthEdge(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + delta: number; + columnCount: number; + minRowSpans: Record; + maxRowSpan: number; +}): void { + if (args.delta === 0) return; + const active = args.placementsById[args.tileId]; + if (!active) return; + const oldTop = active.row; + const contiguousNeighbors = Object.values(args.placementsById).filter( + (other) => other.id !== args.tileId && placementBottom(other) === oldTop && overlapsColumns(active, other), + ); + + if (args.delta > 0) { + const minActiveRowSpan = args.minRowSpans[args.tileId] ?? 1; + const allowed = Math.min(args.delta, Math.max(0, active.rowSpan - minActiveRowSpan)); + if (allowed <= 0) return; + active.row += allowed; + active.rowSpan -= allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.rowSpan += allowed; + } + return; + } + + const allowed = Math.min( + -args.delta, + boundaryExpansionLimit({ + placementsById: args.placementsById, + tileId: args.tileId, + axis: "vertical", + edge: "start", + columnCount: args.columnCount, + minColSpans: {}, + minRowSpans: args.minRowSpans, + maxRowSpan: args.maxRowSpan, + }), + ); + if (allowed <= 0) return; + active.row -= allowed; + active.rowSpan += allowed; + for (const neighbor of contiguousNeighbors) { + neighbor.rowSpan -= allowed; + } +} + +export function resizePackedGridItem(args: { + placementsById: PackedGridPlacementMap; + tileId: string; + direction: "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw"; + deltaCols: number; + deltaRows: number; + columnCount: number; + minColSpans: Record; + minRowSpans: Record; + maxRowSpan?: number; +}): PackedGridPlacementMap { + const next = clonePlacementMap(args.placementsById); + const maxRowSpan = args.maxRowSpan ?? GRID_MAX_ROW_SPAN; + + if (args.direction.includes("e")) { + moveEastEdge({ + placementsById: next, + tileId: args.tileId, + delta: args.deltaCols, + columnCount: args.columnCount, + minColSpans: args.minColSpans, + }); + } else if (args.direction.includes("w")) { + moveWestEdge({ + placementsById: next, + tileId: args.tileId, + delta: args.deltaCols, + columnCount: args.columnCount, + minColSpans: args.minColSpans, + }); + } + + if (args.direction.includes("s")) { + moveSouthEdge({ + placementsById: next, + tileId: args.tileId, + delta: args.deltaRows, + columnCount: args.columnCount, + minRowSpans: args.minRowSpans, + maxRowSpan, + }); + } else if (args.direction.includes("n")) { + moveNorthEdge({ + placementsById: next, + tileId: args.tileId, + delta: args.deltaRows, + columnCount: args.columnCount, + minRowSpans: args.minRowSpans, + maxRowSpan, + }); + } + + return next; +} + +export function readPackedGridPlacement( + layout: DockLayout, + tileId: string, +): { column: number; row: number } | null { + const column = readLayoutNumber(layout, placementKey(tileId, "colStart")); + const row = readLayoutNumber(layout, placementKey(tileId, "rowStart")); + if (column == null || row == null) return null; + return { column, row }; } export function computeMinimumRowSpan( @@ -88,11 +459,15 @@ export function readPackedGridSpan( tileId: string, defaults: PackedGridSpan, ): PackedGridSpan { - const rawColSpan = Number(layout[spanKey(tileId, "col")] ?? defaults.colSpan); - const rawRowSpan = Number(layout[spanKey(tileId, "row")] ?? defaults.rowSpan); + const rawColSpan = readLayoutNumber(layout, placementKey(tileId, "colSpan")) + ?? readLayoutNumber(layout, spanKey(tileId, "col")) + ?? defaults.colSpan; + const rawRowSpan = readLayoutNumber(layout, placementKey(tileId, "rowSpan")) + ?? readLayoutNumber(layout, spanKey(tileId, "row")) + ?? defaults.rowSpan; return { - colSpan: Number.isFinite(rawColSpan) ? rawColSpan : defaults.colSpan, - rowSpan: Number.isFinite(rawRowSpan) ? rawRowSpan : defaults.rowSpan, + colSpan: rawColSpan, + rowSpan: rawRowSpan, }; } @@ -120,27 +495,38 @@ export function reconcilePackedGridLayout(args: { layout: DockLayout; tileIds: string[]; defaultSpansById: Record; + columnCount: number; }): DockLayout { - const { layout, tileIds, defaultSpansById } = args; + const { layout, tileIds, defaultSpansById, columnCount } = args; const activeTileIds = new Set(tileIds); const next: DockLayout = {}; - // Preserve persisted spans for tiles not currently active for (const [key, value] of Object.entries(layout)) { - const match = key.match(/^(.+):(col|row)$/); + const match = key.match(/^(.+):(col|row|colStart|rowStart|colSpan|rowSpan)$/); if (match && !activeTileIds.has(match[1])) { next[key] = value; } } - // Reconcile active tiles with defaults - for (const tileId of tileIds) { - const defaults = defaultSpansById[tileId] ?? { colSpan: 1, rowSpan: 1 }; - const persistedColSpan = Number(layout[spanKey(tileId, "col")]); - const persistedRowSpan = Number(layout[spanKey(tileId, "row")]); - next[spanKey(tileId, "col")] = Number.isFinite(persistedColSpan) ? persistedColSpan : defaults.colSpan; - next[spanKey(tileId, "row")] = Number.isFinite(persistedRowSpan) ? persistedRowSpan : defaults.rowSpan; + const packed = packGridItems( + tileIds.map((tileId) => ({ + id: tileId, + minRowSpan: defaultSpansById[tileId]?.rowSpan ?? 1, + span: readPackedGridSpan(layout, tileId, defaultSpansById[tileId] ?? { colSpan: 1, rowSpan: 1 }), + placement: readPackedGridPlacement(layout, tileId) ?? undefined, + })), + columnCount, + ); + + for (const placement of packed.placements) { + next[spanKey(placement.id, "col")] = placement.colSpan; + next[spanKey(placement.id, "row")] = placement.rowSpan; + next[placementKey(placement.id, "colStart")] = placement.column; + next[placementKey(placement.id, "rowStart")] = placement.row; + next[placementKey(placement.id, "colSpan")] = placement.colSpan; + next[placementKey(placement.id, "rowSpan")] = placement.rowSpan; } + return next; } @@ -152,7 +538,7 @@ function canPlaceAt( rowSpan: number, columnCount: number, ): boolean { - if (column + colSpan - 1 > columnCount) return false; + if (column < 1 || row < 1 || column + colSpan - 1 > columnCount) return false; for (let rowOffset = 0; rowOffset < rowSpan; rowOffset += 1) { for (let colOffset = 0; colOffset < colSpan; colOffset += 1) { const key = `${row + rowOffset}:${column + colOffset}`; @@ -185,7 +571,38 @@ export function packGridItems(items: PackedGridItem[], columnCount: number): { const occupied = new Set(); let totalRows = 0; + const deferred: PackedGridItem[] = []; + for (const item of items) { + const colSpan = Math.max(1, Math.min(clampedColumnCount, item.span.colSpan)); + const rowSpan = Math.max(item.minRowSpan, item.span.rowSpan); + const placement = item.placement; + if ( + placement + && Number.isFinite(placement.column) + && Number.isFinite(placement.row) + && canPlaceAt(occupied, Math.floor(placement.column), Math.floor(placement.row), colSpan, rowSpan, clampedColumnCount) + ) { + const column = Math.floor(placement.column); + const row = Math.floor(placement.row); + occupy(occupied, column, row, colSpan, rowSpan); + placements.push({ + id: item.id, + column, + row, + colSpan, + rowSpan, + }); + totalRows = Math.max(totalRows, row + rowSpan - 1); + continue; + } + deferred.push({ + ...item, + span: { colSpan, rowSpan }, + }); + } + + for (const item of deferred) { const colSpan = Math.max(1, Math.min(clampedColumnCount, item.span.colSpan)); const rowSpan = Math.max(item.minRowSpan, item.span.rowSpan); let placed = false; diff --git a/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts b/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts index 6ebcc0eec..2cfa3d1c1 100644 --- a/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts +++ b/apps/desktop/src/renderer/lib/aiDiscoveryCache.ts @@ -28,8 +28,12 @@ function statusCacheKey(projectRoot: string | null | undefined): string { return normalizeProjectRoot(projectRoot); } -function modelsCacheKey(projectRoot: string | null | undefined, provider: AgentChatProvider): string { - return `${normalizeProjectRoot(projectRoot)}::${provider}`; +function modelsCacheKey( + projectRoot: string | null | undefined, + provider: AgentChatProvider, + activateRuntime: boolean, +): string { + return `${normalizeProjectRoot(projectRoot)}::${provider}::${activateRuntime ? "active" : "passive"}`; } export async function getAiStatusCached(args: { @@ -104,10 +108,12 @@ export async function getAiStatusCached(args: { export async function getAgentChatModelsCached(args: { projectRoot: string | null | undefined; provider: AgentChatProvider; + activateRuntime?: boolean; force?: boolean; ttlMs?: number; }): Promise { - const key = modelsCacheKey(args.projectRoot, args.provider); + const activateRuntime = args.activateRuntime === true; + const key = modelsCacheKey(args.projectRoot, args.provider, activateRuntime); const ttlMs = args.ttlMs ?? DEFAULT_MODELS_TTL_MS; const now = Date.now(); const existing = providerModelsCache.get(key); @@ -120,7 +126,10 @@ export async function getAgentChatModelsCached(args: { } let request: Promise | null = null; - request = window.ade.agentChat.models({ provider: args.provider }).then((models) => { + request = window.ade.agentChat.models({ + provider: args.provider, + ...(activateRuntime ? { activateRuntime: true } : {}), + }).then((models) => { const current = providerModelsCache.get(key); if (current?.inFlight === request) { providerModelsCache.set(key, { diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index c45226ccb..669d13834 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -346,6 +346,7 @@ type AppState = { selectedLaneId: string | null; runLaneId: string | null; focusedSessionId: string | null; + projectRevision: number; theme: ThemeId; terminalPreferences: TerminalPreferences; providerMode: ProviderMode; @@ -469,6 +470,7 @@ export const useAppStore = create((set, get) => ({ selectedLaneId: null, runLaneId: null, focusedSessionId: null, + projectRevision: 0, theme: initialUserPreferences.theme, terminalPreferences: initialUserPreferences.terminalPreferences, providerMode: "guest", @@ -480,7 +482,16 @@ export const useAppStore = create((set, get) => ({ workViewByProject: initialPersistedWorkViews.workViewByProject, laneWorkViewByScope: initialPersistedWorkViews.laneWorkViewByScope, - setProject: (project) => set({ project }), + setProject: (project) => + set((prev) => { + const previousProjectRoot = prev.project?.rootPath ?? null; + const nextProjectRoot = project?.rootPath ?? null; + return { + project, + projectRevision: + previousProjectRoot !== nextProjectRoot ? prev.projectRevision + 1 : prev.projectRevision, + }; + }), setProjectHydrated: (projectHydrated) => set({ projectHydrated }), setShowWelcome: (showWelcome) => set({ showWelcome }), clearProjectTransitionError: () => set({ projectTransitionError: null }), @@ -601,7 +612,8 @@ export const useAppStore = create((set, get) => ({ refreshProject: async () => { const project = await window.ade.app.getProject(); - set({ project, projectHydrated: true }); + get().setProject(project); + set({ projectHydrated: true }); }, refreshLanes: async (options) => { @@ -740,8 +752,8 @@ export const useAppStore = create((set, get) => ({ set({ projectTransition: null }); return null; } + get().setProject(project); set({ - project, projectHydrated: true, showWelcome: false, projectTransition: null, @@ -787,8 +799,8 @@ export const useAppStore = create((set, get) => ({ }); try { const project = await window.ade.project.switchToPath(rootPath); + get().setProject(project); set({ - project, projectHydrated: true, showWelcome: false, projectTransition: null, @@ -859,8 +871,8 @@ export const useAppStore = create((set, get) => ({ await window.ade.project.closeCurrent(); invalidateAiDiscoveryCache(closingProjectRoot); invalidateProjectConfigCache(closingProjectRoot); + get().setProject(null); set({ - project: null, projectHydrated: true, showWelcome: true, projectTransition: null, diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 9217a7bbc..85dac46f1 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -11,6 +11,8 @@ export const IPC = { appLogDebugEvent: "ade.app.logDebugEvent", projectOpenRepo: "ade.project.openRepo", projectChooseDirectory: "ade.project.chooseDirectory", + projectBrowseDirectories: "ade.project.browseDirectories", + projectGetDetail: "ade.project.getDetail", projectOpenAdeFolder: "ade.project.openAdeFolder", projectClearLocalData: "ade.project.clearLocalData", projectListRecent: "ade.project.listRecent", diff --git a/apps/desktop/src/shared/modelProfiles.test.ts b/apps/desktop/src/shared/modelProfiles.test.ts index 63aef9fbe..4fa22d8b3 100644 --- a/apps/desktop/src/shared/modelProfiles.test.ts +++ b/apps/desktop/src/shared/modelProfiles.test.ts @@ -118,9 +118,9 @@ describe("getModelsForProvider", () => { // --------------------------------------------------------------------------- describe("thinking levels", () => { - it("CLAUDE_THINKING_LEVELS has three levels", () => { - expect(CLAUDE_THINKING_LEVELS).toHaveLength(3); - expect(CLAUDE_THINKING_LEVELS.map((t) => t.value)).toEqual(["low", "medium", "high"]); + it("CLAUDE_THINKING_LEVELS exposes the Opus-capable effort ladder", () => { + expect(CLAUDE_THINKING_LEVELS).toHaveLength(4); + expect(CLAUDE_THINKING_LEVELS.map((t) => t.value)).toEqual(["low", "medium", "high", "max"]); }); it("CODEX_THINKING_LEVELS has four levels including xhigh", () => { diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts index 2ca80ff3a..28952be8d 100644 --- a/apps/desktop/src/shared/modelProfiles.ts +++ b/apps/desktop/src/shared/modelProfiles.ts @@ -83,6 +83,7 @@ export const CLAUDE_THINKING_LEVELS: ThinkingOption[] = [ { value: "low", label: "Low" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, + { value: "max", label: "Max" }, ]; export const CODEX_THINKING_LEVELS: ThinkingOption[] = [ diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index 265ab9099..d9bd7a648 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -124,6 +124,26 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ outputPricePer1M: 25, costTier: "very_high", }, + { + id: "anthropic/claude-opus-4-6-1m", + shortId: "opus-1m", + aliases: ["opus[1m]", "claude-opus-4-6[1m]"], + displayName: "Claude Opus 4.6 1M", + family: "anthropic", + authTypes: ["cli-subscription"], + contextWindow: 1_000_000, + maxOutputTokens: 32_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "max"], + color: "#B45309", + providerRoute: "claude-cli", + providerModelId: "opus[1m]", + cliCommand: "claude", + isCliWrapped: true, + inputPricePer1M: 10, + outputPricePer1M: 37.5, + costTier: "very_high", + }, { id: "anthropic/claude-sonnet-4-6", shortId: "sonnet", diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 80f669496..d68c72453 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -683,6 +683,7 @@ export type AgentChatRespondToInputArgs = { export type AgentChatModelsArgs = { provider: AgentChatProvider; + activateRuntime?: boolean; }; export type AgentChatDisposeArgs = { diff --git a/apps/desktop/src/shared/types/core.ts b/apps/desktop/src/shared/types/core.ts index b3c452fb1..ab6e80e40 100644 --- a/apps/desktop/src/shared/types/core.ts +++ b/apps/desktop/src/shared/types/core.ts @@ -45,6 +45,53 @@ export type ProjectInfo = { baseRef: string; }; +export type ProjectBrowseInput = { + partialPath?: string; + cwd?: string | null; + limit?: number; +}; + +export type ProjectBrowseEntry = { + name: string; + fullPath: string; + isGitRepo: boolean; +}; + +export type ProjectLanguageShare = { + name: string; + fraction: number; +}; + +export type ProjectLastCommit = { + subject: string; + isoDate: string; + shortSha: string; +}; + +export type ProjectDetail = { + rootPath: string; + isGitRepo: boolean; + branchName: string | null; + dirtyCount: number | null; + aheadBehind: { ahead: number; behind: number } | null; + lastCommit: ProjectLastCommit | null; + readmeExcerpt: string | null; + languages: ProjectLanguageShare[]; + laneCount: number | null; + lastOpenedAt: string | null; + subdirectoryCount: number | null; +}; + +export type ProjectBrowseResult = { + inputPath: string; + resolvedPath: string; + directoryPath: string; + parentPath: string | null; + exactDirectoryPath: string | null; + openableProjectRoot: string | null; + entries: ProjectBrowseEntry[]; +}; + export type ClearLocalAdeDataArgs = { packs?: boolean; logs?: boolean; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d183fbbb2..5c47446d8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -252,10 +252,10 @@ Agent tools are split by domain: `apps/desktop/src/shared/modelRegistry.ts` + `apps/desktop/src/shared/modelProfiles.ts`: -- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. +- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. Includes the Claude Opus 4.6 1M-context entry (`anthropic/claude-opus-4-6-1m`, aliases `opus[1m]` / `claude-opus-4-6[1m]`, 1,000,000 context / 32,000 max output, `costTier: "very_high"`, full `low|medium|high|max` reasoning tiers). - `ModelProviderGroup` = `"claude" | "codex" | "opencode" | "cursor"`. - Helpers: `getModelById`, `getModelPricing`, `updateModelPricingInRegistry`, `replaceDynamicOpenCodeModelDescriptors`, `resolveProviderGroupForModel`, `resolveModelDescriptorForProvider`, `getRuntimeModelRefForDescriptor`. -- Reasoning tier passthrough (`providerOptions.ts`) maps tier strings directly to each provider's native config (`thinking.type`, `reasoningEffort`, `thinkingConfig.thinkingLevel`, etc.) — no arbitrary token budgets. +- Reasoning tier passthrough (`providerOptions.ts`) maps tier strings directly to each provider's native config (`thinking.type`, `reasoningEffort`, `thinkingConfig.thinkingLevel`, etc.) — no arbitrary token budgets. The Claude vocabulary is `low | medium | high | max`. - Model profiles (`modelProfiles.ts`) derive the Missions UI model catalog and per-call-type intelligence defaults from `MODEL_REGISTRY` rather than maintaining parallel lists. ### 4.5 AI Orchestrator (deterministic runtime) @@ -287,6 +287,7 @@ Full contract: [`docs/architecture/AI_INTEGRATION.md`](../docs/architecture/AI_I - Two categories: **invoke methods** (`ipcRenderer.invoke(channel, args)` returning `Promise`) and **event subscriptions** (`ipcRenderer.on(channel, handler)`). - `contextIsolation: true`, `nodeIntegration: false`, `sandbox: false` (required for preload functionality). - Global window type: `apps/desktop/src/preload/global.d.ts`. +- `window.ade.project.getDroppedPath(file)` wraps Electron's `webUtils.getPathForFile()` so renderer drag-drop handlers can resolve the absolute path of a `File` payload without the renderer needing Node APIs. Used by the Command Palette project browser to accept dropped folders. ### 5.2 Channel design @@ -294,7 +295,7 @@ Full contract: [`docs/architecture/AI_INTEGRATION.md`](../docs/architecture/AI_I ``` ade.app.* # app lifecycle, clipboard, paths -ade.project.* # project open/close/switch/state +ade.project.* # project open/close/switch/state, in-app directory browser (browseDirectories, getDetail) ade.onboarding.* ade.lanes.* # lane list/create/delete/stack/template/env/port/proxy/rebase ade.files.* # file tree, read, write, search, watch @@ -390,7 +391,7 @@ Every service lives under `apps/desktop/src/main/services//`. Summary: | `opencode/` | `openCodeRuntime.ts`, `openCodeServerManager.ts`, `openCodeBinaryManager.ts`, `openCodeInventory.ts`, `openCodeModelCatalog.ts` | OpenCode server spawn, binary resolution, model discovery. | | `orchestrator/` | See §4.5. | Deterministic mission runtime + intelligent coordinator. | | `processes/` | `processService.ts` | Managed-process lifecycle per lane, readiness probes, restart policies. | -| `projects/` | `adeProjectService.ts`, `configReloadService.ts`, `projectService.ts`, `logIntegrityService.ts`, `recentProjectSummary.ts` | Project detection + `.ade` repair/bootstrap, reload on config change, recent-project metadata. | +| `projects/` | `adeProjectService.ts`, `configReloadService.ts`, `projectService.ts`, `logIntegrityService.ts`, `recentProjectSummary.ts`, `projectBrowserService.ts`, `projectDetailService.ts` | Project detection + `.ade` repair/bootstrap, reload on config change, recent-project metadata. `projectBrowserService` is the in-app directory autocomplete used by the Command Palette project browser (typed-path completion, `.git` detection, home expansion, system-picker fallback); `projectDetailService` returns repo metadata (branch, dirty count, ahead/behind, last commit, README excerpt, language mix, lane count, last-opened) for the palette's preview pane. | | `prs/` | `prService.ts`, `prPollingService.ts`, `prSummaryService.ts`, `queueLandingService.ts`, `issueInventoryService.ts`, `prIssueResolver.ts`, `prRebaseResolver.ts`, `integrationPlanning.ts`, `integrationValidation.ts` | PR CRUD, polling (with per-PR `last_polled_at` cursor), AI summary cache keyed by `(prId, head_sha)`, stacked-queue landing, issue inventory, AI-assisted resolution, integration planning. | | `pty/` | `ptyService.ts` | `node-pty` spawn, PTY I/O bridging, transcript writing. | | `runtime/` | `adeMcpLaunch.ts`, `tempCleanupService.ts` | MCP launch resolver (bundled proxy/headless/source), temp cleanup. | @@ -404,6 +405,12 @@ Every service lives under `apps/desktop/src/main/services//`. Summary: Startup sequencing: every background service goes through `scheduleBackgroundProjectTask()` in `main.ts`, which provides explicit labels, `ADE_ENABLE_*` env gates, `project.startup_task_begin`/`_done`/`_enabled`/`_skipped` telemetry, and per-task delays. Integrations stay **dormant-until-configured**. +Project-init step timing goes through `measureProjectInitStep(step, task)` — a wrapper that logs `project.init_step { projectRoot, step, durationMs }` around each hot-path operation (`db_open`, `lane.ensure_primary`, `mcp.socket_server_start`, `memory.files.initial_sync`, `sync.initialize`, etc.) so cold-start latency shows up in the logs by phase. The memory-file mirror sync and sync-service initialization are now scheduled through `scheduleBackgroundProjectTask` rather than awaited inline, gated by `ADE_ENABLE_MEMORY_FILE_SYNC` and `ADE_ENABLE_SYNC_INIT` respectively (both default-on). + +Shutdown pipeline: `main.ts` owns a single `requestAppShutdown({ reason, exitCode, fastKillFirst?, forceAfterMs? })` path driving a central state machine (`shutdownRequested` → `shutdownPromise` → `shutdownFinalized`). Hooks into `before-quit`, `window close`, `SIGINT`, `SIGTERM`, `process.exit`, `will-quit`, and `uncaughtException` all funnel through it. `runImmediateProcessCleanup()` disposes the orchestrator, automations, tests, processes, PTYs, agent chat runtimes, DB flush, and then calls `shutdownOpenCodeServers()`. A `forceAfterMs` timer (default 8 s, 5 s for signals/uncaught) hard-exits if cleanup hangs. User-initiated quit (main window close or `before-quit`) routes through `confirmQuitWarning()` — a modal dialog that explains that closing will stop OpenCode servers, terminal sessions, and test runs. + +On startup the main process also invokes `recoverManagedOpenCodeOrphans({ force: true })` (see `services/opencode/openCodeServerManager.ts`) to reap previous-run OpenCode processes left behind after a crash. Orphan detection matches processes by the managed marker env (`ADE_OPENCODE_MANAGED=1`) and/or the shared XDG config root, and confirms orphaning either by dead owner PID (`ADE_OPENCODE_OWNER_PID`) or reparent-to-init. Each acquire of a shared OpenCode server also invokes `pruneIdleSharedEntries()` which compacts idle entries from older configs (`pool_compaction` reason). + --- ## 7. UI Framework @@ -434,6 +441,7 @@ Electron renderer runtime does **not** wrap the app in `React.StrictMode`. Brows - Narrow selectors on components to minimize re-renders. - Per-project work-view state keyed by project root (`WorkProjectViewState`). - Store-owned event subscriptions for high-frequency streams (e.g., missions). +- `projectRevision` is a monotonically incrementing counter bumped inside `setProject` whenever the active project root actually changes. Long-lived renderer-side caches (most notably the module-level xterm runtime cache in `TerminalView.tsx`) subscribe to it and tear down any entries whose `projectRoot`/`projectRevision` no longer match, so PTYs never bleed between projects. All project-transition paths (`refreshProject`, `openRepo`, `switchProjectToPath`, `closeProject`) go through `setProject` to keep the counter honest. Domain stores co-located with their pages: diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 383c5c8a5..3deb2f898 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -77,6 +77,22 @@ See the detail docs for the specifics: buffered text, and pulls the next queued steer. 6. `dispose({ sessionId })` ends the runtime and persists the final state. +Inactivity eviction runs every 15 s (`SESSION_CLEANUP_INTERVAL_MS`). A +runtime is torn down when its session is idle, has no live pending +input, and has exceeded its provider-specific inactivity window: +`SESSION_INACTIVITY_TIMEOUT_MS = 5 min` for Claude/Codex/Cursor runtimes, +`OPENCODE_SESSION_INACTIVITY_TIMEOUT_MS = 60 s` for OpenCode runtimes +(OpenCode holds a pooled server, so its idle window is much shorter to +free the underlying server sooner). Teardown routes through +`teardownRuntime(managed, "idle_ttl")`. + +On app shutdown the service exposes `forceDisposeAll()` — called from +`runImmediateProcessCleanup()` in `main.ts`. It stops the cleanup timer, +rejects every outstanding `sessionTurnCollector` with a "closed during +shutdown" error so IPC callers don't hang, resolves local pending-input +promises with a `cancel` decision, and tears down every managed runtime +with reason `"shutdown"`. + ## IPC surface All channel constants live in `apps/desktop/src/shared/ipc.ts`; service @@ -102,6 +118,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.fileSearch` | invoke | Debounced attachment picker backend. | | `ade.agentChat.saveTempAttachment` | invoke | Write pasted/dropped image bytes to a temp file (10 MB cap). | | `ade.agentChat.listSubagents` | invoke | Claude subagent snapshot list. | +| `ade.agentChat.models` | invoke | `{ provider, activateRuntime? }`. For OpenCode `activateRuntime: true` is required to *launch* a probe server; otherwise the main process only returns the cached inventory (via `peekOpenCodeInventoryCache`) and an empty list until a real probe has been run. The renderer cache (`aiDiscoveryCache.ts`) keys on `(projectRoot, provider, activateRuntime)` so passive and active reads don't collide. | | `ade.agentChat.getSessionCapabilities` | invoke | Discover supported subagent/review features. | | `ade.agentChat.getTurnFileDiff` | invoke | Lazy diff expansion for a turn-file-summary row. | | `ade.agentChat.event` | push | Stream of `AgentChatEventEnvelope` into the renderer. | @@ -151,6 +168,29 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. chats. Regular renderer surfaces pass `undefined` to exclude them; CTO and worker pages pass `true`. Double-check when wiring new chat lists. +- **OpenCode passive vs. active inventory reads.** `loadAvailableModels` + for `provider: "opencode"` no longer unconditionally starts a probe + server. A passive call (the default for Settings page mounts, model + dropdown hydration, etc.) hits `peekOpenCodeInventoryCache` and + returns whatever was last probed; only explicit `activateRuntime: true` + calls (chat pane refresh for a Claude-to-OpenCode switch, sync + remote command resolution for a `chat.create` missing an explicit + model) will spin up the shared server. This avoids repeatedly + launching an OpenCode process just to render chrome. The registered + request key in `availableModelsRequests` is `${provider}:${mode}` + so an active probe and a passive peek can be in flight concurrently + without cross-resolving. +- **OpenCode shared server pool compaction.** Acquiring a shared + OpenCode server (`acquireSharedOpenCodeServer`) now calls + `pruneIdleSharedEntries(excludeKey)` which shuts down every other + idle shared entry with reason `"pool_compaction"`. The runtime / + coordinator shutdown-reason union was widened accordingly + (`teardownRuntime` in the chat service and + `releaseOpenCodeCoordinatorSession` in `coordinatorAgent.ts` both + accept `"pool_compaction"`). The effect: only one shared OpenCode + server runs at a time per project; switching provider config or + between chats with different configs recycles the pool instead of + stacking processes. ## Configuration diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index d272ad075..78cc1d847 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -84,6 +84,17 @@ registry at runtime when LM Studio or Ollama report available models. These descriptors carry `discoverySource` and a `harnessProfile` that defaults to `guarded` unless explicitly whitelisted. +### Reasoning tiers (Claude) + +Claude's reasoning-tier vocabulary is `low | medium | high | max` +(`CLAUDE_THINKING_LEVELS` in `shared/modelProfiles.ts`). `max` was added +alongside the Claude Opus 4.6 1M entry (`anthropic/claude-opus-4-6-1m`, +aliases `opus[1m]` / `claude-opus-4-6[1m]`, 1,000,000-token context, +32 k output, tier `very_high`) — it's the first registry entry that +advertises the full `low|medium|high|max` tier set. Passthrough to the +provider config is unchanged (the tier string is forwarded directly to +the CLI / SDK — no synthesized token budgets). + ## Auth and credentials `authDetector.ts` (`detectAllAuth`) probes every provider: diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index 7dfd88ace..fcc7841c0 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -65,8 +65,15 @@ and a footer that contains the composer. `AgentChatComposer` supports: -- **Text input** with auto-grow up to `composerMaxHeightPx` (constrained - for grid-tile layouts). +- **Text input** with auto-grow up to `composerMaxHeightPx`. Grid tiles + pass a fixed 144 px ceiling (computed statically from `layoutVariant`) + rather than the old `ResizeObserver`-based 28 %-of-height formula; + that eliminated the observer churn without changing the visible + ceiling for normal tile sizes. +- **Focus-on-active.** The composer receives focus whenever the + enclosing `AgentChatPane` reports `isTileActive: true` (for packed + grid tiles) or any equivalent active state — typing in the grid + immediately targets the focused tile's composer. - **Attachments** via drag-drop, paste, and an inline picker. Images are written through `ade.agentChat.saveTempAttachment` (10 MB cap; MIME validated per provider). @@ -85,7 +92,11 @@ and a footer that contains the composer. (PRD, ARCHITECTURE, mission pack) to the next turn. - **Permission controls.** Inline with the composer: - Interaction mode selector (`default` / `plan`). - - Claude permission mode selector. + - Claude permission mode — a popover picker with four tone-coded + options: **Ask permissions** (default, green), **Accept edits** + (blue), **Plan mode** (purple, read-only turns), **Bypass + permissions** (red). Tone styles live in `CLAUDE_MODE_TONE_STYLES`; + clicking outside or pressing Escape closes the popover. - Codex preset modes (Plan / Guarded Edit / Full Auto); custom and `config-toml` state shown as a summary row rather than raw inline dropdowns. diff --git a/docs/features/project-home/README.md b/docs/features/project-home/README.md index 7871a1bba..bb114c064 100644 --- a/docs/features/project-home/README.md +++ b/docs/features/project-home/README.md @@ -62,6 +62,21 @@ Main process (the substrate): — aggregated lane runtime health. - `apps/desktop/src/main/services/agentTools/` — detects installed agent CLI tools (Claude Code, Codex, Cursor, Aider, Continue). +- `apps/desktop/src/main/services/projects/projectBrowserService.ts` + — serves the Command Palette project browser: expands `~`, handles + platform-appropriate relative / absolute paths, lists matching + subdirectories with `.git` detection (concurrency-limited, capped at + `limit` with 500 max), and resolves any exact-directory match up to + an openable repo root via `resolveRepoRoot()`. Windows-style paths + are rejected on non-Windows hosts. +- `apps/desktop/src/main/services/projects/projectDetailService.ts` — + produces the palette's preview pane: branch name, dirty-file count, + ahead/behind counts, last commit (subject / ISO date / short sha), + README excerpt (first ~1,600 chars, trimmed on paragraph / sentence + boundary), top-four languages by file count (extension-mapped, + depth-2 walk capped at 2,000 files), subdirectory count, and — when + the path matches a recent-projects row in the global state file — + lane count and last-opened timestamp. Shared types: @@ -83,7 +98,8 @@ Rendered by `RunPage` when `useAppStore((s) => s.showWelcome)` is true a prior session. Shows: - ADE logo with a subtle pulse-glow -- "OPEN PROJECT" primary button → `appStore.openRepo()` +- "OPEN PROJECT" primary button → opens the Command Palette in + `intent="project-browse"` mode (see the next subsection) - recent projects list from `window.ade.project.listRecent()`, with display name, host path, lane count, and last-opened timestamp @@ -91,6 +107,42 @@ Clicking a recent project calls `appStore.switchProjectToPath(path)` which goes through the project open flow (`adeProjectService.openProject`). +### Command Palette project browser + +The Command Palette (`renderer/components/app/CommandPalette.tsx`) is a +dual-mode Radix dialog. In default mode it fuzzy-filters navigation / +action commands; in `intent="project-browse"` mode it becomes a +keyboard-first project opener. The palette mounts from two places: + +- **`AppShell`** — global ⌘K shortcut opens the palette in default + mode. The "Open project" / "Open another project" command switches + it into `project-browse` mode without closing. +- **`WelcomeScreen` in `RunPage`** — the "OPEN PROJECT" button mounts + a dedicated palette instance with `intent="project-browse"` so the + empty-project state skips straight to the browser. + +Project-browse behavior: + +1. The input field debounces into `window.ade.project.browseDirectories({ + partialPath, cwd, limit })`. `cwd` is the active project root + (so `../` is a usable starting point); if no project is open the + default input is `~/`. +2. Results render as a list: a "Go up" row if the current directory + has a parent, then matching subdirectories (alphabetically sorted, + `.git`-detected marked with a branch icon). +3. A debounced `window.ade.project.getDetail(target)` populates a + preview pane alongside the list — branch, dirty/ahead/behind, + last commit, README excerpt (rendered through `react-markdown` + + `remark-gfm`), language swatches, lane count, last-opened. +4. Enter activates the highlighted directory (walks into it). ⌘/Ctrl+ + Enter opens the openable project root (the first ancestor with a + `.git` entry). +5. Drag-and-drop onto the palette uses + `window.ade.project.getDroppedPath(file)` to resolve the dropped + folder's absolute path and then opens it. +6. A "Choose folder…" escape hatch falls through to the OS directory + picker via `window.ade.project.chooseDirectory`. + ### Per-lane runtime dashboard When a project is open and not in welcome state: diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 2d00ed1ca..71bf66042 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -14,8 +14,7 @@ does and does not travel, and the layers that implement it. Deep-dives: - `ios-companion.md` — the iPhone controller path: SwiftUI app, native SQLite, pairing, tab structure, command routing from phone to host. - `remote-commands.md` — the `syncRemoteCommandService` registry that - turns controller actions into host-executed mutations. **Branch-modified - at time of writing** (see that doc for specifics). + turns controller actions into host-executed mutations. ## Who participates @@ -93,6 +92,8 @@ only when they join the same sync cluster. └────────────────────────────────────────────────────────────────┘ ``` +## Source file map + Host-side service files (`apps/desktop/src/main/services/sync/`): @@ -113,7 +114,7 @@ Host-side service files `devices` table and `sync_cluster_state` singleton. - `syncPairingStore.ts` (128 lines) — local pairing-secret storage per-peer for W4 pairing flow. -- `syncRemoteCommandService.ts` (1,207 lines) — command action +- `syncRemoteCommandService.ts` (~1,210 lines) — command action registry (lanes, chat, git, PR, sessions, conflicts). Documented separately in `remote-commands.md`. diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 358d5ae70..75d61c8c7 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -8,16 +8,7 @@ host-side services, and replies with `command_ack` and then `command_result`. Source file: `apps/desktop/src/main/services/sync/syncRemoteCommandService.ts` -(1,207 lines). - -> **Branch-modified note:** this file is currently modified on the -> working branch relative to `main`. The diff adds -> `AgentChatFileRef` parsing to `chat.send` and `chat.steer` payloads -> (attachments, displayText, reasoningEffort, executionMode, -> interactionMode). Treat the action set in this doc as authoritative -> for the branch state; on `main` the `chat.send`/`chat.steer` payload -> parsers are narrower. See the diff summary at the bottom of this -> file. +(~1,210 lines). ## Shape @@ -266,31 +257,23 @@ can be sensitive. payloads and streaming reads outside the command surface to avoid bloating the command envelope. -## Branch modifications (current working branch) - -The repository is currently on a branch with the following changes -to `syncRemoteCommandService.ts` relative to `main`: - -1. Import added: `AgentChatFileRef`. -2. New helper `parseAgentChatFileRefs(value)` that accepts an array - of `{ path, type: "file" | "image" }` entries. -3. `parseAgentChatSendArgs` extended to accept and forward - `attachments`, `displayText`, `reasoningEffort`, `executionMode`, - `interactionMode`. -4. `parseAgentChatSteerArgs` extended to accept and forward - `attachments`. - -The effect: controllers (phones and desktop peers) can attach -files/images to a chat send or steer, and specify reasoning effort -/ execution mode / interaction mode from the controller side. The -corresponding host-side agent chat service must accept those -`AgentChatSendArgs` fields; treat these parsers as the contract at -the sync boundary on this branch. - -If you back out the branch changes, `chat.send` and `chat.steer` -accept only `{ sessionId, text }`. Consumers on the controller side -that rely on attachment passthrough need to check `AgentChatSendArgs` -in the shared types to see whether the fields exist at all. +## Chat command payload shape + +`parseAgentChatSendArgs` and `parseAgentChatSteerArgs` accept the full +`AgentChatSendArgs` surface: `sessionId`, `text`, `attachments` (via +`parseAgentChatFileRefs`, array of `{ path, type: "file" | "image" }`), +`displayText`, `reasoningEffort`, `executionMode`, `interactionMode`. +Steers accept `sessionId`, `text`, and `attachments`. Controllers +(phones and desktop peers) can therefore attach files/images and +specify reasoning / execution / interaction modes remotely; the +host-side `agentChatService` consumes the same shape end-to-end. + +`parseChatModelsArgs` accepts `{ provider, activateRuntime? }`. When +`chat.create` is missing an explicit model, `resolveChatCreateArgs` +forwards `activateRuntime: true` only for the `opencode` provider so +the host actually launches the OpenCode probe server before resolving +a default model. All other providers use passive (cache-only) resolution; +see the chat README for the passive/active contract. ## Gotchas diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 96c9206c1..5d6de5f94 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -95,6 +95,10 @@ Props that matter: - `layoutVariant` — `"standard"` (single tab) vs `"grid-tile"` (compact chrome, smaller fonts). +Grid mode keeps running PTY sessions mounted so multiple terminals can +stay live at once; `isActive` only controls focus/input, not whether the +terminal renderer exists. + Constants: - `CHAT_TILE_MIN_WIDTH = 440`, `CHAT_TILE_MIN_HEIGHT = 340` @@ -110,20 +114,49 @@ gaps: - `computeMinimumRowSpan()` / `computeMinimumColSpan()` - `clampPackedGridSpan()` — enforces per-tile min/max spans - `packGridItems(items)` — places each tile in the first available - slot scanning rows then columns + slot scanning rows then columns. Accepts optional `placement` hints + (`{ column, row }`) so resized tiles stay anchored to their + persisted origin instead of being re-flowed by the packer. - `computePackedGridRowHeight(containerHeight, rowCount)` — distributes height evenly, min `GRID_BASE_ROW_PX = 120` -- `reconcilePackedGridLayout(persistedLayout, activeIds)` — preserves - spans for tiles that come back later - -Spans are persisted per session via `readPackedGridSpan` / -`reconcilePackedGridLayout` and survive session switches. +- `reconcilePackedGridLayout({ layout, tileIds, defaultSpansById, + columnCount })` — preserves spans and persisted `colStart/rowStart/ + colSpan/rowSpan` quads for tiles that come back later. +- `resizePackedGridItem({ placementsById, tileId, direction, delta, + … })` — directional edge move (n/s/e/w, plus the diagonal + compositions). Pushes contiguous neighbors when the edge has zero + gap, or consumes free space when there is a gap. `moveEastEdge`, + `moveWestEdge`, and the north/south variants enforce per-tile + min-span floors and the column-count / `GRID_MAX_ROW_SPAN` ceilings. + +Persistence now carries both the span and the origin: the persisted +layout stores `:colStart`, `:rowStart`, `:colSpan`, +`:rowSpan` per tile and legacy `:col` / `:row` are still +read for backward compatibility. `readPackedGridPlacement(layout, id)` +returns the `{ column, row, colSpan, rowSpan }` record when one has been +written. + +West-edge drags keep the dragged edge anchored while the tile grows +leftward (the covered test in `PackedSessionGrid.test.tsx` asserts +`colStart` decreases by exactly the drag delta). During a resize, the +active tile is promoted to the front of the pack order so it "wins" +any overlap with newly-repositioned neighbors. + +`PackedSessionGrid` also accepts an `onViewportMouseLeave` callback +and an `onHover` per tile, so surrounding layouts can clear keyboard +focus / hover state when the pointer exits the grid. ## Terminal renderer: `TerminalView.tsx` Thin wrapper over xterm.js + `FitAddon`. Caches `Terminal` instances in a module-level map keyed by `(ptyId, sessionId)` so a remount does not -rebuild the emulator. +rebuild the emulator. Each cached entry also records the +`(projectRoot, projectRevision)` it was created under; on mount, +`disposeStaleRuntimes(activeProjectRoot, activeProjectRevision)` tears +down any entries whose project context no longer matches, which is how +terminal cache state gets cleared on project switch or close without +ever leaking PTYs between projects. The `projectRevision` counter +lives in `useAppStore` and is bumped on every real project change. Renderer strategy: WebGL-first, fall back to the DOM renderer on any init failure or context loss. Canvas renderer is intentionally skipped @@ -150,6 +183,13 @@ Key behaviors: `terminalPreferences` changes and applies font family, font size, line height, and scrollback to the live terminal, clearing the texture atlas to force glyph re-rasterization for WebGL. +- **Frame-write scheduling** — pending frame writes are coalesced on + `requestAnimationFrame` when the runtime is visible and the page is + foregrounded; a 16 ms `setTimeout` fallback takes over whenever the + runtime is parked (no refs), hidden, or the document is + backgrounded, so background terminals don't stall on `rAF` ticks + that the browser suppresses. `flushPendingFrameWrites` / `clearFrameWriteSchedule` + own both code paths. Font stack defaults: `ui-monospace`, `SFMono-Regular`, `Menlo`, `Monaco`, `Cascadia Mono`, `JetBrains Mono`, `Geist Mono`, `monospace`.