From 9d35235d7eec76e9dfb90ad74929bd397d72037b Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 27 Apr 2026 03:42:04 -0400 Subject: [PATCH 1/8] Add mobile project icons and work controls --- .claude/scheduled_tasks.lock | 1 - .gitignore | 1 + apps/desktop/src/main/main.ts | 31 +- .../src/main/services/ipc/registerIpc.ts | 82 ++++- .../projects/projectIconResolver.test.ts | 53 +++ .../services/projects/projectIconResolver.ts | 139 ++++++++ .../src/main/services/pty/ptyService.test.ts | 97 +++++- .../src/main/services/pty/ptyService.ts | 66 +++- .../services/sync/syncRemoteCommandService.ts | 22 +- apps/desktop/src/preload/global.d.ts | 4 + apps/desktop/src/preload/preload.ts | 7 + apps/desktop/src/renderer/browserMock.ts | 2 + .../src/renderer/components/app/App.tsx | 38 ++- .../components/app/App.workKeepAlive.test.tsx | 8 +- .../src/renderer/components/app/TopBar.tsx | 86 ++++- .../chat/ChatAttachmentTray.test.tsx | 77 +++++ .../components/chat/ChatAttachmentTray.tsx | 201 +++++++++-- .../components/lanes/useLaneWorkSessions.ts | 16 +- .../terminals/TerminalView.test.tsx | 37 ++ .../components/terminals/TerminalView.tsx | 20 ++ .../components/terminals/WorkStartSurface.tsx | 17 +- .../components/terminals/WorkViewArea.tsx | 2 + .../components/terminals/cliLaunch.test.ts | 95 ++++-- .../components/terminals/cliLaunch.ts | 58 +++- .../components/terminals/useWorkSessions.ts | 16 +- .../terminals/workSurfaceVisibility.ts | 5 + apps/desktop/src/shared/ipc.ts | 3 + apps/desktop/src/shared/types/core.ts | 6 + apps/desktop/src/shared/types/sync.ts | 1 + apps/ios/ADE.xcodeproj/project.pbxproj | 4 + apps/ios/ADE/App/ContentView.swift | 198 ++++++----- .../BrandMark.imageset/Contents.json | 22 +- apps/ios/ADE/Models/RemoteModels.swift | 3 + .../Services/LiveActivityCoordinator.swift | 80 ++--- apps/ios/ADE/Services/SyncService.swift | 115 +++++-- apps/ios/ADE/Shared/ADESharedModels.swift | 34 ++ .../ADE/Views/Lanes/LaneAdvancedScreen.swift | 206 ++++++++++++ .../ios/ADE/Views/Lanes/LaneCommitSheet.swift | 316 +++++++++++++++--- .../Lanes/LaneDetailContentSections.swift | 97 +++++- .../Views/Lanes/LaneDetailGitSection.swift | 249 +++----------- .../ADE/Views/Lanes/LaneDetailScreen.swift | 105 ++++-- .../Work/WorkArtifactTerminalViews.swift | 274 ++++++++------- .../ADE/Views/Work/WorkBrowserHelpers.swift | 12 - apps/ios/ADE/Views/Work/WorkModels.swift | 19 -- .../WorkNavigationAndTranscriptHelpers.swift | 311 +++++++++++------ .../ADE/Views/Work/WorkRootComponents.swift | 43 --- .../Views/Work/WorkRootScreen+Actions.swift | 121 +------ apps/ios/ADE/Views/Work/WorkRootScreen.swift | 59 +--- .../ADE/Views/Work/WorkSessionGrouping.swift | 8 - .../Work/WorkStatusAndFormattingHelpers.swift | 27 ++ apps/ios/ADETests/ADETests.swift | 201 ++++------- apps/ios/ADEWidgets/ADELiveActivity.swift | 23 ++ .../ios/ADEWidgets/ADELiveActivityViews.swift | 34 +- 53 files changed, 2565 insertions(+), 1187 deletions(-) delete mode 100644 .claude/scheduled_tasks.lock create mode 100644 apps/desktop/src/main/services/projects/projectIconResolver.test.ts create mode 100644 apps/desktop/src/main/services/projects/projectIconResolver.ts create mode 100644 apps/desktop/src/renderer/components/chat/ChatAttachmentTray.test.tsx create mode 100644 apps/desktop/src/renderer/components/terminals/workSurfaceVisibility.ts create mode 100644 apps/ios/ADE/Views/Lanes/LaneAdvancedScreen.swift diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 11cff863c..000000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"1676c542-49ae-4b07-80db-808ac138cb4b","pid":24538,"procStart":"Fri Apr 24 04:52:14 2026","acquiredAt":1777006545124} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 705f05441..b8cd0e6b7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ release-stable/ # Xcode user data & derived data xcuserdata/ *.xcuserstate +/.derived-data/ apps/ios/.dry-run-derived-data/ apps/ios/build/ ios-signing/ diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 8a53a1ab4..7bb97d72a 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -50,6 +50,7 @@ import { upsertProjectRow, } from "./services/projects/projectService"; import { inspectRecentProject, type RecentProjectInspection } from "./services/projects/recentProjectSummary"; +import { resolveProjectIcon } from "./services/projects/projectIconResolver"; import { createAdeProjectService } from "./services/projects/adeProjectService"; import { createConfigReloadService } from "./services/projects/configReloadService"; import { IPC } from "../shared/ipc"; @@ -955,6 +956,10 @@ app.whenReady().then(async () => { }); if (activeProjectRoot) { projectLastActivatedAt.set(activeProjectRoot, Date.now()); + const activeCtx = projectContexts.get(activeProjectRoot); + if (activeCtx) { + persistRecentProject(activeCtx.project, { recordLastProject: false }); + } try { adeArtifactAllowedDir = resolveAdeLayout(activeProjectRoot).artifactsDir; @@ -3938,6 +3943,7 @@ app.whenReady().then(async () => { rootPath: ctx.project.rootPath, defaultBaseRef: ctx.project.baseRef, lastOpenedAt: recent?.summary.lastOpenedAt ?? null, + iconDataUrl: mobileProjectIconDataUrl(ctx.project.rootPath), laneCount, isAvailable: fs.existsSync(ctx.project.rootPath), isCached: false, @@ -3953,6 +3959,7 @@ app.whenReady().then(async () => { rootPath: recent.summary.rootPath, defaultBaseRef: recent.defaultBaseRef, lastOpenedAt: recent.summary.lastOpenedAt, + iconDataUrl: mobileProjectIconDataUrl(recent.summary.rootPath), laneCount: recent.summary.laneCount ?? 0, isAvailable: recent.summary.exists, isCached: false, @@ -3960,6 +3967,22 @@ app.whenReady().then(async () => { }; } + function mobileProjectIconDataUrl(projectRoot: string): string | null { + try { + const icon = resolveProjectIcon(projectRoot); + if (!icon.sourcePath) return null; + + const image = nativeImage.createFromPath(icon.sourcePath); + if (!image.isEmpty()) { + return image.resize({ width: 64, height: 64, quality: "best" }).toDataURL(); + } + + return icon.mimeType === "image/png" ? icon.dataUrl : null; + } catch { + return null; + } + } + async function listMobileSyncProjects(): Promise<{ projects: SyncMobileProjectSummary[] }> { const recentProjects = (readGlobalState(globalStatePath).recentProjects ?? []) .map(inspectRecentProject); @@ -3971,9 +3994,11 @@ app.whenReady().then(async () => { byRoot.set(normalizeProjectRoot(recent.summary.rootPath), mobileProjectSummaryForRecent(recent)); } const contextSummaries = await Promise.all( - [...projectContexts.entries()].map(async ([root, ctx]) => - [root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const - ), + [...projectContexts.entries()] + .filter(([root]) => recentByRoot.has(root)) + .map(async ([root, ctx]) => + [root, await mobileProjectSummaryForContext(ctx, recentByRoot.get(root) ?? null)] as const + ), ); for (const [root, summary] of contextSummaries) { byRoot.set(root, summary); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 5c138d938..d60b589e1 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, nativeImage, shell } from "electron"; import { createEmptyAutoUpdateSnapshot, type createAutoUpdateService } from "../updates/autoUpdateService"; import { spawn } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -13,6 +13,7 @@ import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../ import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import { browseProjectDirectories } from "../projects/projectBrowserService"; import { getProjectDetail } from "../projects/projectDetailService"; +import { resolveProjectIcon } from "../projects/projectIconResolver"; import { runGit } from "../git/git"; import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types"; import { toRecentProjectSummary } from "../projects/recentProjectSummary"; @@ -258,6 +259,7 @@ import type { ProjectBrowseInput, ProjectBrowseResult, ProjectDetail, + ProjectIcon, ProjectInfo, RecentProjectSummary, PtyCreateArgs, @@ -1890,6 +1892,50 @@ export function registerIpc({ return path.resolve(path.isAbsolute(inputPath) ? inputPath : path.join(projectRoot, inputPath)); }; + const resolveAllowedRendererPath = (rawPath: string): string => { + const raw = typeof rawPath === "string" ? rawPath.trim() : ""; + if (!raw) throw new Error("Missing path."); + const ctx = getCtx(); + const normalized = resolveRendererSuppliedPath(raw, ctx.project.rootPath); + const allowedDirs = getAllowedDirs(getCtx); + const allowed = allowedDirs.some((dir) => { + try { + resolvePathWithinRoot(dir, normalized); + return true; + } catch { + return false; + } + }); + if (!allowed) { + throw new Error("Path is outside allowed directories."); + } + return normalized; + }; + + const inferImageMimeType = (filePath: string): string => { + const ext = path.extname(filePath).toLowerCase(); + switch (ext) { + case ".jpg": + case ".jpeg": + return "image/jpeg"; + case ".gif": + return "image/gif"; + case ".webp": + return "image/webp"; + case ".bmp": + return "image/bmp"; + case ".svg": + return "image/svg+xml"; + case ".ico": + return "image/x-icon"; + case ".tif": + case ".tiff": + return "image/tiff"; + default: + return "image/png"; + } + }; + ipcMain.handle(IPC.appRevealPath, async (_event, arg: { path: string }): Promise => { const raw = typeof arg?.path === "string" ? arg.path.trim() : ""; if (!raw) return; @@ -1940,6 +1986,31 @@ export function registerIpc({ clipboard.writeText(text); }); + ipcMain.handle(IPC.appGetImageDataUrl, async (_event, arg: { path: string }): Promise<{ dataUrl: string }> => { + const filePath = resolveAllowedRendererPath(arg?.path); + const stat = fs.statSync(filePath); + const MAX_PREVIEW_BYTES = 10 * 1024 * 1024; + if (!stat.isFile()) { + throw new Error("Path is not a file."); + } + if (stat.size > MAX_PREVIEW_BYTES) { + throw new Error("Image preview must be 10 MB or smaller."); + } + const data = fs.readFileSync(filePath); + return { + dataUrl: `data:${inferImageMimeType(filePath)};base64,${data.toString("base64")}`, + }; + }); + + ipcMain.handle(IPC.appWriteClipboardImage, async (_event, arg: { path: string }): Promise => { + const filePath = resolveAllowedRendererPath(arg?.path); + const image = nativeImage.createFromPath(filePath); + if (image.isEmpty()) { + throw new Error("Unable to read image."); + } + clipboard.writeImage(image); + }); + ipcMain.handle( IPC.appOpenPathInEditor, async ( @@ -2146,6 +2217,15 @@ export function registerIpc({ } ); + ipcMain.handle( + IPC.projectResolveIcon, + async (_event, args: { rootPath: string }): Promise => { + const rootPath = typeof args?.rootPath === "string" ? args.rootPath.trim() : ""; + if (!rootPath) return { dataUrl: null, sourcePath: null, mimeType: null }; + return resolveProjectIcon(rootPath); + }, + ); + ipcMain.handle(IPC.projectOpenAdeFolder, async (): Promise => { const ctx = getCtx(); await shell.openPath(ctx.adeDir); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.test.ts b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts new file mode 100644 index 000000000..3855bb47c --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectIconResolver.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +import { resolveProjectIcon, resolveProjectIconPath } from "./projectIconResolver"; + +function makeProjectRoot(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), "ade-project-icon-")); +} + +function writeFile(root: string, relativePath: string, contents: string | Buffer): string { + const filePath = path.join(root, relativePath); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); + return filePath; +} + +describe("projectIconResolver", () => { + it("prefers well-known favicon files", () => { + const root = makeProjectRoot(); + const iconPath = writeFile(root, "favicon.svg", "favicon"); + + expect(resolveProjectIconPath(root)).toBe(iconPath); + }); + + it("resolves icon hrefs from project source files", () => { + const root = makeProjectRoot(); + writeFile(root, "index.html", ''); + const iconPath = writeFile(root, "public/brand/logo.svg", "brand"); + + expect(resolveProjectIconPath(root)).toBe(iconPath); + }); + + it("does not resolve linked icons outside the project root", () => { + const root = makeProjectRoot(); + writeFile(path.dirname(root), "outside.svg", "outside"); + writeFile(root, "index.html", ''); + + expect(resolveProjectIconPath(root)).toBeNull(); + }); + + it("returns a data URL for resolved icons", () => { + const root = makeProjectRoot(); + writeFile(root, "favicon.svg", "favicon"); + + const icon = resolveProjectIcon(root); + + expect(icon.mimeType).toBe("image/svg+xml"); + expect(icon.sourcePath).toContain("favicon.svg"); + expect(icon.dataUrl).toMatch(/^data:image\/svg\+xml;base64,/); + }); +}); diff --git a/apps/desktop/src/main/services/projects/projectIconResolver.ts b/apps/desktop/src/main/services/projects/projectIconResolver.ts new file mode 100644 index 000000000..a9bfbdad3 --- /dev/null +++ b/apps/desktop/src/main/services/projects/projectIconResolver.ts @@ -0,0 +1,139 @@ +import fs from "node:fs"; +import path from "node:path"; + +import type { ProjectIcon } from "../../../shared/types"; + +const ICON_MAX_BYTES = 1024 * 1024; + +const ICON_CANDIDATES = [ + "favicon.svg", + "favicon.ico", + "favicon.png", + "public/favicon.svg", + "public/favicon.ico", + "public/favicon.png", + "app/favicon.ico", + "app/favicon.png", + "app/icon.svg", + "app/icon.png", + "app/icon.ico", + "src/favicon.ico", + "src/favicon.svg", + "src/app/favicon.ico", + "src/app/icon.svg", + "src/app/icon.png", + "assets/icon.svg", + "assets/icon.png", + "assets/logo.svg", + "assets/logo.png", + ".idea/icon.svg", +] as const; + +const ICON_SOURCE_FILES = [ + "index.html", + "public/index.html", + "app/routes/__root.tsx", + "src/routes/__root.tsx", + "app/root.tsx", + "src/root.tsx", + "src/index.html", +] as const; + +const LINK_ICON_HTML_RE = + /]*\brel=["'](?:icon|shortcut icon)["'])(?=[^>]*\bhref=["']([^"'?]+))[^>]*>/i; +const LINK_ICON_OBJ_RE = + /(?=[^}]*\brel\s*:\s*["'](?:icon|shortcut icon)["'])(?=[^}]*\bhref\s*:\s*["']([^"'?]+))[^}]*/i; + +function extractIconHref(source: string): string | null { + const htmlMatch = source.match(LINK_ICON_HTML_RE); + if (htmlMatch?.[1]) return htmlMatch[1]; + const objMatch = source.match(LINK_ICON_OBJ_RE); + if (objMatch?.[1]) return objMatch[1]; + return null; +} + +function isLocalIconHref(href: string): boolean { + return !/^(?:[a-z][a-z\d+.-]*:)?\/\//i.test(href) + && !href.startsWith("data:") + && !href.startsWith("#"); +} + +function isPathWithinProject(projectRoot: string, candidatePath: string): boolean { + const relative = path.relative(path.resolve(projectRoot), path.resolve(candidatePath)); + return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); +} + +function findExistingFile(projectRoot: string, candidates: readonly string[]): string | null { + for (const candidate of candidates) { + if (!isPathWithinProject(projectRoot, candidate)) continue; + try { + const stat = fs.statSync(candidate); + if (stat.isFile()) return candidate; + } catch { + // Keep probing other candidates. + } + } + return null; +} + +function resolveIconHref(projectRoot: string, href: string): string[] { + const clean = href.replace(/^\//, ""); + return [path.join(projectRoot, "public", clean), path.join(projectRoot, clean)]; +} + +export function resolveProjectIconPath(projectRoot: string): string | null { + const root = path.resolve(projectRoot); + for (const candidate of ICON_CANDIDATES) { + const existing = findExistingFile(root, [path.join(root, candidate)]); + if (existing) return existing; + } + + for (const sourceFile of ICON_SOURCE_FILES) { + const sourcePath = path.join(root, sourceFile); + let source: string; + try { + source = fs.readFileSync(sourcePath, "utf8"); + } catch { + continue; + } + const href = extractIconHref(source); + if (!href || !isLocalIconHref(href)) continue; + const existing = findExistingFile(root, resolveIconHref(root, href)); + if (existing) return existing; + } + + return null; +} + +function mimeTypeForIconPath(filePath: string): string | null { + switch (path.extname(filePath).toLowerCase()) { + case ".svg": + return "image/svg+xml"; + case ".ico": + return "image/x-icon"; + case ".png": + return "image/png"; + default: + return null; + } +} + +export function resolveProjectIcon(projectRoot: string): ProjectIcon { + const iconPath = resolveProjectIconPath(projectRoot); + if (!iconPath) return { dataUrl: null, sourcePath: null, mimeType: null }; + + const mimeType = mimeTypeForIconPath(iconPath); + if (!mimeType) return { dataUrl: null, sourcePath: null, mimeType: null }; + + const stat = fs.statSync(iconPath); + if (stat.size > ICON_MAX_BYTES) { + return { dataUrl: null, sourcePath: iconPath, mimeType }; + } + + const data = fs.readFileSync(iconPath); + return { + dataUrl: `data:${mimeType};base64,${data.toString("base64")}`, + sourcePath: iconPath, + mimeType, + }; +} diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 2ebe85047..45c23fc04 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -447,6 +447,38 @@ describe("ptyService", () => { expect(mockPty.write).not.toHaveBeenCalled(); }); + it("falls back to typing startupCommand in a shell when direct command spawn fails", async () => { + const { service, mockPty, loadPty } = createHarness(); + const spawn = vi.fn((command: string) => { + if (command === "codex") throw new Error("ENOENT"); + return mockPty; + }); + loadPty.mockImplementationOnce(() => ({ spawn: spawn as any })); + + await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + command: "codex", + args: ["--no-alt-screen", "ADE session guidance"], + startupCommand: "codex --no-alt-screen \"ADE session guidance\"", + }); + + expect(spawn).toHaveBeenCalledWith( + "codex", + ["--no-alt-screen", "ADE session guidance"], + expect.any(Object), + ); + expect(spawn).toHaveBeenCalledWith( + expect.stringMatching(/(?:zsh|bash|sh|powershell|cmd)(?:\.exe)?$/), + expect.any(Array), + expect.any(Object), + ); + expect(mockPty.write).toHaveBeenCalledWith("codex --no-alt-screen \"ADE session guidance\"\r"); + }); + it("wraps direct Windows command shims through cmd.exe", async () => { setPlatform("win32"); const harness = createHarness(); @@ -1157,8 +1189,8 @@ describe("ptyService", () => { const homedir = os.homedir(); const sessionsBase = path.join(homedir, ".codex", "sessions"); const dirPath = path.join(sessionsBase, "2026", "04", "15"); - const filePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-storage.jsonl"); - const startedAt = "2026-04-15T21:30:00.000Z"; + const filePath = path.join(dirPath, "rollout-2026-04-15T22-00-01-thread-storage.jsonl"); + const startedAt = "2026-04-15T22:00:01.000Z"; const oversizedFirstLine = JSON.stringify({ timestamp: startedAt, type: "session_meta", @@ -1496,7 +1528,7 @@ describe("ptyService", () => { }); describe("ensureResumeTargets", () => { - it("calls sessionService.setResumeCommand for each session whose Codex JSONL matches", async () => { + it("does not assign Codex storage targets during passive session-list hydration", async () => { vi.useFakeTimers(); try { const fakeNow = new Date("2026-04-15T22:00:00.000Z"); @@ -1539,7 +1571,64 @@ describe("ptyService", () => { // allow any microtasks to settle await vi.advanceTimersByTimeAsync(0); - expect(sessionService.setResumeCommand).toHaveBeenCalledWith("session-1", "codex resume thread-abc"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith("session-1", "codex resume thread-abc"); + } finally { + vi.useRealTimers(); + } + }); + + it("captures a fresh Codex storage target for a new launch without choosing older same-cwd sessions", async () => { + vi.useFakeTimers(); + try { + const fakeNow = new Date("2026-04-15T22:00:00.000Z"); + vi.setSystemTime(fakeNow); + + const homedir = os.homedir(); + const sessionsBase = path.join(homedir, ".codex", "sessions"); + const dirPath = path.join(sessionsBase, "2026", "04", "15"); + const stalePath = path.join(dirPath, "rollout-2026-04-15T21-30-00-thread-stale.jsonl"); + const freshPath = path.join(dirPath, "rollout-2026-04-15T22-00-01-thread-fresh.jsonl"); + const staleFirstLine = JSON.stringify({ + timestamp: "2026-04-15T21:30:00.000Z", + type: "session_meta", + payload: { + id: "thread-stale", + timestamp: "2026-04-15T21:30:00.000Z", + cwd: "/tmp/test-worktree", + }, + }); + const freshFirstLine = JSON.stringify({ + timestamp: "2026-04-15T22:00:01.000Z", + type: "session_meta", + payload: { + id: "thread-fresh", + timestamp: "2026-04-15T22:00:01.000Z", + cwd: "/tmp/test-worktree", + }, + }); + + mocks.existsSyncResults.set(sessionsBase, true); + mocks.existsSyncResults.set(dirPath, true); + mocks.dirEntries.set(dirPath, [path.basename(stalePath), path.basename(freshPath)]); + mocks.fileContents.set(stalePath, `${staleFirstLine}\n`); + mocks.fileContents.set(freshPath, `${freshFirstLine}\n{"timestamp":"2026-04-15T22:00:01.500Z","type":"event_msg","payload":{"type":"user_message","message":"ADE session guidance"}}\n`); + mocks.fileStats.set(stalePath, { size: staleFirstLine.length, mtimeMs: fakeNow.getTime() - 30 * 60_000, isDirectory: false }); + mocks.fileStats.set(freshPath, { size: freshFirstLine.length, mtimeMs: fakeNow.getTime() + 1_000, isDirectory: false }); + + const { service, sessionService } = createHarness(); + const created = await service.create({ + laneId: "lane-1", + title: "Codex CLI", + cols: 80, + rows: 24, + toolType: "codex", + startupCommand: "codex --no-alt-screen --dangerously-bypass-approvals-and-sandbox", + }); + + await vi.advanceTimersByTimeAsync(1_500); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith(created.sessionId, "codex resume thread-fresh"); + expect(sessionService.setResumeCommand).not.toHaveBeenCalledWith(created.sessionId, "codex resume thread-stale"); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 041f51c2d..0aa43c058 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -697,13 +697,40 @@ export function createPtyService({ } } + function readFilePrefix(filePath: string, maxBytes = 512 * 1024): string | null { + let fd: number | null = null; + try { + fd = fs.openSync(filePath, "r"); + const buf = Buffer.alloc(maxBytes); + const bytesRead = fs.readSync(fd, buf, 0, maxBytes, 0); + if (bytesRead <= 0) return null; + return buf.subarray(0, bytesRead).toString("utf8"); + } catch { + return null; + } finally { + if (fd !== null) { + try { + fs.closeSync(fd); + } catch { + // Ignore close errors while scanning best-effort session metadata. + } + } + } + } + /** * Try to find the Codex session ID from Codex's local storage. * Codex stores sessions at ~/.codex/sessions/YYYY/MM/DD/rollout--.jsonl. * Each JSONL starts with a session_meta event containing `payload.id` and `payload.cwd`. * We score recent candidates by cwd match and closeness to ADE's session startedAt. */ - const resolveCodexSessionIdFromStorage = (args: { cwd: string; startedAt?: string | null }): string | null => { + const resolveCodexSessionIdFromStorage = (args: { + cwd: string; + startedAt?: string | null; + maxStartDeltaMs?: number; + notBeforeMs?: number; + requiredText?: string; + }): string | null => { try { const sessionsBase = path.join(os.homedir(), ".codex", "sessions"); if (!fs.existsSync(sessionsBase)) return null; @@ -746,13 +773,19 @@ export function createPtyService({ const id = typeof payload?.id === "string" ? payload.id.trim() : ""; const cwd = typeof payload?.cwd === "string" ? payload.cwd.trim() : ""; if (type !== "session_meta" || !id || cwd !== args.cwd) continue; + if (args.requiredText) { + const prefix = readFilePrefix(candidate.filePath); + if (!prefix?.includes(args.requiredText)) continue; + } if (!hasStartedAt) return id; const payloadTimestamp = typeof payload?.timestamp === "string" ? payload.timestamp : ""; const payloadTimestampMs = Date.parse(payloadTimestamp); const referenceMs = Number.isFinite(payloadTimestampMs) ? payloadTimestampMs : candidate.mtimeMs; + if (typeof args.notBeforeMs === "number" && referenceMs < args.notBeforeMs) continue; const score = Math.abs(referenceMs - requestedStartedAtMs); + if (typeof args.maxStartDeltaMs === "number" && score > args.maxStartDeltaMs) continue; if (!bestMatch || score < bestMatch.score || (score === bestMatch.score && candidate.mtimeMs > bestMatch.mtimeMs)) { bestMatch = { id, score, mtimeMs: candidate.mtimeMs }; } @@ -797,8 +830,12 @@ export function createPtyService({ } } - if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd) { - const codexSessionId = resolveCodexSessionIdFromStorage({ cwd, startedAt: session.startedAt }); + if ((effectiveToolType === "codex" || effectiveToolType === "codex-orchestrated") && cwd && reason !== "session-list" && reason !== "resume-launch") { + const codexSessionId = resolveCodexSessionIdFromStorage({ + cwd, + startedAt: session.startedAt, + maxStartDeltaMs: 10 * 60_000, + }); if (codexSessionId) { const resumeCmd = `codex resume ${codexSessionId}`; sessionService.setResumeCommand(sessionId, resumeCmd); @@ -844,7 +881,14 @@ export function createPtyService({ const session = sessionService.get(sessionId); if (!session) return; if (session.resumeMetadata?.targetId?.trim()) return; - const codexSessionId = resolveCodexSessionIdFromStorage({ cwd, startedAt }); + const startedAtMs = Date.parse(startedAt); + const codexSessionId = resolveCodexSessionIdFromStorage({ + cwd, + startedAt, + maxStartDeltaMs: 2 * 60_000, + ...(Number.isFinite(startedAtMs) ? { notBeforeMs: startedAtMs - 1_000 } : {}), + requiredText: "ADE session guidance", + }); if (codexSessionId) { sessionService.setResumeCommand(sessionId, `codex resume ${codexSessionId}`); logger.info("pty.codex_session_id_captured_live", { sessionId, codexSessionId, attempt }); @@ -1176,6 +1220,7 @@ export function createPtyService({ let selectedShell: ShellSpec | null = null; const directCommand = typeof args.command === "string" ? args.command.trim() : ""; const directArgs = Array.isArray(args.args) ? args.args.filter((value): value is string => typeof value === "string") : []; + let launchedDirectCommand = false; try { const ptyLib = loadPty(); const opts: IWindowsPtyForkOptions = { @@ -1194,14 +1239,17 @@ export function createPtyService({ ? invocation.args.join(" ") : invocation.args; created = ptyLib.spawn(invocation.command, ptyArgs, opts); + launchedDirectCommand = true; } catch (err) { lastErr = err; } - } else { + } + if (!created && (!directCommand || startupCommand)) { for (const shell of shellCandidates) { try { created = ptyLib.spawn(shell.file, shell.args, opts); selectedShell = shell; + launchedDirectCommand = false; break; } catch (err) { lastErr = err; @@ -1380,10 +1428,10 @@ export function createPtyService({ }); // Only type the startup command into the terminal when we launched an - // interactive shell (no directCommand). If directCommand is set we either - // already threw on spawn failure or the command is running directly — in - // neither case do we want to feed an extra startupCommand string. - if (startupCommand && !directCommand && selectedShell) { + // interactive shell. Direct command launches already received argv; if a + // direct launch fell back to shell, startupCommand keeps compatibility + // with CLIs that are only available through shell startup files. + if (startupCommand && !launchedDirectCommand && selectedShell) { try { pty.write(`${startupCommand}\r`); setRuntimeState(sessionId, "running"); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts index fdd07e5b9..2e2803bf7 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.ts @@ -1524,16 +1524,24 @@ export function createSyncRemoteCommandService(args: SyncRemoteCommandServiceArg register("lanes.rebaseAbort", { viewerAllowed: true, queueable: true }, async (payload) => args.laneService.rebaseAbort(parseRunIdArgs(payload, "lanes.rebaseAbort"))); register("lanes.listRebaseSuggestions", { viewerAllowed: true }, async () => args.rebaseSuggestionService?.listSuggestions() ?? []); register("lanes.dismissRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.rebaseSuggestionService) return { ok: true }; - await args.rebaseSuggestionService.dismiss({ laneId: requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId.") }); + const laneId = requireString(payload.laneId, "lanes.dismissRebaseSuggestion requires laneId."); + args.conflictService?.dismissRebase(laneId); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.dismiss({ laneId }); + } return { ok: true }; }); register("lanes.deferRebaseSuggestion", { viewerAllowed: true, queueable: true }, async (payload) => { - if (!args.rebaseSuggestionService) return { ok: true }; - await args.rebaseSuggestionService.defer({ - laneId: requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."), - minutes: asOptionalNumber(payload.minutes) ?? 60, - }); + const laneId = requireString(payload.laneId, "lanes.deferRebaseSuggestion requires laneId."); + const minutes = Math.max(5, Math.min(7 * 24 * 60, Math.floor(asOptionalNumber(payload.minutes) ?? 60))); + const until = new Date(Date.now() + minutes * 60_000).toISOString(); + args.conflictService?.deferRebase(laneId, until); + if (args.rebaseSuggestionService) { + await args.rebaseSuggestionService.defer({ + laneId, + minutes, + }); + } return { ok: true }; }); register("lanes.listAutoRebaseStatuses", { viewerAllowed: true }, async () => args.autoRebaseService?.listStatuses() ?? []); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index c60f30f7e..843451823 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -5,6 +5,7 @@ import type { ProjectBrowseInput, ProjectBrowseResult, ProjectDetail, + ProjectIcon, BatchAssessmentResult, ApplyConflictProposalArgs, AttachLaneArgs, @@ -608,6 +609,8 @@ declare global { revealPath: (path: string) => Promise; openPath: (path: string) => Promise; writeClipboardText: (text: string) => Promise; + getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>; + writeClipboardImage: (path: string) => Promise; openPathInEditor: (args: { rootPath: string; relativePath?: string; @@ -628,6 +631,7 @@ declare global { args?: ProjectBrowseInput, ) => Promise; getDetail: (rootPath: string) => Promise; + resolveIcon: (rootPath: string) => Promise; getDroppedPath: (file: File) => string; openAdeFolder: () => Promise; clearLocalData: ( diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 10ba40d0c..5faebc117 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -7,6 +7,7 @@ import type { ProjectBrowseInput, ProjectBrowseResult, ProjectDetail, + ProjectIcon, } from "../shared/types"; import type { BatchAssessmentResult, @@ -619,6 +620,10 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.appOpenPath, { path }), writeClipboardText: async (text: string): Promise => ipcRenderer.invoke(IPC.appWriteClipboardText, { text }), + getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> => + ipcRenderer.invoke(IPC.appGetImageDataUrl, { path }), + writeClipboardImage: async (path: string): Promise => + ipcRenderer.invoke(IPC.appWriteClipboardImage, { path }), openPathInEditor: async (args: { rootPath: string; relativePath?: string; @@ -640,6 +645,8 @@ contextBridge.exposeInMainWorld("ade", { ipcRenderer.invoke(IPC.projectBrowseDirectories, args), getDetail: async (rootPath: string): Promise => ipcRenderer.invoke(IPC.projectGetDetail, { rootPath }), + resolveIcon: async (rootPath: string): Promise => + ipcRenderer.invoke(IPC.projectResolveIcon, { rootPath }), getDroppedPath: (file: File): string => { try { return webUtils.getPathForFile(file); diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index af8269058..a31a7b581 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -1657,6 +1657,8 @@ if (typeof window !== "undefined" && !(window as any).ade) { openExternal: resolvedArg(undefined), revealPath: resolvedArg(undefined), writeClipboardText: resolvedArg(undefined), + getImageDataUrl: resolvedArg({ dataUrl: "" }), + writeClipboardImage: resolvedArg(undefined), openPathInEditor: resolvedArg(undefined), }, project: { diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index a67f0e4d2..5523cc6e0 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -59,6 +59,7 @@ const CtoPage = React.lazy(() => import { useAppStore } from "../../state/appStore"; import { getDirtyFileTextForWindow } from "../../lib/dirtyWorkspaceBuffers"; +import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; const StartupSplashScreen = (
@@ -190,6 +191,31 @@ function PersistentWorkSurface({ active }: { active: boolean }) { const projectHydrated = useAppStore((s) => s.projectHydrated); const showWelcome = useAppStore((s) => s.showWelcome); const project = useAppStore((s) => s.project); + const workSurfaceRef = React.useRef(null); + + React.useEffect(() => { + if (!active) return; + const raf = window.requestAnimationFrame(() => { + dispatchWorkSurfaceRevealed(); + }); + const settleTimer = window.setTimeout(() => { + dispatchWorkSurfaceRevealed(); + }, 120); + return () => { + window.cancelAnimationFrame(raf); + window.clearTimeout(settleTimer); + }; + }, [active]); + + React.useEffect(() => { + const node = workSurfaceRef.current; + if (!node) return; + if (active) { + node.removeAttribute("inert"); + } else { + node.setAttribute("inert", ""); + } + }, [active]); if (!projectHydrated) { return active ? GuardLoadingFallback : null; @@ -202,8 +228,18 @@ function PersistentWorkSurface({ active }: { active: boolean }) { return (