diff --git a/.gitignore b/.gitignore index 3ac5a88fdfab3c0e19a87842855744a7b7640c3f..08ba9a8a1e32370fc37f9fbfd03607d43e812f78 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ packages/*/dist .env.local build/ .logs/ +release/ diff --git a/.oxlintrc.json b/.oxlintrc.json index d45179a4c468ff9129700fba0ac6e14223cec0b1..fba6a9e2e368ffcd50ba29c484e7d0c71c6ac9d1 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -1,6 +1,13 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "ignorePatterns": ["dist", "dist-electron", "node_modules", "bun.lock", "*.tsbuildinfo"], + "ignorePatterns": [ + "dist", + "dist-electron", + "node_modules", + "bun.lock", + "*.tsbuildinfo", + "**/routeTree.gen.ts" + ], "plugins": ["eslint", "oxc", "react", "unicorn", "typescript"], "categories": { "correctness": "warn", diff --git a/KEYBINDINGS.md b/KEYBINDINGS.md index a44f1f5f4ff997d13508a4ed7d28a4993d7b9d19..001cebba1a224f38c57e8346839d564271e7f84b 100644 --- a/KEYBINDINGS.md +++ b/KEYBINDINGS.md @@ -53,6 +53,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged - `chat.new`: create a new chat thread preserving the active thread's branch/worktree state - `chat.newLocal`: create a new local chat thread for the active project (no worktree context) - `editor.openFavorite`: open current project/worktree in the last-used editor +- `script.{id}.run`: run a project script by id (for example `script.test.run`) ### Key Syntax diff --git a/README.md b/README.md index ac73a427082d88fe755a14b4e66c3605fb64d783..425a70e4041018b3793af34d548c358597bad03d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop bun run build bun run start +# Build a shareable macOS .dmg (arm64 by default) +bun run dist:desktop:dmg + # Or from any project directory after publishing: npx t3 ``` @@ -69,6 +72,17 @@ npx t3 - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. - `bun run test` — Runs workspace tests. +- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. +- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. + +### Desktop `.dmg` packaging notes + +- Default build is unsigned/not notarized for local sharing. +- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. +- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). +- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. +- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. +- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` ### Running multiple dev instances diff --git a/TODO.md b/TODO.md index e6ff5a6b7e4a04433c7bd84abe54fca6d56bb127..cf8656655e8eea82dc195d411a51a85cf2cc2150 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ ## Small things +- [ ] Plan mode +- [ ] Submitting new messages should scroll to bottom - [ ] 1 click copy in codeblocks - [x] Pasting/adding images to submit with prompts - [ ] Only show last 10 threads for a given project @@ -12,7 +14,7 @@ - [x] "Response" bar should ONLY appear on work completed - [x] "Response" bar should ONLY appear above the FINAL message in a series - [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) -- [ ] Insta focus input on new thread creation +- [x] Insta focus input on new thread creation - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it - [x] Making new thread should auto-focus input box - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index acb4535b60e5107bc2fac12705d43e40b23c2d18..45b848af6524e510fc183d1ac21891d95444e769 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1 +1,2 @@ dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d6afd21ce940cbbd337504fb545c19c59ed95e2c..65a23524587ac523734734ec9214e235c534d433 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,13 +2,14 @@ "name": "@t3tools/desktop", "version": "0.0.0", "private": true, + "productName": "T3 Code (Alpha)", "main": "dist-electron/main.js", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsdown", - "start": "electron dist-electron/main.js", + "start": "bun run scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..da16d12a0c7cb97f6a77c382f1a07c52a9457b16 Binary files /dev/null and b/apps/desktop/resources/icon.icns differ diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..37f3f756a553db1520e7bde5f928bc7a68cd1bdf Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index be7d8380fb7172b0d35436d3e8b4a714f3d13810..03ac96b5a070e5e3a12acb02693ef27bc678c672 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,10 @@ -import { spawn } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import electronmon from "electronmon"; import waitOn from "wait-on"; +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); const devServerUrl = `http://localhost:${port}`; @@ -14,18 +17,57 @@ await waitOn({ ], }); -const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", +const monitor = await electronmon({ + cwd: desktopDir, + args: ["dist-electron/main.js"], env: { ...childEnv, VITE_DEV_SERVER_URL: devServerUrl, }, + electronPath: resolveElectronPath(), }); -child.on("exit", (code) => { - process.exit(code ?? 0); +let shuttingDown = false; + +function killChildTree(signal) { + if (process.platform === "win32") { + return; + } + + // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. + spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); +} + +async function shutdown(exitCode) { + if (shuttingDown) return; + shuttingDown = true; + + try { + await Promise.race([ + monitor.destroy(), + new Promise((resolve) => setTimeout(resolve, 1_500)), + ]); + } catch { + // Best effort only; fallback process kill below handles stubborn children. + } + + killChildTree("TERM"); + setTimeout(() => { + killChildTree("KILL"); + }, 1_200).unref(); + + process.exit(exitCode); +} + +process.once("SIGINT", () => { + void shutdown(130); +}); +process.once("SIGTERM", () => { + void shutdown(143); +}); +process.once("SIGHUP", () => { + void shutdown(129); }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2a6d685dea2032cc4de322aa03ba9bb46fa5110a --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -0,0 +1,173 @@ +// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_BUNDLE_ID = "com.t3tools.t3code"; +const LAUNCHER_VERSION = 1; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const desktopDir = resolve(__dirname, ".."); + +function setPlistString(plistPath, key, value) { + const replaceResult = spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (replaceResult.status === 0) { + return; + } + + const insertResult = spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (insertResult.status === 0) { + return; + } + + const details = [replaceResult.stderr, insertResult.stderr] + .filter(Boolean) + .join("\n"); + throw new Error( + `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), + ); +} + +function patchMainBundleInfoPlist(appBundlePath, iconPath) { + const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); + + const resourcesDir = join(appBundlePath, "Contents", "Resources"); + copyFileSync(iconPath, join(resourcesDir, "icon.icns")); + copyFileSync(iconPath, join(resourcesDir, "electron.icns")); +} + +function patchHelperBundleInfoPlists(appBundlePath) { + const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); + if (!existsSync(frameworksDir)) { + return; + } + + for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.endsWith(".app")) { + continue; + } + if (!entry.name.startsWith("Electron Helper")) { + continue; + } + + const helperPlistPath = join( + frameworksDir, + entry.name, + "Contents", + "Info.plist", + ); + if (!existsSync(helperPlistPath)) { + continue; + } + + const suffix = entry.name + .replace("Electron Helper", "") + .replace(".app", "") + .trim(); + const helperName = suffix + ? `${APP_DISPLAY_NAME} Helper ${suffix}` + : `${APP_DISPLAY_NAME} Helper`; + const helperIdSuffix = suffix + .replace(/[()]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-"); + const helperBundleId = helperIdSuffix + ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` + : `${APP_BUNDLE_ID}.helper`; + + setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); + setPlistString(helperPlistPath, "CFBundleName", helperName); + setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function buildMacLauncher(electronBinaryPath) { + const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); + const runtimeDir = join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = join( + targetAppBundlePath, + "Contents", + "MacOS", + "Electron", + ); + const iconPath = join(desktopDir, "resources", "icon.icns"); + const metadataPath = join(runtimeDir, "metadata.json"); + + mkdirSync(runtimeDir, { recursive: true }); + + const expectedMetadata = { + launcherVersion: LAUNCHER_VERSION, + sourceAppBundlePath, + sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: statSync(iconPath).mtimeMs, + }; + + const currentMetadata = readJson(metadataPath); + if ( + existsSync(targetBinaryPath) && + currentMetadata && + JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) + ) { + return targetBinaryPath; + } + + rmSync(targetAppBundlePath, { recursive: true, force: true }); + cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); + patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchHelperBundleInfoPlists(targetAppBundlePath); + writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + + return targetBinaryPath; +} + +export function resolveElectronPath() { + const require = createRequire(import.meta.url); + const electronBinaryPath = require("electron"); + + if (process.platform !== "darwin") { + return electronBinaryPath; + } + + return buildMacLauncher(electronBinaryPath); +} diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bf93adb6b0db0c2b5658faa08d5fd9ec3f6ee9e7 --- /dev/null +++ b/apps/desktop/scripts/start-electron.mjs @@ -0,0 +1,20 @@ +import { spawn } from "node:child_process"; + +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; + +const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { + stdio: "inherit", + cwd: desktopDir, + env: childEnv, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a1f7a9bf21349271a570135693c45bae2b90d7e2..792e78811ecf87fb1c5915145ac75ea6d9b7aa73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,7 +5,16 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + type MenuItemConstructorOptions, + protocol, + shell, +} from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; @@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const ROOT_DIR = path.resolve(__dirname, "../../.."); -const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); -const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); +const MENU_ACTION_CHANNEL = "desktop:menu-action"; const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); +const DESKTOP_SCHEME = "t3"; +const ROOT_DIR = path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_USER_MODEL_ID = "com.t3tools.t3code"; let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess | null = null; @@ -30,6 +41,235 @@ let backendWsUrl = ""; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; +let desktopProtocolRegistered = false; + +protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); + +function resolveAppRoot(): string { + if (!app.isPackaged) { + return ROOT_DIR; + } + return app.getAppPath(); +} + +function resolveBackendEntry(): string { + return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); +} + +function resolveBackendCwd(): string { + if (!app.isPackaged) { + return resolveAppRoot(); + } + return os.homedir(); +} + +function resolveDesktopStaticDir(): string | null { + const appRoot = resolveAppRoot(); + const candidates = [ + path.join(appRoot, "apps/server/dist/client"), + path.join(appRoot, "apps/web/dist"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "index.html"))) { + return candidate; + } + } + + return null; +} + +function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); + if (normalizedPath.includes("..")) { + return path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; + const resolvedPath = path.join(staticRoot, requestedPath); + + if (path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = path.join(resolvedPath, "index.html"); + if (fs.existsSync(nestedIndex)) { + return nestedIndex; + } + + return path.join(staticRoot, "index.html"); +} + +function isStaticAssetRequest(requestUrl: string): boolean { + try { + const url = new URL(requestUrl); + return path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +function registerDesktopProtocol(): void { + if (isDevelopment || desktopProtocolRegistered) return; + + const staticRoot = resolveDesktopStaticDir(); + if (!staticRoot) { + throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); + } + + const staticRootResolved = path.resolve(staticRoot); + const staticRootPrefix = `${staticRootResolved}${path.sep}`; + const fallbackIndex = path.join(staticRootResolved, "index.html"); + + protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { + try { + const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url); + + if (!isInRoot || !fs.existsSync(resolvedCandidate)) { + if (isAssetRequest) { + callback({ error: -6 }); + return; + } + callback({ path: fallbackIndex }); + return; + } + + callback({ path: resolvedCandidate }); + } catch { + callback({ path: fallbackIndex }); + } + }); + + desktopProtocolRegistered = true; +} + +function dispatchMenuAction(action: string): void { + const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; + const targetWindow = existingWindow ?? createWindow(); + if (!existingWindow) { + mainWindow = targetWindow; + } + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + if (!targetWindow.isVisible()) { + targetWindow.show(); + } + targetWindow.focus(); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); +} + +function configureApplicationMenu(): void { + const template: MenuItemConstructorOptions[] = []; + + if (process.platform === "darwin") { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(process.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" as const }, + ]), + { role: process.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ); + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +function resolveResourcePath(fileName: string): string | null { + const candidates = [ + path.join(__dirname, "../resources", fileName), + path.join(process.resourcesPath, "resources", fileName), + path.join(process.resourcesPath, fileName), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { + return resolveResourcePath(`icon.${ext}`); +} + +function configureAppIdentity(): void { + app.setName(APP_DISPLAY_NAME); + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + }); + + if (process.platform === "win32") { + app.setAppUserModelId(APP_USER_MODEL_ID); + } + + if (process.platform === "darwin" && app.dock) { + const iconPath = resolveIconPath("png"); + if (iconPath) { + app.dock.setIcon(iconPath); + } + } +} async function reserveLoopbackPort(): Promise { return new Promise((resolve, reject) => { @@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { function startBackend(): void { if (isQuitting || backendProcess) return; - if (!fs.existsSync(BACKEND_ENTRY)) { - scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); + const backendEntry = resolveBackendEntry(); + if (!fs.existsSync(backendEntry)) { + scheduleBackendRestart(`missing server entry at ${backendEntry}`); return; } - const child = spawn(process.execPath, [BACKEND_ENTRY], { - cwd: ROOT_DIR, + const child = spawn(process.execPath, [backendEntry], { + cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { @@ -124,10 +365,10 @@ function stopBackend(): void { backendProcess = null; if (!child) return; - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); setTimeout(() => { - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); } }, 2_000).unref(); @@ -204,6 +445,13 @@ function registerIpcHandlers(): void { }); } +function getIconOption(): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + const iconPath = resolveIconPath(ext); + return iconPath ? { icon: iconPath } : {}; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -212,6 +460,8 @@ function createWindow(): BrowserWindow { minHeight: 620, show: false, autoHideMenuBar: true, + ...getIconOption(), + title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, webPreferences: { @@ -223,6 +473,13 @@ function createWindow(): BrowserWindow { }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(APP_DISPLAY_NAME); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(APP_DISPLAY_NAME); + }); window.once("ready-to-show", () => { window.show(); }); @@ -231,10 +488,7 @@ function createWindow(): BrowserWindow { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { - if (!fs.existsSync(WEB_ENTRY)) { - throw new Error(`Web bundle missing at ${WEB_ENTRY}`); - } - void window.loadFile(WEB_ENTRY); + void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); } window.on("closed", () => { @@ -246,6 +500,8 @@ function createWindow(): BrowserWindow { return window; } +configureAppIdentity(); + async function bootstrap(): Promise { backendPort = await reserveLoopbackPort(); backendAuthToken = randomBytes(24).toString("hex"); @@ -263,6 +519,9 @@ app.on("before-quit", () => { }); app.whenReady().then(() => { + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); void bootstrap(); app.on("activate", () => { @@ -277,3 +536,19 @@ app.on("window-all-closed", () => { app.quit(); } }); + +if (process.platform !== "win32") { + process.on("SIGINT", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); + + process.on("SIGTERM", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index fb90831fc0ded445d5ba9dcd85ef58c9b9c7a4c0..464832b33d307420860bfd9d90a6977d9b9d7247 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const MENU_ACTION_CHANNEL = "desktop:menu-action"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { showContextMenu: (items: { id: string; label: string }[]) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise, + onMenuAction: (listener: (action: string) => void) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { + if (typeof action !== "string") return; + listener(action); + }; + + ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + }; + }, }); diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 4a97814a73080a72532e2204dfc5c306a2be8ee5..afb23d48a768f7c1a52b386659e1e919625682f1 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -43,6 +43,36 @@ function createSendTurnHarness() { return { manager, context, requireSession, sendRequest, updateSession }; } +function createThreadControlHarness() { + const manager = new CodexAppServerManager(); + const context = { + session: { + sessionId: "sess_1", + provider: "codex", + status: "ready", + threadId: "thread_1", + createdAt: "2026-02-10T00:00:00.000Z", + updatedAt: "2026-02-10T00:00:00.000Z", + }, + }; + + const requireSession = vi + .spyOn( + manager as unknown as { requireSession: (sessionId: string) => unknown }, + "requireSession", + ) + .mockReturnValue(context); + const sendRequest = vi.spyOn( + manager as unknown as { sendRequest: (...args: unknown[]) => Promise }, + "sendRequest", + ); + const updateSession = vi + .spyOn(manager as unknown as { updateSession: (...args: unknown[]) => void }, "updateSession") + .mockImplementation(() => {}); + + return { manager, context, requireSession, sendRequest, updateSession }; +} + describe("classifyCodexStderrLine", () => { it("ignores empty lines", () => { expect(classifyCodexStderrLine(" ")).toBeNull(); @@ -113,6 +143,40 @@ describe("isRecoverableThreadResumeError", () => { }); }); +describe("startSession", () => { + it("emits session/startFailed when resolving cwd throws before process launch", async () => { + const manager = new CodexAppServerManager(); + const events: Array<{ method: string; kind: string; message?: string }> = []; + manager.on("event", (event) => { + events.push({ + method: event.method, + kind: event.kind, + ...(event.message ? { message: event.message } : {}), + }); + }); + + const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => { + throw new Error("cwd missing"); + }); + try { + await expect( + manager.startSession({ + provider: "codex", + }), + ).rejects.toThrow("cwd missing"); + expect(events).toHaveLength(1); + expect(events[0]).toEqual({ + method: "session/startFailed", + kind: "error", + message: "cwd missing", + }); + } finally { + processCwd.mockRestore(); + manager.stopAll(); + } + }); +}); + describe("sendTurn", () => { it("sends text and image user input items to turn/start", async () => { const { manager, context, requireSession, sendRequest, updateSession } = @@ -198,3 +262,91 @@ describe("sendTurn", () => { ).rejects.toThrow("Turn input must include text or attachments."); }); }); + +describe("thread checkpoint control", () => { + it("reads thread turns from thread/read", async () => { + const { manager, context, requireSession, sendRequest } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + thread: { + id: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }, + }); + + const result = await manager.readThread("sess_1"); + + expect(requireSession).toHaveBeenCalledWith("sess_1"); + expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { + threadId: "thread_1", + includeTurns: true, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + }); + + it("reads thread turns from flat thread/read responses", async () => { + const { manager, context, sendRequest } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + + const result = await manager.readThread("sess_1"); + + expect(sendRequest).toHaveBeenCalledWith(context, "thread/read", { + threadId: "thread_1", + includeTurns: true, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [ + { + id: "turn_1", + items: [{ type: "userMessage", content: [{ type: "text", text: "hello" }] }], + }, + ], + }); + }); + + it("rolls back turns via thread/rollback and resets session running state", async () => { + const { manager, context, sendRequest, updateSession } = createThreadControlHarness(); + sendRequest.mockResolvedValue({ + thread: { + id: "thread_1", + turns: [], + }, + }); + + const result = await manager.rollbackThread("sess_1", 2); + + expect(sendRequest).toHaveBeenCalledWith(context, "thread/rollback", { + threadId: "thread_1", + numTurns: 2, + }); + expect(updateSession).toHaveBeenCalledWith(context, { + status: "ready", + activeTurnId: undefined, + }); + expect(result).toEqual({ + threadId: "thread_1", + turns: [], + }); + }); +}); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 27da26fb0bfdf8a522ab138746a2513d54a9eab4..912f9b2db867aabd380a17fa2e83ab348dc97dd7 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -65,6 +65,16 @@ interface JsonRpcNotification { params?: unknown; } +export interface CodexThreadTurnSnapshot { + id: string; + items: unknown[]; +} + +export interface CodexThreadSnapshot { + threadId: string; + turns: CodexThreadTurnSnapshot[]; +} + const ANSI_ESCAPE_CHAR = String.fromCharCode(27); const ANSI_ESCAPE_REGEX = new RegExp(`${ANSI_ESCAPE_CHAR}\\[[0-9;]*m`, "g"); const CODEX_STDERR_LOG_REGEX = @@ -138,40 +148,48 @@ export class CodexAppServerManager extends EventEmitter { const sessionId = randomUUID(); const now = new Date().toISOString(); - - const session: ProviderSession = { - sessionId, - provider: "codex", - status: "connecting", - model: normalizeCodexModelSlug(input.model), - cwd: input.cwd, - createdAt: now, - updatedAt: now, - }; - - const child = spawn("codex", ["app-server"], { - cwd: input.cwd, - env: process.env, - stdio: ["pipe", "pipe", "pipe"], - }); - const output = readline.createInterface({ input: child.stdout }); - - const context: CodexSessionContext = { - session, - child, - output, - pending: new Map(), - pendingApprovals: new Map(), - nextRequestId: 1, - stopping: false, - }; - - this.sessions.set(sessionId, context); - this.attachProcessListeners(context); - - this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); + let context: CodexSessionContext | undefined; try { + const resolvedCwd = input.cwd ?? process.cwd(); + + const session: ProviderSession = { + sessionId, + provider: "codex", + status: "connecting", + model: normalizeCodexModelSlug(input.model), + cwd: resolvedCwd, + createdAt: now, + updatedAt: now, + }; + + const codexBinaryPath = input.codexBinaryPath?.trim() || "codex"; + const codexHomePath = input.codexHomePath?.trim(); + const child = spawn(codexBinaryPath, ["app-server"], { + cwd: resolvedCwd, + env: { + ...process.env, + ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), + }, + stdio: ["pipe", "pipe", "pipe"], + }); + const output = readline.createInterface({ input: child.stdout }); + + context = { + session, + child, + output, + pending: new Map(), + pendingApprovals: new Map(), + nextRequestId: 1, + stopping: false, + }; + + this.sessions.set(sessionId, context); + this.attachProcessListeners(context); + + this.emitLifecycleEvent(context, "session/connecting", "Starting codex app-server"); + await this.sendRequest(context, "initialize", { clientInfo: { name: "t3code_desktop", @@ -241,12 +259,24 @@ export class CodexAppServerManager extends EventEmitter { + const context = this.requireSession(sessionId); + const threadId = context.session.threadId; + if (!threadId) { + throw new Error("Session is missing a thread id."); + } + + const response = await this.sendRequest(context, "thread/read", { + threadId, + includeTurns: true, + }); + return this.parseThreadSnapshot("thread/read", response); + } + + async rollbackThread(sessionId: string, numTurns: number): Promise { + const context = this.requireSession(sessionId); + const threadId = context.session.threadId; + if (!threadId) { + throw new Error("Session is missing a thread id."); + } + if (!Number.isInteger(numTurns) || numTurns < 1) { + throw new Error("numTurns must be an integer >= 1."); + } + + const response = await this.sendRequest(context, "thread/rollback", { + threadId, + numTurns, + }); + this.updateSession(context, { + status: "ready", + activeTurnId: undefined, + }); + return this.parseThreadSnapshot("thread/rollback", response); + } + async respondToRequest( sessionId: string, requestId: string, @@ -742,6 +807,31 @@ export class CodexAppServerManager extends EventEmitter { + const turn = this.readObject(turnValue); + const turnId = this.readString(turn, "id") ?? `${threadId}:turn:${index + 1}`; + const items = this.readArray(turn, "items") ?? []; + return { + id: turnId, + items, + }; + }); + + return { + threadId, + turns, + }; + } + private isServerRequest(value: unknown): value is JsonRpcRequest { if (!value || typeof value !== "object") { return false; @@ -823,6 +913,16 @@ export class CodexAppServerManager extends EventEmitter; } + private readArray(value: unknown, key?: string): unknown[] | undefined { + const target = + key === undefined + ? value + : value && typeof value === "object" + ? (value as Record)[key] + : undefined; + return Array.isArray(target) ? target : undefined; + } + private readString(value: unknown, key: string): string | undefined { if (!value || typeof value !== "object") { return undefined; diff --git a/apps/server/src/filesystemCheckpointStore.test.ts b/apps/server/src/filesystemCheckpointStore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e78ebdf8568ada263783cfa57fab74dd207d1bd --- /dev/null +++ b/apps/server/src/filesystemCheckpointStore.test.ts @@ -0,0 +1,243 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { checkpointRefForThreadTurn, FilesystemCheckpointStore } from "./filesystemCheckpointStore"; +import { runProcess } from "./processRunner"; + +async function runGit(cwd: string, args: readonly string[]): Promise { + const result = await runProcess("git", args, { + cwd, + allowNonZeroExit: true, + }); + if (result.code !== 0) { + throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`); + } + return result.stdout.trim(); +} + +function exists(filePath: string): boolean { + return fs.existsSync(filePath); +} + +async function createRepo(): Promise { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-test-")); + await runGit(cwd, ["init", "--initial-branch=main"]); + await runGit(cwd, ["config", "user.email", "test@example.com"]); + await runGit(cwd, ["config", "user.name", "Test User"]); + await writeFile(path.join(cwd, "README.md"), "v1\n"); + await writeFile(path.join(cwd, "tracked.txt"), "tracked-v1\n"); + await runGit(cwd, ["add", "."]); + await runGit(cwd, ["commit", "-m", "Initial"]); + return cwd; +} + +async function createRepoWithoutHead(): Promise { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-empty-test-")); + await runGit(cwd, ["init", "--initial-branch=main"]); + return cwd; +} + +describe("FilesystemCheckpointStore", () => { + it("captures refs without mutating branch history and restores workspace state", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_1"; + + try { + const headBefore = await runGit(cwd, ["rev-parse", "HEAD"]); + + await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); + const rootRef = checkpointRefForThreadTurn(threadId, 0); + const rootCommit = await runGit(cwd, ["rev-parse", "--verify", `${rootRef}^{commit}`]); + expect(rootCommit.length).toBeGreaterThan(0); + expect(await runGit(cwd, ["rev-parse", "HEAD"])).toBe(headBefore); + + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await rm(path.join(cwd, "tracked.txt")); + await writeFile(path.join(cwd, "notes.md"), "notes\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + await writeFile(path.join(cwd, "README.md"), "v3\n"); + await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); + await writeFile(path.join(cwd, "tracked.txt"), "tracked-new\n"); + + const restoredRoot = await store.restoreCheckpoint({ cwd, threadId, turnCount: 0 }); + expect(restoredRoot).toBe(true); + expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); + expect(await readFile(path.join(cwd, "tracked.txt"), "utf8")).toBe("tracked-v1\n"); + expect(exists(path.join(cwd, "notes.md"))).toBe(false); + expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); + + const restoredTurn1 = await store.restoreCheckpoint({ cwd, threadId, turnCount: 1 }); + expect(restoredTurn1).toBe(true); + expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v2\n"); + expect(exists(path.join(cwd, "tracked.txt"))).toBe(false); + expect(await readFile(path.join(cwd, "notes.md"), "utf8")).toBe("notes\n"); + expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("returns false when restoring a missing checkpoint", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + + try { + const restored = await store.restoreCheckpoint({ + cwd, + threadId: "thr_2", + turnCount: 999, + }); + expect(restored).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("falls back to HEAD when restoring turn 0 and root checkpoint ref is missing", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_2b"; + + try { + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + await writeFile(path.join(cwd, "README.md"), "v3\n"); + await writeFile(path.join(cwd, "scratch.txt"), "scratch\n"); + + const restored = await store.restoreCheckpoint({ + cwd, + threadId, + turnCount: 0, + }); + + expect(restored).toBe(true); + expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("v1\n"); + expect(exists(path.join(cwd, "scratch.txt"))).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("prunes refs newer than the retained turn", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_3"; + + try { + await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); + await writeFile(path.join(cwd, "README.md"), "turn1\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + await writeFile(path.join(cwd, "README.md"), "turn2\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 2 }); + + await store.pruneAfterTurn({ + cwd, + threadId, + maxTurnCount: 1, + }); + + expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 0 })).toBe(true); + expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 1 })).toBe(true); + expect(await store.hasCheckpoint({ cwd, threadId, turnCount: 2 })).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("returns a unified patch between checkpoint turns", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_4"; + + try { + await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await writeFile(path.join(cwd, "new.txt"), "new file\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + const diff = await store.diffCheckpoints({ + cwd, + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain("diff --git a/new.txt b/new.txt"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("falls back to HEAD as diff base when turn 0 checkpoint is missing", async () => { + const cwd = await createRepo(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr_5"; + + try { + await writeFile(path.join(cwd, "README.md"), "v2\n"); + await writeFile(path.join(cwd, "new.txt"), "new file\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 1 }); + + const diff = await store.diffCheckpoints({ + cwd, + threadId, + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(diff).toContain("diff --git a/README.md b/README.md"); + expect(diff).toContain("diff --git a/new.txt b/new.txt"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("detects non-repo folders", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoints-nonrepo-")); + const store = new FilesystemCheckpointStore(); + + try { + expect(await store.isGitRepository(cwd)).toBe(false); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it("returns false when repository path does not exist", async () => { + const store = new FilesystemCheckpointStore(); + const cwd = path.join(os.tmpdir(), `t3-fs-checkpoints-missing-${Date.now()}`); + + expect(await store.isGitRepository(cwd)).toBe(false); + }); + + it("restores checkpoint without reset when repository has no HEAD commit", async () => { + const cwd = await createRepoWithoutHead(); + const store = new FilesystemCheckpointStore(); + const threadId = "thr-no-head"; + + try { + await writeFile(path.join(cwd, "README.md"), "first\n"); + await store.captureCheckpoint({ cwd, threadId, turnCount: 0 }); + + await writeFile(path.join(cwd, "README.md"), "second\n"); + const restored = await store.restoreCheckpoint({ + cwd, + threadId, + turnCount: 0, + }); + + expect(restored).toBe(true); + expect(await readFile(path.join(cwd, "README.md"), "utf8")).toBe("first\n"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/filesystemCheckpointStore.ts b/apps/server/src/filesystemCheckpointStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..41ea5e3c27d5be19c8add20e4ee847fd7ffaf0dc --- /dev/null +++ b/apps/server/src/filesystemCheckpointStore.ts @@ -0,0 +1,322 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; + +import { runProcess } from "./processRunner"; + +const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints"; +const DEFAULT_TIMEOUT_MS = 30_000; +const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; + +function quoteGitCommand(args: readonly string[]): string { + return `git ${args.join(" ")}`; +} + +function checkpointRefThreadSegment(threadId: string): string { + return Buffer.from(threadId, "utf8").toString("base64url"); +} + +export function checkpointRefForThreadTurn(threadId: string, turnCount: number): string { + if (!Number.isInteger(turnCount) || turnCount < 0) { + throw new Error(`Invalid checkpoint turn count: ${turnCount}`); + } + return `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/${turnCount}`; +} + +interface GitOptions { + env?: NodeJS.ProcessEnv; + stdin?: string; + allowNonZeroExit?: boolean; +} + +export class FilesystemCheckpointStore { + async isGitRepository(cwd: string): Promise { + try { + const result = await this.runGit(cwd, ["rev-parse", "--is-inside-work-tree"], { + allowNonZeroExit: true, + }); + return result.code === 0 && result.stdout.trim() === "true"; + } catch { + return false; + } + } + + async captureCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { + const { cwd, threadId, turnCount } = input; + const ref = checkpointRefForThreadTurn(threadId, turnCount); + + const tempDir = await mkdtemp(path.join(os.tmpdir(), "t3-fs-checkpoint-")); + + try { + const tempIndexPath = path.join(tempDir, `index-${randomUUID()}`); + const commitEnv: NodeJS.ProcessEnv = { + ...process.env, + GIT_INDEX_FILE: tempIndexPath, + GIT_AUTHOR_NAME: "T3 Code", + GIT_AUTHOR_EMAIL: "codex@users.noreply.github.com", + GIT_COMMITTER_NAME: "T3 Code", + GIT_COMMITTER_EMAIL: "codex@users.noreply.github.com", + }; + + const hasHead = await this.hasHeadCommit(cwd); + if (hasHead) { + await this.runGit(cwd, ["read-tree", "HEAD"], { env: commitEnv }); + } + + await this.runGit(cwd, ["add", "-A", "--", "."], { env: commitEnv }); + const writeTreeResult = await this.runGit(cwd, ["write-tree"], { env: commitEnv }); + const treeOid = writeTreeResult.stdout.trim(); + if (!treeOid) { + throw new Error("git write-tree returned an empty tree oid."); + } + + const message = `t3 checkpoint thread=${threadId} turn=${turnCount}`; + const commitTreeResult = await this.runGit(cwd, ["commit-tree", treeOid], { + env: commitEnv, + stdin: `${message}\n`, + }); + const commitOid = commitTreeResult.stdout.trim(); + if (!commitOid) { + throw new Error("git commit-tree returned an empty commit oid."); + } + + await this.runGit(cwd, ["update-ref", ref, commitOid]); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + } + + async hasCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { + const { cwd, threadId, turnCount } = input; + const ref = checkpointRefForThreadTurn(threadId, turnCount); + const commit = await this.resolveCheckpointCommit(cwd, ref); + return commit !== null; + } + + async ensureRootCheckpoint(input: { cwd: string; threadId: string }): Promise { + const { cwd, threadId } = input; + const rootRef = checkpointRefForThreadTurn(threadId, 0); + const existing = await this.resolveCheckpointCommit(cwd, rootRef); + if (existing) { + return true; + } + + const headCommit = await this.resolveHeadCommit(cwd); + if (!headCommit) { + return false; + } + + await this.runGit(cwd, ["update-ref", rootRef, headCommit]); + return true; + } + + async restoreCheckpoint(input: { + cwd: string; + threadId: string; + turnCount: number; + }): Promise { + const { cwd, threadId, turnCount } = input; + const ref = checkpointRefForThreadTurn(threadId, turnCount); + let commitOid = await this.resolveCheckpointCommit(cwd, ref); + if (!commitOid && turnCount === 0) { + commitOid = await this.resolveHeadCommit(cwd); + } + if (!commitOid) { + return false; + } + + await this.runGit(cwd, ["restore", "--source", commitOid, "--worktree", "--staged", "--", "."]); + await this.runGit(cwd, ["clean", "-fd", "--", "."]); + if (await this.hasHeadCommit(cwd)) { + await this.runGit(cwd, ["reset", "--quiet", "--", "."]); + } + return true; + } + + async diffCheckpoints(input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }): Promise { + const { cwd, threadId, fromTurnCount, toTurnCount } = input; + if (!Number.isInteger(fromTurnCount) || fromTurnCount < 0) { + throw new Error(`Invalid from turn count: ${fromTurnCount}`); + } + if (!Number.isInteger(toTurnCount) || toTurnCount < 0) { + throw new Error(`Invalid to turn count: ${toTurnCount}`); + } + if (fromTurnCount > toTurnCount) { + throw new Error(`Invalid checkpoint range: ${fromTurnCount}..${toTurnCount}`); + } + + const fromRef = checkpointRefForThreadTurn(threadId, fromTurnCount); + const toRef = checkpointRefForThreadTurn(threadId, toTurnCount); + let fromCommitOid = await this.resolveCheckpointCommit(cwd, fromRef); + const toCommitOid = await this.resolveCheckpointCommit(cwd, toRef); + if (!fromCommitOid && fromTurnCount === 0) { + const headCommit = await this.resolveHeadCommit(cwd); + if (headCommit) { + fromCommitOid = headCommit; + } + } + if (!fromCommitOid) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${fromTurnCount} in thread ${threadId}.`, + ); + } + if (!toCommitOid) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${toTurnCount} in thread ${threadId}.`, + ); + } + + const result = await this.runGit(cwd, [ + "diff", + "--patch", + "--minimal", + "--no-color", + fromCommitOid, + toCommitOid, + ]); + return result.stdout; + } + + private async resolveHeadCommit(cwd: string): Promise { + const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], { + allowNonZeroExit: true, + }); + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + } + + async pruneAfterTurn(input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }): Promise { + const { cwd, threadId, maxTurnCount } = input; + if (!Number.isInteger(maxTurnCount) || maxTurnCount < 0) { + throw new Error(`Invalid max turn count: ${maxTurnCount}`); + } + + const threadRefPrefix = `${CHECKPOINT_REFS_PREFIX}/${checkpointRefThreadSegment(threadId)}/turn/`; + const result = await this.runGit( + cwd, + ["for-each-ref", "--format=%(refname)", threadRefPrefix], + { + allowNonZeroExit: true, + }, + ); + if (result.code !== 0) { + return; + } + + const refsToDelete = result.stdout + .split(/\r?\n/g) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .filter((refName) => { + const turn = this.readRefTurnCount(refName); + return turn !== null && turn > maxTurnCount; + }); + + const batchSize = 32; + const batches = Array.from( + { length: Math.ceil(refsToDelete.length / batchSize) }, + (_, index) => refsToDelete.slice(index * batchSize, (index + 1) * batchSize), + ); + await batches.reduce>((pending, batch) => { + return pending.then(async () => { + await Promise.all( + batch.map((refName) => + this.runGit(cwd, ["update-ref", "-d", refName], { allowNonZeroExit: true }), + ), + ); + }); + }, Promise.resolve()); + } + + private async hasHeadCommit(cwd: string): Promise { + const result = await this.runGit(cwd, ["rev-parse", "--verify", "HEAD"], { + allowNonZeroExit: true, + }); + return result.code === 0; + } + + private async resolveCheckpointCommit(cwd: string, ref: string): Promise { + const result = await this.runGit(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + allowNonZeroExit: true, + }); + if (result.code !== 0) { + return null; + } + const commit = result.stdout.trim(); + return commit.length > 0 ? commit : null; + } + + private readRefTurnCount(ref: string): number | null { + const segments = ref.split("/"); + const rawTurn = segments.at(-1); + if (!rawTurn) { + return null; + } + const turn = Number.parseInt(rawTurn, 10); + return Number.isInteger(turn) && turn >= 0 ? turn : null; + } + + private async runGit( + cwd: string, + args: readonly string[], + options: GitOptions = {}, + ): Promise>> { + let result: Awaited>; + try { + result = await runProcess("git", args, { + cwd, + env: options.env, + stdin: options.stdin, + timeoutMs: DEFAULT_TIMEOUT_MS, + allowNonZeroExit: true, + maxBufferBytes: DEFAULT_MAX_OUTPUT_BYTES, + outputMode: "truncate", + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to run ${quoteGitCommand(args)}: ${message}`, { cause: error }); + } + + if (result.timedOut) { + throw new Error(`${quoteGitCommand(args)} timed out.`); + } + + if (result.stdoutTruncated || result.stderrTruncated) { + throw new Error( + `${quoteGitCommand(args)} output exceeded ${DEFAULT_MAX_OUTPUT_BYTES} bytes and was truncated.`, + ); + } + + if (!options.allowNonZeroExit && result.code !== 0) { + const stderr = result.stderr.trim(); + throw new Error( + stderr.length > 0 + ? `${quoteGitCommand(args)} failed: ${stderr}` + : `${quoteGitCommand(args)} failed with code ${result.code ?? "null"}.`, + ); + } + + return result; + } +} diff --git a/apps/server/src/git.test.ts b/apps/server/src/git.test.ts index 515a763cf727dc19df843e4986c7dc8cdc969608..1e01c6d2623ede654bd0cc3c8de2ed234b1c21fe 100644 --- a/apps/server/src/git.test.ts +++ b/apps/server/src/git.test.ts @@ -617,6 +617,32 @@ describe("git integration", () => { await removeGitWorktree({ cwd: tmp.path, path: wtPath }); expect(existsSync(wtPath)).toBe(false); }); + + it("removeGitWorktree force removes a dirty worktree", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + + const wtPath = path.join(tmp.path, "wt-dirty-dir"); + const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( + (b) => b.current, + )!.name; + + await createGitWorktree({ + cwd: tmp.path, + branch: currentBranch, + newBranch: "wt-dirty", + path: wtPath, + }); + expect(existsSync(wtPath)).toBe(true); + + await writeFile(path.join(wtPath, "README.md"), "dirty change\n"); + + await expect(removeGitWorktree({ cwd: tmp.path, path: wtPath })).rejects.toThrow(); + expect(existsSync(wtPath)).toBe(true); + + await removeGitWorktree({ cwd: tmp.path, path: wtPath, force: true }); + expect(existsSync(wtPath)).toBe(false); + }); }); // ── Full flow: local branch checkout ── @@ -760,6 +786,53 @@ describe("git integration", () => { expect(dirty.hasWorkingTreeChanges).toBe(true); }); + it("includes command context when worktree removal fails", async () => { + await using tmp = await makeTmpDir(); + await initRepoWithCommit(tmp.path); + const core = new GitCoreService(); + const missingWorktreePath = path.join(tmp.path, "missing-worktree"); + + const error = await core + .removeWorktree({ cwd: tmp.path, path: missingWorktreePath }) + .then(() => null) + .catch((err: unknown) => err); + + expect(error).toBeInstanceOf(Error); + const message = (error as Error).message; + expect(message).toContain("git worktree remove"); + expect(message).toContain(`cwd: ${tmp.path}`); + expect(message).toContain(missingWorktreePath); + }); + + it("refreshes upstream before statusDetails so behind count reflects remote updates", async () => { + await using remote = await makeTmpDir(); + await using source = await makeTmpDir(); + await using clone = await makeTmpDir(); + await git(remote.path, "init --bare"); + + await initRepoWithCommit(source.path); + const initialBranch = (await listGitBranches({ cwd: source.path })).branches.find( + (branch) => branch.current, + )!.name; + await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); + await git(source.path, `push -u origin ${initialBranch}`); + + await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); + await git(clone.path, "config user.email 'test@test.com'"); + await git(clone.path, "config user.name 'Test'"); + await git(clone.path, `checkout -B ${initialBranch} --track origin/${initialBranch}`); + await writeFile(path.join(clone.path, "CHANGELOG.md"), "remote change\n"); + await git(clone.path, "add CHANGELOG.md"); + await git(clone.path, "commit -m 'remote update'"); + await git(clone.path, `push origin ${initialBranch}`); + + const core = new GitCoreService(); + const details = await core.statusDetails(source.path); + expect(details.branch).toBe(initialBranch); + expect(details.aheadCount).toBe(0); + expect(details.behindCount).toBe(1); + }); + it("prepares commit context by auto-staging and creates commit", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); diff --git a/apps/server/src/git.ts b/apps/server/src/git.ts index 7c2da5ccf5a306c89f327089ae344b3d6cca82f8..18d3ef92f0b547f9b82176ab6cc3de89cb1bfb8a 100644 --- a/apps/server/src/git.ts +++ b/apps/server/src/git.ts @@ -65,6 +65,8 @@ interface ExecuteGitOptions { } const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; +const STATUS_UPSTREAM_REFRESH_INTERVAL_MS = 15_000; +const STATUS_UPSTREAM_REFRESH_TIMEOUT_MS = 5_000; /** Spawn git directly with an argv array — no shell, no quoting needed. */ function runGit( @@ -216,6 +218,8 @@ async function executeGit( } export class GitCoreService { + private readonly lastUpstreamRefreshAt = new Map(); + async status(raw: GitStatusInput): Promise { const input = gitStatusInputSchema.parse(raw); const details = await this.statusDetails(input.cwd); @@ -231,6 +235,8 @@ export class GitCoreService { } async statusDetails(cwd: string): Promise { + await this.refreshStatusUpstreamIfStale(cwd).catch(() => undefined); + const [statusStdout, unstagedNumstatStdout, stagedNumstatStdout] = await Promise.all([ this.gitStdout(cwd, ["status", "--porcelain=2", "--branch"]), this.gitStdout(cwd, ["diff", "--numstat"]), @@ -540,10 +546,22 @@ export class GitCoreService { } async removeWorktree(input: GitRemoveWorktreeInput): Promise { - await executeGit(input.cwd, ["worktree", "remove", input.path], { - timeoutMs: 15_000, - fallbackErrorMessage: "git worktree remove failed", - }); + const args = ["worktree", "remove"]; + if (input.force) { + args.push("--force"); + } + args.push(input.path); + try { + await executeGit(input.cwd, args, { + timeoutMs: 15_000, + fallbackErrorMessage: "git worktree remove failed", + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`${commandLabel(args)} failed (cwd: ${input.cwd}): ${detail}`, { + cause: error, + }); + } } async createBranch(input: GitCreateBranchInput): Promise { @@ -553,7 +571,9 @@ export class GitCoreService { }); } - private async refreshCheckedOutBranchUpstream(cwd: string): Promise { + private async resolveCurrentUpstream( + cwd: string, + ): Promise<{ upstreamRef: string; remoteName: string; upstreamBranch: string } | null> { const upstreamRef = trimStdout( await this.gitStdout( cwd, @@ -562,24 +582,72 @@ export class GitCoreService { ), ); if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { - return; + return null; } const separatorIndex = upstreamRef.indexOf("/"); if (separatorIndex <= 0) { - return; + return null; } const remoteName = upstreamRef.slice(0, separatorIndex); const upstreamBranch = upstreamRef.slice(separatorIndex + 1); if (remoteName.length === 0) { - return; + return null; } if (upstreamBranch.length === 0) { + return null; + } + + return { + upstreamRef, + remoteName, + upstreamBranch, + }; + } + + private async fetchUpstreamRef( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Promise { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + await this.git(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], true); + } + + private async fetchUpstreamRefForStatus( + cwd: string, + upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string }, + ): Promise { + const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`; + await executeGit(cwd, ["fetch", "--quiet", "--no-tags", upstream.remoteName, refspec], { + allowNonZeroExit: true, + timeoutMs: STATUS_UPSTREAM_REFRESH_TIMEOUT_MS, + }); + } + + private async refreshStatusUpstreamIfStale(cwd: string): Promise { + const upstream = await this.resolveCurrentUpstream(cwd); + if (!upstream) { return; } - const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`; - await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true); + const cacheKey = `${cwd}\u0000${upstream.upstreamRef}`; + const now = Date.now(); + const lastRefreshAt = this.lastUpstreamRefreshAt.get(cacheKey) ?? 0; + if (now - lastRefreshAt < STATUS_UPSTREAM_REFRESH_INTERVAL_MS) { + return; + } + + // Record attempt time first to avoid repeated fetch spikes when status is polled often. + this.lastUpstreamRefreshAt.set(cacheKey, now); + await this.fetchUpstreamRefForStatus(cwd, upstream); + } + + private async refreshCheckedOutBranchUpstream(cwd: string): Promise { + const upstream = await this.resolveCurrentUpstream(cwd); + if (!upstream) { + return; + } + await this.fetchUpstreamRef(cwd, upstream); } async checkoutBranch(input: GitCheckoutInput): Promise { diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d761588e93872b3fa084d7cefde627f87a3728b5..73a8ce667e4486893daf8cf9952eb2ebc167f73c 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -1,8 +1,34 @@ -import { assert, describe, it } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { assert, afterEach, describe, expect, it, vi } from "vitest"; -import { compileResolvedKeybindingRule, parseKeybindingShortcut } from "./keybindings"; +import { + compileResolvedKeybindingRule, + parseKeybindingShortcut, + upsertKeybindingRule, +} from "./keybindings"; describe("server keybindings", () => { + const tempDirs: string[] = []; + const logger = { + warn: vi.fn(), + }; + + function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; + } + + afterEach(() => { + vi.restoreAllMocks(); + logger.warn.mockReset(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it("parses shortcuts including plus key", () => { assert.deepEqual(parseKeybindingShortcut("mod+j"), { key: "j", @@ -65,5 +91,135 @@ describe("server keybindings", () => { when: "terminalFocus && (", }), ); + + assert.isNull( + compileResolvedKeybindingRule({ + key: "mod+d", + command: "terminal.split", + when: `${"!".repeat(300)}terminalFocus`, + }), + ); + }); + + it("upserts custom keybindings to ~/.t3/keybindings.json", () => { + const fakeHome = makeTempDir("t3code-keybindings-upsert-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + configPath, + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + const resolved = upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + + expect(persisted).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + expect(resolved.some((entry) => entry.command === "script.run-tests.run")).toBe(true); + }); + + it("replaces existing custom keybinding for the same command", () => { + const fakeHome = makeTempDir("t3code-keybindings-upsert-replace-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + configPath, + JSON.stringify([{ key: "mod+r", command: "script.run-tests.run" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + + const persisted = JSON.parse(fs.readFileSync(configPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + expect(persisted).toEqual([{ key: "mod+shift+r", command: "script.run-tests.run" }]); + }); + + it("refuses to overwrite malformed keybindings config", () => { + const fakeHome = makeTempDir("t3code-keybindings-malformed-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync(configPath, "{ not-json", "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + expect(() => + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }), + ).toThrow(/Unable to parse keybindings config/); + + expect(fs.readFileSync(configPath, "utf8")).toBe("{ not-json"); + }); + + it("reports non-array config parse errors without duplicate prefix", () => { + const fakeHome = makeTempDir("t3code-keybindings-non-array-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync(configPath, JSON.stringify({ key: "mod+j", command: "terminal.toggle" }), "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + expect(() => + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }), + ).toThrow(new RegExp(`Unable to parse keybindings config at ${configPath}: expected JSON array`)); + + expect(() => + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }), + ).not.toThrow( + new RegExp(`Unable to parse keybindings config at ${configPath}: Unable to parse keybindings config at`), + ); + }); + + it("cleans up temp files when atomic keybinding write fails", () => { + const fakeHome = makeTempDir("t3code-keybindings-atomic-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const configPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync(configPath, JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), "utf8"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + vi.spyOn(fs, "renameSync").mockImplementation(() => { + throw new Error("rename failed"); + }); + + expect(() => + upsertKeybindingRule(logger, { + key: "mod+shift+r", + command: "script.run-tests.run", + }), + ).toThrow(/rename failed/); + + expect(fs.readFileSync(configPath, "utf8")).toBe( + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + ); + const tempFiles = fs + .readdirSync(configDir) + .filter((file) => file.includes("keybindings.json.") && file.endsWith(".tmp")); + expect(tempFiles).toEqual([]); }); }); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 97e2de438ff79666a16cdbf60844a2fbbbaabd97..dd5e401e9e756e060ff554b943efd0565f0f6a30 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -24,6 +24,8 @@ type WhenToken = | { type: "lparen" } | { type: "rparen" }; +const MAX_WHEN_EXPRESSION_DEPTH = 256; + export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+j", command: "terminal.toggle" }, { key: "mod+d", command: "terminal.split", when: "terminalFocus" }, @@ -159,7 +161,10 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe if (!tokens || tokens.length === 0) return null; let index = 0; - const parsePrimary = (): KeybindingWhenNode | null => { + const parsePrimary = (depth: number): KeybindingWhenNode | null => { + if (depth > MAX_WHEN_EXPRESSION_DEPTH) { + return null; + } const token = tokens[index]; if (!token) return null; @@ -170,7 +175,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe if (token.type === "lparen") { index += 1; - const expressionNode = parseOr(); + const expressionNode = parseOr(depth + 1); const closeToken = tokens[index]; if (!expressionNode || !closeToken || closeToken.type !== "rparen") { return null; @@ -182,24 +187,34 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return null; }; - const parseUnary = (): KeybindingWhenNode | null => { - const token = tokens[index]; - if (token?.type === "not") { + const parseUnary = (depth: number): KeybindingWhenNode | null => { + let notCount = 0; + while (tokens[index]?.type === "not") { index += 1; - const node = parseUnary(); - if (!node) return null; - return { type: "not", node }; + notCount += 1; + if (notCount > MAX_WHEN_EXPRESSION_DEPTH) { + return null; + } } - return parsePrimary(); + + let node = parsePrimary(depth); + if (!node) return null; + + while (notCount > 0) { + node = { type: "not", node }; + notCount -= 1; + } + + return node; }; - const parseAnd = (): KeybindingWhenNode | null => { - let left = parseUnary(); + const parseAnd = (depth: number): KeybindingWhenNode | null => { + let left = parseUnary(depth); if (!left) return null; while (tokens[index]?.type === "and") { index += 1; - const right = parseUnary(); + const right = parseUnary(depth); if (!right) return null; left = { type: "and", left, right }; } @@ -207,13 +222,13 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return left; }; - const parseOr = (): KeybindingWhenNode | null => { - let left = parseAnd(); + const parseOr = (depth: number): KeybindingWhenNode | null => { + let left = parseAnd(depth); if (!left) return null; while (tokens[index]?.type === "or") { index += 1; - const right = parseAnd(); + const right = parseAnd(depth); if (!right) return null; left = { type: "or", left, right }; } @@ -221,7 +236,7 @@ export function parseKeybindingWhenExpression(expression: string): KeybindingWhe return left; }; - const ast = parseOr(); + const ast = parseOr(0); if (!ast || index !== tokens.length) return null; return ast; } @@ -274,51 +289,49 @@ function compileDefaultKeybindings(): ResolvedKeybindingsConfig { } const DEFAULT_RESOLVED_KEYBINDINGS = compileDefaultKeybindings(); +const KEYBINDINGS_CONFIG_PATH = path.join(".t3", "keybindings.json"); +const MAX_KEYBINDINGS = 256; -function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { - if (custom.length === 0) { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; - } - - const overriddenCommands = new Set(custom.map((binding) => binding.command)); - const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( - (binding) => !overriddenCommands.has(binding.command), - ); - const merged = [...retainedDefaults, ...custom]; - - if (merged.length <= 256) { - return merged; +class KeybindingsConfigParseError extends Error { + constructor(configPath: string, detail: string, options?: { cause?: unknown }) { + super(`Unable to parse keybindings config at ${configPath}: ${detail}`, options); } +} - // Keep the latest rules when the config exceeds max size; later rules have higher precedence. - return merged.slice(-256); +function resolveKeybindingsConfigPath(): string { + return path.join(os.homedir(), KEYBINDINGS_CONFIG_PATH); } -export function loadResolvedKeybindingsConfig( +function loadCustomKeybindingsConfig( logger: KeybindingsLogger, -): ResolvedKeybindingsConfig { - const configPath = path.join(os.homedir(), ".t3", "keybindings.json"); + options?: { + throwOnUnreadableConfig?: boolean; + }, +): KeybindingsConfig { + const configPath = resolveKeybindingsConfigPath(); try { const raw = fs.readFileSync(configPath, "utf8"); const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) { + if (options?.throwOnUnreadableConfig) { + throw new KeybindingsConfigParseError(configPath, "expected JSON array"); + } logger.warn("ignoring keybindings config with unsupported format; expected array", { path: configPath, }); - return [...DEFAULT_RESOLVED_KEYBINDINGS]; + return []; } - const sanitized: ResolvedKeybindingsConfig = []; + const sanitized: KeybindingsConfig = []; let invalidEntries = 0; for (const entry of parsed) { const result = keybindingRuleSchema.safeParse(entry); if (result.success) { - const compiled = compileResolvedKeybindingRule(result.data); - if (!compiled) { + if (!compileResolvedKeybindingRule(result.data)) { invalidEntries += 1; continue; } - sanitized.push(compiled); + sanitized.push(result.data); continue; } invalidEntries += 1; @@ -330,26 +343,105 @@ export function loadResolvedKeybindingsConfig( totalEntries: parsed.length, }); } - const overriddenCommands = new Set(sanitized.map((entry) => entry.command)); - const mergedBeforeCapLength = - DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) - .length + sanitized.length; - const merged = mergeWithDefaultKeybindings(sanitized); - if (mergedBeforeCapLength > 256) { - logger.warn("truncating merged keybindings config to max entries", { - path: configPath, - maxEntries: 256, - }); - } - return merged; + return sanitized; } catch (error) { if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return [...DEFAULT_RESOLVED_KEYBINDINGS]; + return []; + } + if (options?.throwOnUnreadableConfig) { + if (error instanceof KeybindingsConfigParseError) { + throw error; + } + throw new KeybindingsConfigParseError( + configPath, + error instanceof Error ? error.message : String(error), + { cause: error }, + ); } logger.warn("ignoring malformed keybindings config", { path: configPath, error: error instanceof Error ? error.message : String(error), }); + return []; } - return [...DEFAULT_RESOLVED_KEYBINDINGS]; +} + +function writeConfigAtomically(configPath: string, config: KeybindingsConfig): void { + const tempPath = `${configPath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(tempPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); + fs.renameSync(tempPath, configPath); + } catch (error) { + fs.rmSync(tempPath, { force: true }); + throw error; + } +} + +function mergeWithDefaultKeybindings(custom: ResolvedKeybindingsConfig): ResolvedKeybindingsConfig { + if (custom.length === 0) { + return [...DEFAULT_RESOLVED_KEYBINDINGS]; + } + + const overriddenCommands = new Set(custom.map((binding) => binding.command)); + const retainedDefaults = DEFAULT_RESOLVED_KEYBINDINGS.filter( + (binding) => !overriddenCommands.has(binding.command), + ); + const merged = [...retainedDefaults, ...custom]; + + if (merged.length <= MAX_KEYBINDINGS) { + return merged; + } + + // Keep the latest rules when the config exceeds max size; later rules have higher precedence. + return merged.slice(-MAX_KEYBINDINGS); +} + +export function loadResolvedKeybindingsConfig( + logger: KeybindingsLogger, +): ResolvedKeybindingsConfig { + const customConfig = loadCustomKeybindingsConfig(logger); + const compiledCustomConfig = compileResolvedKeybindingsConfig(customConfig); + const overriddenCommands = new Set(compiledCustomConfig.map((entry) => entry.command)); + const mergedBeforeCapLength = + DEFAULT_RESOLVED_KEYBINDINGS.filter((binding) => !overriddenCommands.has(binding.command)) + .length + compiledCustomConfig.length; + const merged = mergeWithDefaultKeybindings(compiledCustomConfig); + if (mergedBeforeCapLength > MAX_KEYBINDINGS) { + logger.warn("truncating merged keybindings config to max entries", { + path: resolveKeybindingsConfigPath(), + maxEntries: MAX_KEYBINDINGS, + }); + } + return merged; +} + +export function upsertKeybindingRule( + logger: KeybindingsLogger, + rawRule: unknown, +): ResolvedKeybindingsConfig { + const rule = keybindingRuleSchema.parse(rawRule); + if (!compileResolvedKeybindingRule(rule)) { + throw new Error(`Invalid keybinding shortcut: "${rule.key}"`); + } + + const configPath = resolveKeybindingsConfigPath(); + const customConfig = loadCustomKeybindingsConfig(logger, { + throwOnUnreadableConfig: true, + }); + const nextConfig = [...customConfig.filter((entry) => entry.command !== rule.command), rule]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS ? nextConfig.slice(-MAX_KEYBINDINGS) : nextConfig; + + if (nextConfig.length > MAX_KEYBINDINGS) { + logger.warn("truncating keybindings config to max entries", { + path: configPath, + maxEntries: MAX_KEYBINDINGS, + }); + } + + fs.mkdirSync(path.dirname(configPath), { recursive: true }); + writeConfigAtomically(configPath, cappedConfig); + + const compiledCustomConfig = compileResolvedKeybindingsConfig(cappedConfig); + return mergeWithDefaultKeybindings(compiledCustomConfig); } diff --git a/apps/server/src/projectRegistry.test.ts b/apps/server/src/projectRegistry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..199e69d000a666efb610a39f68e9c12974345b48 --- /dev/null +++ b/apps/server/src/projectRegistry.test.ts @@ -0,0 +1,113 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { ProjectRegistry } from "./projectRegistry"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("ProjectRegistry scripts", () => { + it("stores and reloads project scripts", () => { + const stateDir = makeTempDir("t3code-project-registry-state-"); + const projectDir = makeTempDir("t3code-project-registry-project-"); + const registry = new ProjectRegistry(stateDir); + + const created = registry.add({ cwd: projectDir }).project; + const updated = registry.updateScripts({ + id: created.id, + scripts: [ + { + id: "run", + name: "Run", + command: "bun run dev", + icon: "play", + runOnWorktreeCreate: false, + }, + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }); + + expect(updated.project.scripts).toHaveLength(2); + expect(updated.project.scripts[1]?.id).toBe("setup"); + expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(true); + + const reloaded = new ProjectRegistry(stateDir); + const listed = reloaded.list(); + expect(listed).toHaveLength(1); + expect(listed[0]?.scripts).toHaveLength(2); + expect(listed[0]?.scripts[0]?.id).toBe("run"); + expect(listed[0]?.scripts[1]?.id).toBe("setup"); + }); + + it("deduplicates script ids and allows only one setup script", () => { + const stateDir = makeTempDir("t3code-project-registry-state-"); + const projectDir = makeTempDir("t3code-project-registry-project-"); + const registry = new ProjectRegistry(stateDir); + const created = registry.add({ cwd: projectDir }).project; + + const updated = registry.updateScripts({ + id: created.id, + scripts: [ + { + id: "setup", + name: "Setup", + command: "echo setup", + icon: "configure", + runOnWorktreeCreate: true, + }, + { + id: "setup", + name: "Setup Duplicate", + command: "echo duplicate", + icon: "configure", + runOnWorktreeCreate: true, + }, + { + id: "another", + name: "Another Setup", + command: "echo another", + icon: "play", + runOnWorktreeCreate: true, + }, + ], + }); + + expect(updated.project.scripts).toHaveLength(2); + expect(updated.project.scripts[0]?.id).toBe("setup"); + expect(updated.project.scripts[0]?.runOnWorktreeCreate).toBe(true); + expect(updated.project.scripts[1]?.id).toBe("another"); + expect(updated.project.scripts[1]?.runOnWorktreeCreate).toBe(false); + }); + + it("throws when updating scripts for an unknown project id", () => { + const stateDir = makeTempDir("t3code-project-registry-state-"); + const registry = new ProjectRegistry(stateDir); + + expect(() => + registry.updateScripts({ + id: "missing-project", + scripts: [], + }), + ).toThrowError("Project not found"); + }); +}); diff --git a/apps/server/src/projectRegistry.ts b/apps/server/src/projectRegistry.ts index 5b29fbfed9e2b179813f204c06d23b6f06aef18d..c849192d53ec4064425467c67416dc94f0c9902f 100644 --- a/apps/server/src/projectRegistry.ts +++ b/apps/server/src/projectRegistry.ts @@ -8,11 +8,31 @@ import { type ProjectListResult, type ProjectRecord, type ProjectRemoveInput, + type ProjectScript, + type ProjectUpdateScriptsInput, + type ProjectUpdateScriptsResult, + normalizeProjectScripts, projectAddInputSchema, projectRecordSchema, projectRemoveInputSchema, + projectScriptsSchema, + projectUpdateScriptsInputSchema, } from "@t3tools/contracts"; +function cloneScripts(scripts: readonly ProjectScript[]): ProjectScript[] { + const cloned: ProjectScript[] = []; + for (const script of scripts) { + cloned.push({ + id: script.id, + name: script.name, + command: script.command, + icon: script.icon, + runOnWorktreeCreate: script.runOnWorktreeCreate, + }); + } + return cloned; +} + function normalizeCwd(rawCwd: string): string { const resolved = path.resolve(rawCwd.trim()); const normalized = path.normalize(resolved); @@ -48,7 +68,10 @@ export class ProjectRegistry { } list(): ProjectListResult { - return this.projects.map((project) => ({ ...project })); + return this.projects.map((project) => ({ + ...project, + scripts: cloneScripts(project.scripts), + })); } add(raw: ProjectAddInput): ProjectAddResult { @@ -60,7 +83,13 @@ export class ProjectRegistry { const existing = this.projects.find((project) => normalizeCwd(project.cwd) === normalizedCwd); if (existing) { - return { project: { ...existing }, created: false }; + return { + project: { + ...existing, + scripts: cloneScripts(existing.scripts), + }, + created: false, + }; } const now = new Date().toISOString(); @@ -68,13 +97,20 @@ export class ProjectRegistry { id: randomUUID(), cwd: normalizedCwd, name: inferProjectName(normalizedCwd), + scripts: [], createdAt: now, updatedAt: now, }; this.projects = [project, ...this.projects]; this.persist(); - return { project: { ...project }, created: true }; + return { + project: { + ...project, + scripts: cloneScripts(project.scripts), + }, + created: true, + }; } remove(raw: ProjectRemoveInput): void { @@ -88,6 +124,31 @@ export class ProjectRegistry { this.persist(); } + updateScripts(raw: ProjectUpdateScriptsInput): ProjectUpdateScriptsResult { + const input = projectUpdateScriptsInputSchema.parse(raw); + const index = this.projects.findIndex((project) => project.id === input.id); + if (index < 0) { + throw new Error(`Project not found: ${input.id}`); + } + + const nextScripts = normalizeProjectScripts(projectScriptsSchema.parse(input.scripts)); + const nextUpdatedAt = new Date().toISOString(); + const nextProject: ProjectRecord = { + ...this.projects[index]!, + scripts: nextScripts, + updatedAt: nextUpdatedAt, + }; + + const nextProjects = [...this.projects]; + nextProjects[index] = nextProject; + this.projects = nextProjects; + this.persist(); + + return { + project: { ...nextProject, scripts: cloneScripts(nextProject.scripts) }, + }; + } + private loadFromDisk(): ProjectRecord[] { const loaded = this.readPersistedProjects(); const deduped = new Map(); @@ -107,6 +168,7 @@ export class ProjectRegistry { cwd: normalizedCwd, name: project.name.trim().length > 0 ? project.name.trim() : inferProjectName(normalizedCwd), + scripts: normalizeProjectScripts(project.scripts), }; deduped.set(normalizedCwd, normalizedProject); normalizedProjects.push(normalizedProject); @@ -121,6 +183,7 @@ export class ProjectRegistry { source.id !== project.id || source.cwd !== project.cwd || source.name !== project.name || + JSON.stringify(source.scripts) !== JSON.stringify(project.scripts) || source.createdAt !== project.createdAt || source.updatedAt !== project.updatedAt ); diff --git a/apps/server/src/providerManager.test.ts b/apps/server/src/providerManager.test.ts index 424cfce16c0f9ab13c038f9b3fa0f78bbc4f7dc5..c26f34cb6ae55ba5c536dba0a86bd866971b57da 100644 --- a/apps/server/src/providerManager.test.ts +++ b/apps/server/src/providerManager.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ProviderManager } from "./providerManager"; @@ -59,4 +59,821 @@ describe("ProviderManager", () => { manager.dispose(); }); + + it("rejects checkpoint operations for unknown sessions", async () => { + const manager = new ProviderManager(); + + await expect( + manager.listCheckpoints({ + sessionId: "missing-session", + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + await expect( + manager.revertToCheckpoint({ + sessionId: "missing-session", + turnCount: 0, + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + await expect( + manager.getCheckpointDiff({ + sessionId: "missing-session", + fromTurnCount: 0, + toTurnCount: 1, + }), + ).rejects.toThrow("Unknown provider session: missing-session"); + + manager.dispose(); + }); + + it("derives checkpoints from thread turns", async () => { + const manager = new ProviderManager(); + const codex = ( + manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + } + ).codex; + + codex.hasSession = () => true; + codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { + id: "turn_1", + items: [ + { + type: "userMessage", + content: [{ type: "text", text: "Refactor the logger" }], + }, + { + type: "agentMessage", + text: "I refactored it.", + }, + ], + }, + ], + }); + + const result = await manager.listCheckpoints({ + sessionId: "sess_1", + }); + + expect(result).toEqual({ + threadId: "thr_1", + checkpoints: [ + { + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: false, + }, + { + id: "turn_1", + turnCount: 1, + messageCount: 2, + label: "Turn 1", + preview: "Refactor the logger", + isCurrent: true, + }, + ], + }); + + manager.dispose(); + }); + + it("rolls back from a selected checkpoint turn count", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + restoreCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const rollbackCalls: Array<{ sessionId: string; numTurns: number }> = []; + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + { id: "turn_3", items: [] }, + ], + }); + internals.codex.rollbackThread = async (sessionId, numTurns) => { + rollbackCalls.push({ sessionId, numTurns }); + return { + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }; + }; + const hasCheckpoint = vi.fn(async () => true); + const restoreCheckpoint = vi.fn(async () => true); + const pruneAfterTurn = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; + internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; + internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }); + + expect(rollbackCalls).toEqual([{ sessionId: "sess_1", numTurns: 2 }]); + expect(result.threadId).toBe("thr_1"); + expect(result.turnCount).toBe(1); + expect(result.messageCount).toBe(0); + expect(result.rolledBackTurns).toBe(2); + expect(result.checkpoints).toHaveLength(2); + expect(hasCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(restoreCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(pruneAfterTurn).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + maxTurnCount: 1, + }); + + manager.dispose(); + }); + + it("returns diff text for a checkpoint range", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + expect(result).toEqual({ + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + diff: "diff --git a/a.ts b/a.ts", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + manager.dispose(); + }); + + it("emits checkpoint/captured after turn completion checkpoint capture", async () => { + const manager = new ProviderManager(); + const events: Array<{ method: string; payload?: unknown; threadId?: string }> = []; + manager.on("event", (event) => { + events.push({ + method: event.method, + ...(event.payload !== undefined ? { payload: event.payload } : {}), + ...(event.threadId ? { threadId: event.threadId } : {}), + }); + }); + + const internals = manager as unknown as { + onCodexEvent: (event: { + id: string; + kind: "notification"; + provider: "codex"; + sessionId: string; + createdAt: string; + method: string; + threadId: string; + }) => void; + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }); + const captureCheckpoint = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + internals.onCodexEvent({ + id: "evt-turn-complete", + kind: "notification", + provider: "codex", + sessionId: "sess_1", + createdAt: "2026-02-19T00:00:00.000Z", + method: "turn/completed", + threadId: "thr_1", + }); + + await vi.waitFor(() => { + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(events.some((event) => event.method === "checkpoint/captured")).toBe(true); + }); + + const checkpointCapturedEvent = events.find((event) => event.method === "checkpoint/captured"); + expect(checkpointCapturedEvent?.threadId).toBe("thr_1"); + expect(checkpointCapturedEvent?.payload).toEqual({ + threadId: "thr_1", + turnCount: 1, + }); + + manager.dispose(); + }); + + it("lazily initializes filesystem checkpoints before diffing when cache is missing", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + cwd: "/repo", + }, + ]; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + const isGitRepository = vi.fn(async () => true); + const ensureRootCheckpoint = vi.fn(async () => true); + const captureCheckpoint = vi.fn(async () => undefined); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.delete("sess_1"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + + expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); + expect(isGitRepository).toHaveBeenCalledWith("/repo"); + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 2, + }); + expect(ensureRootCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + fromTurnCount: 1, + toTurnCount: 2, + }); + expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/repo"); + + manager.dispose(); + }); + + it("falls back to process cwd for lazy checkpoint init when session cwd is missing", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + ensureRootCheckpoint: (input: { cwd: string; threadId: string }) => Promise; + captureCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + diffCheckpoints: (input: { + cwd: string; + threadId: string; + fromTurnCount: number; + toTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); + try { + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + }, + ]; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }); + const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); + const ensureRootCheckpoint = vi.fn(async () => true); + const captureCheckpoint = vi.fn(async () => undefined); + const diffCheckpoints = vi.fn(async () => "diff --git a/a.ts b/a.ts"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.filesystemCheckpointStore.ensureRootCheckpoint = ensureRootCheckpoint; + internals.filesystemCheckpointStore.captureCheckpoint = captureCheckpoint; + internals.filesystemCheckpointStore.diffCheckpoints = diffCheckpoints; + internals.sessionCheckpointCwds.delete("sess_1"); + + const result = await manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 0, + toTurnCount: 1, + }); + + expect(result.diff).toBe("diff --git a/a.ts b/a.ts"); + expect(isGitRepository).toHaveBeenCalledWith("/fallback-repo"); + expect(captureCheckpoint).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(ensureRootCheckpoint).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + }); + expect(diffCheckpoints).toHaveBeenCalledWith({ + cwd: "/fallback-repo", + threadId: "thr_1", + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(internals.sessionCheckpointCwds.get("sess_1")).toBe("/fallback-repo"); + } finally { + processCwd.mockRestore(); + manager.dispose(); + } + }); + + it("does not fall back to process cwd when a session cwd exists but is not a git repo", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const processCwd = vi.spyOn(process, "cwd").mockReturnValue("/fallback-repo"); + try { + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + cwd: "/session-cwd", + }, + ]; + const isGitRepository = vi.fn(async (cwd: string) => cwd === "/fallback-repo"); + internals.filesystemCheckpointStore.isGitRepository = isGitRepository; + internals.sessionCheckpointCwds.delete("sess_1"); + + await expect( + manager.getCheckpointDiff({ + sessionId: "sess_1", + fromTurnCount: 0, + toTurnCount: 1, + }), + ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); + expect(isGitRepository).toHaveBeenCalledWith("/session-cwd"); + expect(isGitRepository).not.toHaveBeenCalledWith("/fallback-repo"); + } finally { + processCwd.mockRestore(); + manager.dispose(); + } + }); + + it("fails revert when filesystem checkpoints are unavailable for the session", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + listSessions: () => Array<{ + sessionId: string; + provider: "codex"; + status: "ready"; + createdAt: string; + updatedAt: string; + cwd?: string; + }>; + }; + filesystemCheckpointStore: { + isGitRepository: (cwd: string) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.listSessions = () => [ + { + sessionId: "sess_1", + provider: "codex", + status: "ready", + createdAt: "2026-02-18T00:00:00.000Z", + updatedAt: "2026-02-18T00:00:00.000Z", + }, + ]; + internals.filesystemCheckpointStore.isGitRepository = async () => false; + internals.sessionCheckpointCwds.delete("sess_1"); + + await expect( + manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }), + ).rejects.toThrow("Filesystem checkpoints are unavailable for this session."); + + manager.dispose(); + }); + + it("restores filesystem checkpoint when reverting and checkpointing is enabled", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + restoreCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + const callOrder: string[] = []; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + internals.codex.rollbackThread = async () => { + callOrder.push("rollback"); + return { + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + }; + }; + const hasCheckpoint = vi.fn(async () => true); + const restoreCheckpoint = vi.fn(async () => { + callOrder.push("restore"); + return true; + }); + const pruneAfterTurn = vi.fn(async () => { + callOrder.push("prune"); + return undefined; + }); + internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; + internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; + internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }); + + expect(result.turnCount).toBe(1); + expect(hasCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(restoreCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 1, + }); + expect(pruneAfterTurn).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + maxTurnCount: 1, + }); + expect(callOrder).toEqual(["restore", "rollback", "prune"]); + + manager.dispose(); + }); + + it("reverts to turn 0 without requiring an explicit turn 0 checkpoint ref", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + restoreCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + pruneAfterTurn: (input: { + cwd: string; + threadId: string; + maxTurnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + internals.codex.rollbackThread = async () => ({ + threadId: "thr_1", + turns: [], + }); + const hasCheckpoint = vi.fn(async () => false); + const restoreCheckpoint = vi.fn(async () => true); + const pruneAfterTurn = vi.fn(async () => undefined); + internals.filesystemCheckpointStore.hasCheckpoint = hasCheckpoint; + internals.filesystemCheckpointStore.restoreCheckpoint = restoreCheckpoint; + internals.filesystemCheckpointStore.pruneAfterTurn = pruneAfterTurn; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + const result = await manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 0, + }); + + expect(result.turnCount).toBe(0); + expect(hasCheckpoint).not.toHaveBeenCalled(); + expect(restoreCheckpoint).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + turnCount: 0, + }); + expect(pruneAfterTurn).toHaveBeenCalledWith({ + cwd: "/repo", + threadId: "thr_1", + maxTurnCount: 0, + }); + + manager.dispose(); + }); + + it("fails before rollback when filesystem checkpoint is missing", async () => { + const manager = new ProviderManager(); + const internals = manager as unknown as { + codex: { + hasSession: (sessionId: string) => boolean; + readThread: (sessionId: string) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + rollbackThread: ( + sessionId: string, + numTurns: number, + ) => Promise<{ + threadId: string; + turns: Array<{ id: string; items: unknown[] }>; + }>; + }; + filesystemCheckpointStore: { + hasCheckpoint: (input: { + cwd: string; + threadId: string; + turnCount: number; + }) => Promise; + }; + sessionCheckpointCwds: Map; + }; + + const rollbackSpy = vi.fn(async () => ({ + threadId: "thr_1", + turns: [{ id: "turn_1", items: [] }], + })); + + internals.codex.hasSession = () => true; + internals.codex.readThread = async () => ({ + threadId: "thr_1", + turns: [ + { id: "turn_1", items: [] }, + { id: "turn_2", items: [] }, + ], + }); + internals.codex.rollbackThread = rollbackSpy; + internals.filesystemCheckpointStore.hasCheckpoint = async () => false; + internals.sessionCheckpointCwds.set("sess_1", "/repo"); + + await expect( + manager.revertToCheckpoint({ + sessionId: "sess_1", + turnCount: 1, + }), + ).rejects.toThrow("Filesystem checkpoint is unavailable for turn 1 in thread thr_1."); + expect(rollbackSpy).not.toHaveBeenCalled(); + + manager.dispose(); + }); }); diff --git a/apps/server/src/providerManager.ts b/apps/server/src/providerManager.ts index 52fb1b648475846cdc7448108419bab9a3c3418e..d8442e6c7dee4aaf52d907eec6f69e730677fec5 100644 --- a/apps/server/src/providerManager.ts +++ b/apps/server/src/providerManager.ts @@ -1,10 +1,18 @@ import { EventEmitter } from "node:events"; import fs from "node:fs"; import path from "node:path"; +import { randomUUID } from "node:crypto"; import { + type ProviderCheckpoint, type ProviderEvent, + type ProviderGetCheckpointDiffInput, + type ProviderGetCheckpointDiffResult, type ProviderInterruptTurnInput, + type ProviderListCheckpointsInput, + type ProviderListCheckpointsResult, + type ProviderRevertToCheckpointInput, + type ProviderRevertToCheckpointResult, type ProviderRespondToRequestInput, type ProviderSendTurnInput, type ProviderSession, @@ -12,23 +20,155 @@ import { type ProviderStopSessionInput, type ProviderTurnStartResult, providerInterruptTurnInputSchema, + providerGetCheckpointDiffInputSchema, + providerListCheckpointsInputSchema, + providerRevertToCheckpointInputSchema, providerRespondToRequestInputSchema, providerSendTurnInputSchema, providerSessionStartInputSchema, providerStopSessionInputSchema, } from "@t3tools/contracts"; +import type { CodexThreadTurnSnapshot } from "./codexAppServerManager"; import { CodexAppServerManager } from "./codexAppServerManager"; +import { FilesystemCheckpointStore } from "./filesystemCheckpointStore"; export interface ProviderManagerEvents { event: [event: ProviderEvent]; } +function asObject(value: unknown): Record | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + return value as Record; +} + +function asString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function asArray(value: unknown): unknown[] { + return Array.isArray(value) ? value : []; +} + +function trimToPreview(value: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (normalized.length <= 120) { + return normalized; + } + return `${normalized.slice(0, 117)}...`; +} + +function summarizeUserMessageContent(content: unknown[]): string | undefined { + const segments: string[] = []; + + for (const part of content) { + const record = asObject(part); + const type = asString(record?.type); + if (!type) continue; + + if (type === "text") { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + segments.push(text.trim()); + } + continue; + } + + if (type === "image") { + segments.push("[Image attachment]"); + continue; + } + + if (type === "localImage") { + segments.push("[Local image attachment]"); + continue; + } + } + + if (segments.length === 0) { + return undefined; + } + return trimToPreview(segments.join(" ")); +} + +function summarizeTurn(turn: CodexThreadTurnSnapshot): { + messageCountDelta: number; + preview?: string; +} { + let messageCountDelta = 0; + let preview: string | undefined; + + for (const item of turn.items) { + const record = asObject(item); + const type = asString(record?.type); + if (!type) continue; + + if (type === "userMessage") { + messageCountDelta += 1; + if (!preview) { + const content = asArray(record?.content); + preview = summarizeUserMessageContent(content); + } + continue; + } + + if (type === "agentMessage") { + messageCountDelta += 1; + if (!preview) { + const text = asString(record?.text); + if (text && text.trim().length > 0) { + preview = trimToPreview(text); + } + } + } + } + + return { + messageCountDelta, + ...(preview ? { preview } : {}), + }; +} + +function buildCheckpoints(turns: CodexThreadTurnSnapshot[]): ProviderCheckpoint[] { + const checkpoints: ProviderCheckpoint[] = []; + let messageCount = 0; + const isEmpty = turns.length === 0; + checkpoints.push({ + id: "root", + turnCount: 0, + messageCount: 0, + label: "Start of conversation", + isCurrent: isEmpty, + }); + + for (let index = 0; index < turns.length; index += 1) { + const turn = turns[index]; + if (!turn) continue; + const turnSummary = summarizeTurn(turn); + messageCount += turnSummary.messageCountDelta; + checkpoints.push({ + id: turn.id, + turnCount: index + 1, + messageCount, + label: `Turn ${index + 1}`, + ...(turnSummary.preview ? { preview: turnSummary.preview } : {}), + isCurrent: index === turns.length - 1, + }); + } + + return checkpoints; +} + export class ProviderManager extends EventEmitter { private readonly codex = new CodexAppServerManager(); + private readonly filesystemCheckpointStore = new FilesystemCheckpointStore(); private readonly threadLogsDir: string; private readonly threadLogStreams = new Map(); private readonly sessionThreadIds = new Map(); private readonly pendingEventsBySession = new Map(); + private readonly sessionCheckpointCwds = new Map(); + private readonly filesystemLocks = new Map>(); private disposed = false; private readonly onCodexEvent = (event: ProviderEvent) => { if (this.disposed) { @@ -37,6 +177,7 @@ export class ProviderManager extends EventEmitter { this.routeEventToThreadLog(event); this.emit("event", event); + this.maybeCaptureFilesystemCheckpoint(event); }; constructor() { @@ -60,6 +201,11 @@ export class ProviderManager extends EventEmitter { this.sessionThreadIds.set(session.sessionId, session.threadId); this.flushPendingSessionEvents(session.sessionId, session.threadId); } + await this.initializeFilesystemCheckpointing(session, input.cwd).catch((error) => { + const message = + error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; + this.emitFilesystemCheckpointError(session.sessionId, message, session.threadId); + }); return session; } @@ -95,12 +241,138 @@ export class ProviderManager extends EventEmitter { this.codex.stopSession(input.sessionId); this.sessionThreadIds.delete(input.sessionId); this.pendingEventsBySession.delete(input.sessionId); + this.sessionCheckpointCwds.delete(input.sessionId); + this.filesystemLocks.delete(input.sessionId); } listSessions(): ProviderSession[] { return this.codex.listSessions(); } + async listCheckpoints(raw: ProviderListCheckpointsInput): Promise { + const input = providerListCheckpointsInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const snapshot = await this.codex.readThread(input.sessionId); + return { + threadId: snapshot.threadId, + checkpoints: buildCheckpoints(snapshot.turns), + }; + } + + async getCheckpointDiff( + raw: ProviderGetCheckpointDiffInput, + ): Promise { + const input = providerGetCheckpointDiffInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); + if (!checkpointCwd) { + throw new Error("Filesystem checkpoints are unavailable for this session."); + } + + return this.withFilesystemLock(input.sessionId, async () => { + const snapshot = await this.codex.readThread(input.sessionId); + if (input.toTurnCount > snapshot.turns.length) { + throw new Error( + `Checkpoint turn count ${input.toTurnCount} exceeds current turn count ${snapshot.turns.length}.`, + ); + } + + const diff = await this.filesystemCheckpointStore.diffCheckpoints({ + cwd: checkpointCwd, + threadId: snapshot.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + }); + + return { + threadId: snapshot.threadId, + fromTurnCount: input.fromTurnCount, + toTurnCount: input.toTurnCount, + diff, + }; + }); + } + + async revertToCheckpoint( + raw: ProviderRevertToCheckpointInput, + ): Promise { + const input = providerRevertToCheckpointInputSchema.parse(raw); + if (!this.codex.hasSession(input.sessionId)) { + throw new Error(`Unknown provider session: ${input.sessionId}`); + } + + const checkpointCwd = await this.getOrInitializeFilesystemCheckpointCwd(input.sessionId); + if (!checkpointCwd) { + throw new Error("Filesystem checkpoints are unavailable for this session."); + } + return this.withFilesystemLock(input.sessionId, async () => { + const beforeSnapshot = await this.codex.readThread(input.sessionId); + const currentTurnCount = beforeSnapshot.turns.length; + if (input.turnCount > currentTurnCount) { + throw new Error( + `Checkpoint turn count ${input.turnCount} exceeds current turn count ${currentTurnCount}.`, + ); + } + + if (checkpointCwd && input.turnCount > 0) { + const hasCheckpoint = await this.filesystemCheckpointStore.hasCheckpoint({ + cwd: checkpointCwd, + threadId: beforeSnapshot.threadId, + turnCount: input.turnCount, + }); + if (!hasCheckpoint) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, + ); + } + } + + const restored = await this.filesystemCheckpointStore.restoreCheckpoint({ + cwd: checkpointCwd, + threadId: beforeSnapshot.threadId, + turnCount: input.turnCount, + }); + if (!restored) { + throw new Error( + `Filesystem checkpoint is unavailable for turn ${input.turnCount} in thread ${beforeSnapshot.threadId}.`, + ); + } + + const requestedRollbackTurns = currentTurnCount - input.turnCount; + const afterSnapshot = + requestedRollbackTurns > 0 + ? await this.codex.rollbackThread(input.sessionId, requestedRollbackTurns) + : beforeSnapshot; + + await this.filesystemCheckpointStore.pruneAfterTurn({ + cwd: checkpointCwd, + threadId: afterSnapshot.threadId, + maxTurnCount: afterSnapshot.turns.length, + }); + + const checkpoints = buildCheckpoints(afterSnapshot.turns); + const currentCheckpoint = + checkpoints.find((checkpoint) => checkpoint.isCurrent) ?? + checkpoints[checkpoints.length - 1] ?? + checkpoints[0]; + const rolledBackTurns = Math.max(0, currentTurnCount - afterSnapshot.turns.length); + + return { + threadId: afterSnapshot.threadId, + turnCount: currentCheckpoint?.turnCount ?? 0, + messageCount: currentCheckpoint?.messageCount ?? 0, + rolledBackTurns, + checkpoints, + }; + }); + } + stopAll(): void { this.codex.stopAll(); } @@ -118,6 +390,174 @@ export class ProviderManager extends EventEmitter { this.threadLogStreams.clear(); this.sessionThreadIds.clear(); this.pendingEventsBySession.clear(); + this.sessionCheckpointCwds.clear(); + this.filesystemLocks.clear(); + } + + private async initializeFilesystemCheckpointing( + session: ProviderSession, + preferredCwd?: string, + ): Promise { + const cwd = preferredCwd ?? session.cwd ?? process.cwd(); + + await this.withFilesystemLock(session.sessionId, async () => { + const supportsGit = await this.filesystemCheckpointStore.isGitRepository(cwd); + if (!supportsGit) { + this.sessionCheckpointCwds.delete(session.sessionId); + return; + } + + const snapshot = await this.codex.readThread(session.sessionId); + await this.filesystemCheckpointStore.ensureRootCheckpoint({ + cwd, + threadId: snapshot.threadId, + }); + await this.filesystemCheckpointStore.captureCheckpoint({ + cwd, + threadId: snapshot.threadId, + turnCount: snapshot.turns.length, + }); + if (this.codex.hasSession(session.sessionId)) { + this.sessionCheckpointCwds.set(session.sessionId, cwd); + } + }); + } + + private async getOrInitializeFilesystemCheckpointCwd(sessionId: string): Promise { + const existingCwd = this.sessionCheckpointCwds.get(sessionId); + if (existingCwd) { + return existingCwd; + } + + const session = this.codex + .listSessions() + .find((candidate) => candidate.sessionId === sessionId); + const candidateCwds = session?.cwd ? [session.cwd] : [process.cwd()]; + + await this.withFilesystemLock(sessionId, async () => { + const currentCwd = this.sessionCheckpointCwds.get(sessionId); + if (currentCwd) { + return; + } + + const cwdSupport = await Promise.all( + candidateCwds.map(async (cwd) => ({ + cwd, + supportsGit: await this.filesystemCheckpointStore.isGitRepository(cwd), + })), + ); + const supportedCwd = cwdSupport.find((entry) => entry.supportsGit)?.cwd; + if (supportedCwd) { + const snapshot = await this.codex.readThread(sessionId); + await this.filesystemCheckpointStore.ensureRootCheckpoint({ + cwd: supportedCwd, + threadId: snapshot.threadId, + }); + await this.filesystemCheckpointStore.captureCheckpoint({ + cwd: supportedCwd, + threadId: snapshot.threadId, + turnCount: snapshot.turns.length, + }); + if (this.codex.hasSession(sessionId)) { + this.sessionCheckpointCwds.set(sessionId, supportedCwd); + } + return; + } + + this.sessionCheckpointCwds.delete(sessionId); + }); + + return this.sessionCheckpointCwds.get(sessionId) ?? null; + } + + private maybeCaptureFilesystemCheckpoint(event: ProviderEvent): void { + if (event.kind !== "notification" || event.method !== "turn/completed") { + return; + } + + const checkpointCwd = this.sessionCheckpointCwds.get(event.sessionId); + if (!checkpointCwd) { + void this.getOrInitializeFilesystemCheckpointCwd(event.sessionId) + .then(async (initializedCwd) => { + if (!initializedCwd || !this.codex.hasSession(event.sessionId)) { + return; + } + const snapshot = await this.codex.readThread(event.sessionId); + this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); + }) + .catch((error) => { + const message = + error instanceof Error ? error.message : "Failed to initialize filesystem checkpoints."; + this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); + }); + return; + } + + void this.withFilesystemLock(event.sessionId, async () => { + if (!this.codex.hasSession(event.sessionId)) { + return; + } + + const snapshot = await this.codex.readThread(event.sessionId); + await this.filesystemCheckpointStore.captureCheckpoint({ + cwd: checkpointCwd, + threadId: snapshot.threadId, + turnCount: snapshot.turns.length, + }); + this.emitCheckpointCaptured(event.sessionId, snapshot.threadId, snapshot.turns.length); + }).catch((error) => { + const message = error instanceof Error ? error.message : "Failed to capture checkpoint."; + this.emitFilesystemCheckpointError(event.sessionId, message, event.threadId); + }); + } + + private emitCheckpointCaptured(sessionId: string, threadId: string, turnCount: number): void { + this.emit("event", { + id: randomUUID(), + kind: "notification", + provider: "codex", + sessionId, + createdAt: new Date().toISOString(), + method: "checkpoint/captured", + threadId, + payload: { + threadId, + turnCount, + }, + }); + } + + private emitFilesystemCheckpointError( + sessionId: string, + message: string, + threadId?: string, + ): void { + this.emit("event", { + id: randomUUID(), + kind: "error", + provider: "codex", + sessionId, + createdAt: new Date().toISOString(), + method: "checkpoint/filesystemError", + message, + ...(threadId ? { threadId } : {}), + }); + } + + private withFilesystemLock(sessionId: string, fn: () => Promise): Promise { + const previous = this.filesystemLocks.get(sessionId) ?? Promise.resolve(); + const next = previous.catch(() => undefined).then(fn); + const completion = next.then( + () => undefined, + () => undefined, + ); + this.filesystemLocks.set(sessionId, completion); + return next.finally(() => { + const tracked = this.filesystemLocks.get(sessionId); + if (tracked === completion) { + this.filesystemLocks.delete(sessionId); + } + }); } private routeEventToThreadLog(event: ProviderEvent): void { diff --git a/apps/server/src/ptyAdapter.ts b/apps/server/src/ptyAdapter.ts index 903b5911b13e8e77631fbe02448b1537243a07eb..60061e04c88d3ddb13a4b151b082faf4b4c2d83f 100644 --- a/apps/server/src/ptyAdapter.ts +++ b/apps/server/src/ptyAdapter.ts @@ -20,6 +20,7 @@ export interface PtyProcess { export interface PtySpawnInput { shell: string; + args?: string[]; cwd: string; cols: number; rows: number; @@ -118,7 +119,7 @@ class NodePtyProcess implements PtyProcess { export class NodePtyAdapter implements PtyAdapter { spawn(input: PtySpawnInput): PtyProcess { ensureNodePtySpawnHelperExecutable(); - const ptyProcess = nodePty.spawn(input.shell, [], { + const ptyProcess = nodePty.spawn(input.shell, input.args ?? [], { cwd: input.cwd, cols: input.cols, rows: input.rows, diff --git a/apps/server/src/terminalManager.test.ts b/apps/server/src/terminalManager.test.ts index 55b11068be618f7e3357b7c9e312cc2e17e12950..c98b0b3a782b4811bad75e55b158e7d018adf5dc 100644 --- a/apps/server/src/terminalManager.test.ts +++ b/apps/server/src/terminalManager.test.ts @@ -196,6 +196,65 @@ describe("TerminalManager", () => { manager.dispose(); }); + it("resizes running terminal on open when a different size is requested", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const process = ptyAdapter.processes[0]; + expect(process).toBeDefined(); + if (!process) return; + + await manager.open(openInput({ cols: 140, rows: 40 })); + + expect(process.resizeCalls).toEqual([{ cols: 140, rows: 40 }]); + + manager.dispose(); + }); + + it("preserves existing terminal size on open when size is omitted", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open(openInput({ cols: 100, rows: 24 })); + const ptyProcess = ptyAdapter.processes[0]; + expect(ptyProcess).toBeDefined(); + if (!ptyProcess) return; + + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + expect(ptyProcess.resizeCalls).toEqual([]); + + ptyProcess.emitExit({ exitCode: 0, signal: 0 }); + await manager.open({ + threadId: "thread-1", + cwd: globalThis.process.cwd(), + }); + + const resumedSpawn = ptyAdapter.spawnInputs[1]; + expect(resumedSpawn).toBeDefined(); + if (!resumedSpawn) return; + expect(resumedSpawn.cols).toBe(100); + expect(resumedSpawn.rows).toBe(24); + + manager.dispose(); + }); + + it("uses default dimensions when opening a new terminal without size hints", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open({ + threadId: "thread-1", + cwd: process.cwd(), + }); + + const spawned = ptyAdapter.spawnInputs[0]; + expect(spawned).toBeDefined(); + if (!spawned) return; + expect(spawned.cols).toBe(120); + expect(spawned.rows).toBe(30); + + manager.dispose(); + }); + it("supports multiple terminals per thread with isolated sessions", async () => { const { manager, ptyAdapter } = makeManager(); await manager.open(openInput({ terminalId: "default" })); @@ -466,4 +525,42 @@ describe("TerminalManager", () => { restoreEnv(); } }); + + it("injects runtime env overrides into spawned terminals", async () => { + const { manager, ptyAdapter } = makeManager(); + await manager.open( + openInput({ + env: { + T3CODE_PROJECT_ROOT: "/repo", + T3CODE_WORKTREE_PATH: "/repo/worktree-a", + CUSTOM_FLAG: "1", + }, + }), + ); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.env.T3CODE_PROJECT_ROOT).toBe("/repo"); + expect(spawnInput.env.T3CODE_WORKTREE_PATH).toBe("/repo/worktree-a"); + expect(spawnInput.env.CUSTOM_FLAG).toBe("1"); + + manager.dispose(); + }); + + it("starts zsh with prompt spacer disabled to avoid `%` end markers", async () => { + if (process.platform === "win32") return; + const { manager, ptyAdapter } = makeManager(5, { + shellResolver: () => "/bin/zsh", + }); + await manager.open(openInput()); + const spawnInput = ptyAdapter.spawnInputs[0]; + expect(spawnInput).toBeDefined(); + if (!spawnInput) return; + + expect(spawnInput.shell).toBe("/bin/zsh"); + expect(spawnInput.args).toEqual(["-o", "nopromptsp"]); + + manager.dispose(); + }); }); diff --git a/apps/server/src/terminalManager.ts b/apps/server/src/terminalManager.ts index b6fb31af3bb8642134fc7b61705837b38e1b84f5..dcdf78615df5df4a862a4eb0f1d4a76ea2fe2ac4 100644 --- a/apps/server/src/terminalManager.ts +++ b/apps/server/src/terminalManager.ts @@ -26,6 +26,8 @@ import { runProcess } from "./processRunner"; const DEFAULT_HISTORY_LINE_LIMIT = 5_000; const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; +const DEFAULT_OPEN_COLS = 120; +const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); type TerminalSubprocessChecker = (terminalPid: number) => Promise; @@ -59,8 +61,16 @@ interface TerminalSessionState { unsubscribeData: (() => void) | null; unsubscribeExit: (() => void) | null; hasRunningSubprocess: boolean; + runtimeEnv: Record | null; } +interface ShellCandidate { + shell: string; + args?: string[]; +} + +type TerminalStartInput = TerminalOpenInput & { cols: number; rows: number }; + function defaultShellResolver(): string { if (process.platform === "win32") { return process.env.ComSpec ?? "cmd.exe"; @@ -82,34 +92,54 @@ function normalizeShellCommand(value: string | undefined): string | null { return firstToken.replace(/^['"]|['"]$/g, ""); } -function uniqueShells(shells: Array): string[] { +function shellCandidateFromCommand(command: string | null): ShellCandidate | null { + if (!command || command.length === 0) return null; + const shellName = path.basename(command).toLowerCase(); + if (process.platform !== "win32" && shellName === "zsh") { + return { shell: command, args: ["-o", "nopromptsp"] }; + } + return { shell: command }; +} + +function formatShellCandidate(candidate: ShellCandidate): string { + if (!candidate.args || candidate.args.length === 0) return candidate.shell; + return `${candidate.shell} ${candidate.args.join(" ")}`; +} + +function uniqueShellCandidates(candidates: Array): ShellCandidate[] { const seen = new Set(); - const ordered: string[] = []; - for (const shell of shells) { - if (!shell || shell.length === 0) continue; - if (seen.has(shell)) continue; - seen.add(shell); - ordered.push(shell); + const ordered: ShellCandidate[] = []; + for (const candidate of candidates) { + if (!candidate) continue; + const key = formatShellCandidate(candidate); + if (seen.has(key)) continue; + seen.add(key); + ordered.push(candidate); } return ordered; } -function resolveShellCandidates(shellResolver: () => string): string[] { - const requested = normalizeShellCommand(shellResolver()); +function resolveShellCandidates(shellResolver: () => string): ShellCandidate[] { + const requested = shellCandidateFromCommand(normalizeShellCommand(shellResolver())); if (process.platform === "win32") { - return uniqueShells([requested, process.env.ComSpec ?? null, "powershell.exe", "cmd.exe"]); + return uniqueShellCandidates([ + requested, + shellCandidateFromCommand(process.env.ComSpec ?? null), + shellCandidateFromCommand("powershell.exe"), + shellCandidateFromCommand("cmd.exe"), + ]); } - return uniqueShells([ + return uniqueShellCandidates([ requested, - normalizeShellCommand(process.env.SHELL), - "/bin/zsh", - "/bin/bash", - "/bin/sh", - "zsh", - "bash", - "sh", + shellCandidateFromCommand(normalizeShellCommand(process.env.SHELL)), + shellCandidateFromCommand("/bin/zsh"), + shellCandidateFromCommand("/bin/bash"), + shellCandidateFromCommand("/bin/sh"), + shellCandidateFromCommand("zsh"), + shellCandidateFromCommand("bash"), + shellCandidateFromCommand("sh"), ]); } @@ -241,16 +271,33 @@ function shouldExcludeTerminalEnvKey(key: string): boolean { return TERMINAL_ENV_BLOCKLIST.has(normalizedKey); } -function createTerminalSpawnEnv(baseEnv: NodeJS.ProcessEnv): NodeJS.ProcessEnv { +function createTerminalSpawnEnv( + baseEnv: NodeJS.ProcessEnv, + runtimeEnv?: Record | null, +): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { if (value === undefined) continue; if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + if (runtimeEnv) { + for (const [key, value] of Object.entries(runtimeEnv)) { + spawnEnv[key] = value; + } + } return spawnEnv; } +function normalizedRuntimeEnv( + env: Record | undefined, +): Record | null { + if (!env) return null; + const entries = Object.entries(env); + if (entries.length === 0) return null; + return Object.fromEntries(entries.sort(([left], [right]) => left.localeCompare(right))); +} + export class TerminalManager extends EventEmitter { private readonly sessions = new Map(); private readonly logsDir: string; @@ -291,6 +338,8 @@ export class TerminalManager extends EventEmitter { if (!existing) { await this.flushPersistQueue(input.threadId, input.terminalId); const history = await this.readHistory(input.threadId, input.terminalId); + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; const session: TerminalSessionState = { threadId: input.threadId, terminalId: input.terminalId, @@ -301,37 +350,49 @@ export class TerminalManager extends EventEmitter { exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, + cols, + rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); - this.startSession(session, input, "started"); + this.startSession(session, { ...input, cols, rows }, "started"); return this.snapshot(session); } - if (existing.cwd !== input.cwd) { + const nextRuntimeEnv = normalizedRuntimeEnv(input.env); + const currentRuntimeEnv = existing.runtimeEnv; + const targetCols = input.cols ?? existing.cols; + const targetRows = input.rows ?? existing.rows; + const runtimeEnvChanged = + JSON.stringify(currentRuntimeEnv) !== JSON.stringify(nextRuntimeEnv); + + if (existing.cwd !== input.cwd || runtimeEnvChanged) { this.stopProcess(existing); existing.cwd = input.cwd; + existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); } else if (existing.status === "exited" || existing.status === "error") { + existing.runtimeEnv = nextRuntimeEnv; existing.history = ""; await this.persistHistory(existing.threadId, existing.terminalId, existing.history); + } else if (currentRuntimeEnv !== nextRuntimeEnv) { + existing.runtimeEnv = nextRuntimeEnv; } if (!existing.process) { - this.startSession(existing, input, "started"); + this.startSession(existing, { ...input, cols: targetCols, rows: targetRows }, "started"); return this.snapshot(existing); } - if (existing.cols !== input.cols || existing.rows !== input.rows) { - existing.cols = input.cols; - existing.rows = input.rows; - existing.process.resize(input.cols, input.rows); + if (existing.cols !== targetCols || existing.rows !== targetRows) { + existing.cols = targetCols; + existing.rows = targetRows; + existing.process.resize(targetCols, targetRows); existing.updatedAt = new Date().toISOString(); } @@ -388,6 +449,8 @@ export class TerminalManager extends EventEmitter { const sessionKey = toSessionKey(input.threadId, input.terminalId); let session = this.sessions.get(sessionKey); if (!session) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; session = { threadId: input.threadId, terminalId: input.terminalId, @@ -398,22 +461,27 @@ export class TerminalManager extends EventEmitter { exitCode: null, exitSignal: null, updatedAt: new Date().toISOString(), - cols: input.cols, - rows: input.rows, + cols, + rows, process: null, unsubscribeData: null, unsubscribeExit: null, hasRunningSubprocess: false, + runtimeEnv: normalizedRuntimeEnv(input.env), }; this.sessions.set(sessionKey, session); } else { this.stopProcess(session); session.cwd = input.cwd; + session.runtimeEnv = normalizedRuntimeEnv(input.env); } + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + session.history = ""; await this.persistHistory(input.threadId, input.terminalId, session.history); - this.startSession(session, input, "restarted"); + this.startSession(session, { ...input, cols, rows }, "restarted"); return this.snapshot(session); }); } @@ -462,7 +530,7 @@ export class TerminalManager extends EventEmitter { private startSession( session: TerminalSessionState, - input: TerminalOpenInput, + input: TerminalStartInput, eventType: "started" | "restarted", ): void { this.stopProcess(session); @@ -480,19 +548,20 @@ export class TerminalManager extends EventEmitter { let startedShell: string | null = null; try { const shellCandidates = resolveShellCandidates(this.shellResolver); - const terminalEnv = createTerminalSpawnEnv(process.env); + const terminalEnv = createTerminalSpawnEnv(process.env, session.runtimeEnv); let lastSpawnError: unknown = null; - for (const shell of shellCandidates) { + for (const candidate of shellCandidates) { try { ptyProcess = this.ptyAdapter.spawn({ - shell, + shell: candidate.shell, + ...(candidate.args ? { args: candidate.args } : {}), cwd: session.cwd, cols: session.cols, rows: session.rows, env: terminalEnv, }); - startedShell = shell; + startedShell = formatShellCandidate(candidate); break; } catch (error) { lastSpawnError = error; @@ -506,7 +575,9 @@ export class TerminalManager extends EventEmitter { const detail = lastSpawnError instanceof Error ? lastSpawnError.message : "Terminal start failed"; const tried = - shellCandidates.length > 0 ? ` Tried shells: ${shellCandidates.join(", ")}.` : ""; + shellCandidates.length > 0 + ? ` Tried shells: ${shellCandidates.map((candidate) => formatShellCandidate(candidate)).join(", ")}.` + : ""; throw new Error(`${detail}.${tried}`.trim()); } diff --git a/apps/server/src/workspaceEntries.chunking.test.ts b/apps/server/src/workspaceEntries.chunking.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6644d608015d2d3cac619a7936eaf7b30ddf0054 --- /dev/null +++ b/apps/server/src/workspaceEntries.chunking.test.ts @@ -0,0 +1,80 @@ +import { assert, beforeEach, describe, it, vi } from "vitest"; +import type { ProcessRunOptions, ProcessRunResult } from "./processRunner"; + +const { runProcessMock } = vi.hoisted(() => ({ + runProcessMock: vi.fn< + ( + command: string, + args: readonly string[], + options?: ProcessRunOptions, + ) => Promise + >(), +})); + +vi.mock("./processRunner", () => ({ + runProcess: runProcessMock, +})); + +function processResult( + overrides: Partial & Pick, +): ProcessRunResult { + return { + stdout: overrides.stdout, + code: overrides.code, + stderr: overrides.stderr ?? "", + signal: overrides.signal ?? null, + timedOut: overrides.timedOut ?? false, + stdoutTruncated: overrides.stdoutTruncated ?? false, + stderrTruncated: overrides.stderrTruncated ?? false, + }; +} + +describe("searchWorkspaceEntries git-ignore chunking", () => { + beforeEach(() => { + runProcessMock.mockReset(); + vi.resetModules(); + }); + + it("chunks git check-ignore stdin to avoid building giant strings", async () => { + const ignoredPaths = Array.from( + { length: 5000 }, + (_, index) => `ignored/${index.toString().padStart(5, "0")}/${"x".repeat(80)}.ts`, + ); + const keptPaths = ["src/keep.ts", "docs/readme.md"]; + const listedPaths = [...ignoredPaths, ...keptPaths]; + let checkIgnoreCalls = 0; + + runProcessMock.mockImplementation(async (_command, args, options) => { + if (args[0] === "rev-parse") { + return processResult({ code: 0, stdout: "true\n" }); + } + + if (args[0] === "ls-files") { + return processResult({ code: 0, stdout: `${listedPaths.join("\0")}\0` }); + } + + if (args[0] === "check-ignore") { + checkIgnoreCalls += 1; + const chunkPaths = (options?.stdin ?? "").split("\0").filter((value) => value.length > 0); + const chunkIgnored = chunkPaths.filter((value) => value.startsWith("ignored/")); + return processResult({ + code: chunkIgnored.length > 0 ? 0 : 1, + stdout: chunkIgnored.length > 0 ? `${chunkIgnored.join("\0")}\0` : "", + }); + } + + throw new Error(`Unexpected command: git ${args.join(" ")}`); + }); + + const { searchWorkspaceEntries } = await import("./workspaceEntries"); + const result = await searchWorkspaceEntries({ + cwd: "/virtual/workspace", + query: "", + limit: 100, + }); + + assert.isAbove(checkIgnoreCalls, 1); + assert.isFalse(result.entries.some((entry) => entry.path.startsWith("ignored/"))); + assert.isTrue(result.entries.some((entry) => entry.path === "src/keep.ts")); + }); +}); diff --git a/apps/server/src/workspaceEntries.test.ts b/apps/server/src/workspaceEntries.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e3660609dfedac710decffe58022416fbd0a0690 --- /dev/null +++ b/apps/server/src/workspaceEntries.test.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import fsPromises from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +import { afterEach, assert, describe, it, vi } from "vitest"; + +import { searchWorkspaceEntries } from "./workspaceEntries"; + +const tempDirs: string[] = []; + +function makeTempDir(prefix: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +function writeFile(cwd: string, relativePath: string, contents = ""): void { + const absolutePath = path.join(cwd, relativePath); + fs.mkdirSync(path.dirname(absolutePath), { recursive: true }); + fs.writeFileSync(absolutePath, contents, "utf8"); +} + +function runGit(cwd: string, args: string[]): void { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) { + throw new Error(result.stderr || `git ${args.join(" ")} failed`); + } +} + +describe("searchWorkspaceEntries", () => { + afterEach(() => { + vi.restoreAllMocks(); + for (const dir of tempDirs.splice(0, tempDirs.length)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it("returns files and directories relative to cwd", async () => { + const cwd = makeTempDir("t3code-workspace-entries-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/index.ts"); + writeFile(cwd, "README.md"); + writeFile(cwd, ".git/HEAD"); + writeFile(cwd, "node_modules/pkg/index.js"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + assert.include(paths, "src"); + assert.include(paths, "src/components"); + assert.include(paths, "src/components/Composer.tsx"); + assert.include(paths, "README.md"); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".git"))); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith("node_modules"))); + assert.isFalse(result.truncated); + }); + + it("filters and ranks entries by query", async () => { + const cwd = makeTempDir("t3code-workspace-query-"); + writeFile(cwd, "src/components/Composer.tsx"); + writeFile(cwd, "src/components/composePrompt.ts"); + writeFile(cwd, "docs/composition.md"); + + const result = await searchWorkspaceEntries({ cwd, query: "compo", limit: 5 }); + + assert.isAbove(result.entries.length, 0); + assert.isTrue(result.entries.some((entry) => entry.path === "src/components")); + assert.isTrue(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))); + }); + + it("excludes gitignored paths for git repositories", async () => { + const cwd = makeTempDir("t3code-workspace-gitignore-"); + runGit(cwd, ["init"]); + writeFile(cwd, ".gitignore", ".convex/\nconvex/\nignored.txt\n"); + writeFile(cwd, "src/keep.ts", "export {};"); + writeFile(cwd, "ignored.txt", "ignore me"); + writeFile(cwd, ".convex/local-storage/data.json", "{}"); + writeFile(cwd, "convex/UOoS-l/convex_local_storage/modules/data.json", "{}"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + assert.include(paths, "src"); + assert.include(paths, "src/keep.ts"); + assert.notInclude(paths, "ignored.txt"); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith("convex/"))); + }); + + it("excludes tracked paths that match ignore rules", async () => { + const cwd = makeTempDir("t3code-workspace-tracked-gitignore-"); + runGit(cwd, ["init"]); + writeFile(cwd, ".convex/local-storage/data.json", "{}"); + writeFile(cwd, "src/keep.ts", "export {};"); + runGit(cwd, ["add", ".convex/local-storage/data.json", "src/keep.ts"]); + writeFile(cwd, ".gitignore", ".convex/\n"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + assert.include(paths, "src"); + assert.include(paths, "src/keep.ts"); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); + }); + + it("excludes .convex in non-git workspaces", async () => { + const cwd = makeTempDir("t3code-workspace-non-git-convex-"); + writeFile(cwd, ".convex/local-storage/data.json", "{}"); + writeFile(cwd, "src/keep.ts", "export {};"); + + const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 }); + const paths = result.entries.map((entry) => entry.path); + + assert.include(paths, "src"); + assert.include(paths, "src/keep.ts"); + assert.isFalse(paths.some((entryPath) => entryPath.startsWith(".convex/"))); + }); + + it("deduplicates concurrent index builds for the same cwd", async () => { + const cwd = makeTempDir("t3code-workspace-concurrent-build-"); + writeFile(cwd, "src/components/Composer.tsx"); + + let rootReadCount = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation( + (async (...args: Parameters) => { + if (args[0] === cwd) { + rootReadCount += 1; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir, + ); + + await Promise.all([ + searchWorkspaceEntries({ cwd, query: "", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "comp", limit: 100 }), + searchWorkspaceEntries({ cwd, query: "src", limit: 100 }), + ]); + + assert.equal(rootReadCount, 1); + }); + + it("limits concurrent directory reads while walking the filesystem", async () => { + const cwd = makeTempDir("t3code-workspace-read-concurrency-"); + for (let index = 0; index < 80; index += 1) { + writeFile(cwd, `group-${index}/entry-${index}.ts`, "export {};"); + } + + let activeReads = 0; + let peakReads = 0; + const originalReaddir = fsPromises.readdir.bind(fsPromises); + vi.spyOn(fsPromises, "readdir").mockImplementation( + (async (...args: Parameters) => { + const target = args[0]; + if (typeof target === "string" && target.startsWith(cwd)) { + activeReads += 1; + peakReads = Math.max(peakReads, activeReads); + await new Promise((resolve) => setTimeout(resolve, 4)); + try { + return await originalReaddir(...args); + } finally { + activeReads -= 1; + } + } + return originalReaddir(...args); + }) as typeof fsPromises.readdir, + ); + + await searchWorkspaceEntries({ cwd, query: "", limit: 200 }); + + assert.isAtMost(peakReads, 32); + }); +}); diff --git a/apps/server/src/workspaceEntries.ts b/apps/server/src/workspaceEntries.ts new file mode 100644 index 0000000000000000000000000000000000000000..dae321ecb1c9a78c49f7d72d9bcf5b6a6a36c180 --- /dev/null +++ b/apps/server/src/workspaceEntries.ts @@ -0,0 +1,433 @@ +import fs from "node:fs/promises"; +import type { Dirent } from "node:fs"; +import path from "node:path"; +import { runProcess } from "./processRunner"; + +import { + projectSearchEntriesInputSchema, + type ProjectEntry, + type ProjectSearchEntriesInput, + type ProjectSearchEntriesResult, +} from "@t3tools/contracts"; + +const WORKSPACE_CACHE_TTL_MS = 15_000; +const WORKSPACE_CACHE_MAX_KEYS = 4; +const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; +const WORKSPACE_SCAN_READDIR_CONCURRENCY = 32; +const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024; +const IGNORED_DIRECTORY_NAMES = new Set([ + ".git", + ".convex", + "node_modules", + ".next", + ".turbo", + "dist", + "build", + "out", + ".cache", +]); + +interface WorkspaceIndex { + scannedAt: number; + entries: ProjectEntry[]; + truncated: boolean; +} + +const workspaceIndexCache = new Map(); +const inFlightWorkspaceIndexBuilds = new Map>(); + +function toPosixPath(input: string): string { + return input.split(path.sep).join("/"); +} + +function parentPathOf(input: string): string | undefined { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return undefined; + } + return input.slice(0, separatorIndex); +} + +function basenameOf(input: string): string { + const separatorIndex = input.lastIndexOf("/"); + if (separatorIndex === -1) { + return input; + } + return input.slice(separatorIndex + 1); +} + +function normalizeQuery(input: string): string { + return input + .trim() + .replace(/^[@./]+/, "") + .toLowerCase(); +} + +function scoreEntry(entry: ProjectEntry, query: string): number { + if (!query) { + return entry.kind === "directory" ? 0 : 1; + } + + const normalizedPath = entry.path.toLowerCase(); + const normalizedName = basenameOf(normalizedPath); + + if (normalizedName === query) return 0; + if (normalizedPath === query) return 1; + if (normalizedName.startsWith(query)) return 2; + if (normalizedPath.startsWith(query)) return 3; + if (normalizedPath.includes(`/${query}`)) return 4; + return 5; +} + +function isPathInIgnoredDirectory(relativePath: string): boolean { + const firstSegment = relativePath.split("/")[0]; + if (!firstSegment) return false; + return IGNORED_DIRECTORY_NAMES.has(firstSegment); +} + +function splitNullSeparatedPaths(input: string, truncated: boolean): string[] { + const parts = input.split("\0"); + if (parts.length === 0) return []; + + // If output was truncated, the final token can be partial. + if (truncated && parts[parts.length - 1]?.length) { + parts.pop(); + } + + return parts.filter((value) => value.length > 0); +} + +function directoryAncestorsOf(relativePath: string): string[] { + const segments = relativePath.split("/").filter((segment) => segment.length > 0); + if (segments.length <= 1) return []; + const directories: string[] = []; + for (let index = 1; index < segments.length; index += 1) { + directories.push(segments.slice(0, index).join("/")); + } + return directories; +} + +async function mapWithConcurrency( + items: readonly TInput[], + concurrency: number, + mapper: (item: TInput, index: number) => Promise, +): Promise { + if (items.length === 0) { + return []; + } + + const boundedConcurrency = Math.max(1, Math.min(concurrency, items.length)); + const results = Array.from({ length: items.length }) as TOutput[]; + let nextIndex = 0; + + const workers = Array.from({ length: boundedConcurrency }, async () => { + while (nextIndex < items.length) { + const currentIndex = nextIndex; + nextIndex += 1; + results[currentIndex] = await mapper(items[currentIndex] as TInput, currentIndex); + } + }); + + await Promise.all(workers); + return results; +} + +async function isInsideGitWorkTree(cwd: string): Promise { + const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], { + cwd, + allowNonZeroExit: true, + timeoutMs: 5_000, + maxBufferBytes: 4_096, + }).catch(() => null); + return Boolean( + insideWorkTree && insideWorkTree.code === 0 && insideWorkTree.stdout.trim() === "true", + ); +} + +async function filterGitIgnoredPaths(cwd: string, relativePaths: string[]): Promise { + if (relativePaths.length === 0) { + return relativePaths; + } + + const ignoredPaths = new Set(); + let chunk: string[] = []; + let chunkBytes = 0; + + const flushChunk = async (): Promise => { + if (chunk.length === 0) { + return true; + } + + const checkIgnore = await runProcess("git", ["check-ignore", "--no-index", "-z", "--stdin"], { + cwd, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxBufferBytes: 16 * 1024 * 1024, + outputMode: "truncate", + stdin: `${chunk.join("\0")}\0`, + }).catch(() => null); + chunk = []; + chunkBytes = 0; + + if (!checkIgnore) { + return false; + } + + // git-check-ignore exits with 1 when no paths match. + if (checkIgnore.code !== 0 && checkIgnore.code !== 1) { + return false; + } + + const matchedIgnoredPaths = splitNullSeparatedPaths( + checkIgnore.stdout, + Boolean(checkIgnore.stdoutTruncated), + ); + for (const ignoredPath of matchedIgnoredPaths) { + ignoredPaths.add(ignoredPath); + } + return true; + }; + + for (const relativePath of relativePaths) { + const relativePathBytes = Buffer.byteLength(relativePath) + 1; + if ( + chunk.length > 0 && + chunkBytes + relativePathBytes > GIT_CHECK_IGNORE_MAX_STDIN_BYTES && + !(await flushChunk()) + ) { + return relativePaths; + } + + chunk.push(relativePath); + chunkBytes += relativePathBytes; + + if (chunkBytes >= GIT_CHECK_IGNORE_MAX_STDIN_BYTES && !(await flushChunk())) { + return relativePaths; + } + } + + if (!(await flushChunk())) { + return relativePaths; + } + + if (ignoredPaths.size === 0) { + return relativePaths; + } + + return relativePaths.filter((relativePath) => !ignoredPaths.has(relativePath)); +} + +async function buildWorkspaceIndexFromGit(cwd: string): Promise { + if (!(await isInsideGitWorkTree(cwd))) { + return null; + } + + const listedFiles = await runProcess( + "git", + ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], + { + cwd, + allowNonZeroExit: true, + timeoutMs: 20_000, + maxBufferBytes: 16 * 1024 * 1024, + outputMode: "truncate", + }, + ).catch(() => null); + if (!listedFiles || listedFiles.code !== 0) { + return null; + } + + const listedPaths = splitNullSeparatedPaths( + listedFiles.stdout, + Boolean(listedFiles.stdoutTruncated), + ) + .map((entry) => toPosixPath(entry)) + .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry)); + const filePaths = await filterGitIgnoredPaths(cwd, listedPaths); + + const directorySet = new Set(); + for (const filePath of filePaths) { + for (const directoryPath of directoryAncestorsOf(filePath)) { + if (!isPathInIgnoredDirectory(directoryPath)) { + directorySet.add(directoryPath); + } + } + } + + const directoryEntries: ProjectEntry[] = [...directorySet] + .toSorted((left, right) => left.localeCompare(right)) + .map((directoryPath) => ({ + path: directoryPath, + kind: "directory", + parentPath: parentPathOf(directoryPath), + })); + const fileEntries: ProjectEntry[] = [...new Set(filePaths)] + .toSorted((left, right) => left.localeCompare(right)) + .map((filePath) => ({ + path: filePath, + kind: "file", + parentPath: parentPathOf(filePath), + })); + + const entries = [...directoryEntries, ...fileEntries]; + return { + scannedAt: Date.now(), + entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES), + truncated: Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES, + }; +} + +async function buildWorkspaceIndex(cwd: string): Promise { + const gitIndexed = await buildWorkspaceIndexFromGit(cwd); + if (gitIndexed) { + return gitIndexed; + } + const shouldFilterWithGitIgnore = await isInsideGitWorkTree(cwd); + + let pendingDirectories: string[] = [""]; + const entries: ProjectEntry[] = []; + let truncated = false; + + while (pendingDirectories.length > 0 && !truncated) { + const currentDirectories = pendingDirectories; + pendingDirectories = []; + const directoryEntries = await mapWithConcurrency( + currentDirectories, + WORKSPACE_SCAN_READDIR_CONCURRENCY, + async (relativeDir) => { + const absoluteDir = relativeDir ? path.join(cwd, relativeDir) : cwd; + try { + const dirents = await fs.readdir(absoluteDir, { withFileTypes: true }); + return { relativeDir, dirents }; + } catch (error) { + if (!relativeDir) { + throw new Error( + `Unable to scan workspace entries at '${cwd}': ${error instanceof Error ? error.message : "unknown error"}`, + { cause: error }, + ); + } + return { relativeDir, dirents: null }; + } + }, + ); + + const candidateEntriesByDirectory = directoryEntries.map((directoryEntry) => { + const { relativeDir, dirents } = directoryEntry; + if (!dirents) return [] as Array<{ dirent: Dirent; relativePath: string }>; + + dirents.sort((left, right) => left.name.localeCompare(right.name)); + const candidates: Array<{ dirent: Dirent; relativePath: string }> = []; + for (const dirent of dirents) { + if (!dirent.name || dirent.name === "." || dirent.name === "..") { + continue; + } + if (dirent.isDirectory() && IGNORED_DIRECTORY_NAMES.has(dirent.name)) { + continue; + } + if (!dirent.isDirectory() && !dirent.isFile()) { + continue; + } + + const relativePath = toPosixPath( + relativeDir ? path.join(relativeDir, dirent.name) : dirent.name, + ); + if (isPathInIgnoredDirectory(relativePath)) { + continue; + } + candidates.push({ dirent, relativePath }); + } + return candidates; + }); + + const candidatePaths = candidateEntriesByDirectory.flatMap((candidateEntries) => + candidateEntries.map((entry) => entry.relativePath), + ); + const allowedPathSet = shouldFilterWithGitIgnore + ? new Set(await filterGitIgnoredPaths(cwd, candidatePaths)) + : null; + + for (const candidateEntries of candidateEntriesByDirectory) { + for (const candidate of candidateEntries) { + if (allowedPathSet && !allowedPathSet.has(candidate.relativePath)) { + continue; + } + + const entry: ProjectEntry = { + path: candidate.relativePath, + kind: candidate.dirent.isDirectory() ? "directory" : "file", + parentPath: parentPathOf(candidate.relativePath), + }; + entries.push(entry); + + if (candidate.dirent.isDirectory()) { + pendingDirectories.push(candidate.relativePath); + } + + if (entries.length >= WORKSPACE_INDEX_MAX_ENTRIES) { + truncated = true; + break; + } + } + + if (truncated) { + break; + } + } + } + + return { + scannedAt: Date.now(), + entries, + truncated, + }; +} + +async function getWorkspaceIndex(cwd: string): Promise { + const cached = workspaceIndexCache.get(cwd); + if (cached && Date.now() - cached.scannedAt < WORKSPACE_CACHE_TTL_MS) { + return cached; + } + + const inFlight = inFlightWorkspaceIndexBuilds.get(cwd); + if (inFlight) { + return inFlight; + } + + const nextPromise = buildWorkspaceIndex(cwd) + .then((next) => { + workspaceIndexCache.set(cwd, next); + while (workspaceIndexCache.size > WORKSPACE_CACHE_MAX_KEYS) { + const oldestKey = workspaceIndexCache.keys().next().value; + if (!oldestKey) break; + workspaceIndexCache.delete(oldestKey); + } + return next; + }) + .finally(() => { + inFlightWorkspaceIndexBuilds.delete(cwd); + }); + inFlightWorkspaceIndexBuilds.set(cwd, nextPromise); + return nextPromise; +} + +export async function searchWorkspaceEntries( + input: ProjectSearchEntriesInput, +): Promise { + const parsed = projectSearchEntriesInputSchema.parse(input); + const index = await getWorkspaceIndex(parsed.cwd); + const normalizedQuery = normalizeQuery(parsed.query); + const candidates = normalizedQuery + ? index.entries.filter((entry) => entry.path.toLowerCase().includes(normalizedQuery)) + : index.entries; + + const ranked = candidates.toSorted((left, right) => { + const scoreDelta = scoreEntry(left, normalizedQuery) - scoreEntry(right, normalizedQuery); + if (scoreDelta !== 0) return scoreDelta; + return left.path.localeCompare(right.path); + }); + + return { + entries: ranked.slice(0, parsed.limit), + truncated: index.truncated || ranked.length > parsed.limit, + }; +} diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d7ab19ce7f55d0adbc12c61111b97d36ceecb38f..6e1b8c0b6afac501faac6adb2e405d2895fa3927 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -323,6 +323,8 @@ describe("WebSocket Server", () => { }); it("responds to server.getConfig", async () => { + const fakeHome = makeTempDir("t3code-home-"); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); server = createTestServer({ cwd: "/my/workspace" }); await server.start(); const addr = server.httpServer.address(); @@ -491,6 +493,59 @@ describe("WebSocket Server", () => { expect(secondResponse.result).toEqual(firstResponse.result); }); + it("upserts keybinding rules and updates cached server config", async () => { + const fakeHome = makeTempDir("t3code-home-upsert-keybinding-"); + const configDir = path.join(fakeHome, ".t3"); + fs.mkdirSync(configDir, { recursive: true }); + const keybindingsPath = path.join(configDir, "keybindings.json"); + fs.writeFileSync( + keybindingsPath, + JSON.stringify([{ key: "mod+j", command: "terminal.toggle" }]), + "utf8", + ); + vi.spyOn(os, "homedir").mockReturnValue(fakeHome); + + server = createTestServer({ cwd: "/my/workspace" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const upsertResponse = await sendRequest(ws, WS_METHODS.serverUpsertKeybinding, { + key: "mod+shift+r", + command: "script.run-tests.run", + }); + expect(upsertResponse.error).toBeUndefined(); + expect(upsertResponse.result).toEqual({ + keybindings: mergeWithDefaultsForTest([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]), + }); + + const persistedConfig = JSON.parse(fs.readFileSync(keybindingsPath, "utf8")) as Array<{ + key: string; + command: string; + }>; + expect(persistedConfig).toEqual([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + + const configResponse = await sendRequest(ws, WS_METHODS.serverGetConfig); + expect(configResponse.error).toBeUndefined(); + expect(configResponse.result).toEqual({ + cwd: "/my/workspace", + keybindings: mergeWithDefaultsForTest([ + { key: "mod+j", command: "terminal.toggle" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]), + }); + }); + it("warns and ignores unsupported keybindings config format", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const fakeHome = makeTempDir("t3code-home-unsupported-format-"); @@ -591,6 +646,38 @@ describe("WebSocket Server", () => { expect(response.result).toEqual([]); }); + it("returns unknown-session errors for checkpoint RPCs without an active provider session", async () => { + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const listResponse = await sendRequest(ws, WS_METHODS.providersListCheckpoints, { + sessionId: "missing-session", + }); + expect(listResponse.result).toBeUndefined(); + expect(listResponse.error?.message).toContain("Unknown provider session"); + + const revertResponse = await sendRequest(ws, WS_METHODS.providersRevertToCheckpoint, { + sessionId: "missing-session", + turnCount: 0, + }); + expect(revertResponse.result).toBeUndefined(); + expect(revertResponse.error?.message).toContain("Unknown provider session"); + + const diffResponse = await sendRequest(ws, WS_METHODS.providersGetCheckpointDiff, { + sessionId: "missing-session", + fromTurnCount: 0, + toTurnCount: 1, + }); + expect(diffResponse.result).toBeUndefined(); + expect(diffResponse.error?.message).toContain("Unknown provider session"); + }); + it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); @@ -756,6 +843,42 @@ describe("WebSocket Server", () => { expect(projectId).toBeTruthy(); if (!projectId) return; + const updatedScripts = await sendRequest(ws, WS_METHODS.projectsUpdateScripts, { + id: projectId, + scripts: [ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }); + expect(updatedScripts.error).toBeUndefined(); + const scriptPayload = ( + updatedScripts.result as { + project: { + scripts: Array<{ + id: string; + name: string; + command: string; + icon: string; + runOnWorktreeCreate: boolean; + }>; + }; + } + ).project.scripts; + expect(scriptPayload).toEqual([ + { + id: "setup", + name: "Setup", + command: "bun install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ]); + const removed = await sendRequest(ws, WS_METHODS.projectsRemove, { id: projectId, }); @@ -766,6 +889,38 @@ describe("WebSocket Server", () => { expect(afterRemove.result).toEqual([]); }); + it("supports projects.searchEntries", async () => { + const workspace = makeTempDir("t3code-ws-workspace-entries-"); + fs.mkdirSync(path.join(workspace, "src", "components"), { recursive: true }); + fs.writeFileSync(path.join(workspace, "src", "components", "Composer.tsx"), "export {};", "utf8"); + fs.writeFileSync(path.join(workspace, "README.md"), "# test", "utf8"); + fs.mkdirSync(path.join(workspace, ".git"), { recursive: true }); + fs.writeFileSync(path.join(workspace, ".git", "HEAD"), "ref: refs/heads/main\n", "utf8"); + + server = createTestServer({ cwd: "/test" }); + await server.start(); + const addr = server.httpServer.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.projectsSearchEntries, { + cwd: workspace, + query: "comp", + limit: 10, + }); + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ + entries: expect.arrayContaining([ + expect.objectContaining({ path: "src/components", kind: "directory" }), + expect.objectContaining({ path: "src/components/Composer.tsx", kind: "file" }), + ]), + truncated: false, + }); + }); + it("supports git methods over websocket", async () => { const repoCwd = makeTempDir("t3code-ws-git-project-"); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a8cbbba51e572a2efd94aa8a6548719136c36991..325727827d2fb6a3e68106b4d0c8e84a8fd1b29e 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -31,7 +31,8 @@ import { removeGitWorktree, } from "./git"; import { TerminalManager } from "./terminalManager"; -import { loadResolvedKeybindingsConfig } from "./keybindings"; +import { loadResolvedKeybindingsConfig, upsertKeybindingRule } from "./keybindings"; +import { searchWorkspaceEntries } from "./workspaceEntries"; const MIME_TYPES: Record = { ".html": "text/html; charset=utf-8", @@ -91,7 +92,7 @@ export function createServer(options: ServerOptions) { const logger = createLogger("ws"); const logWebSocketEvents = explicitLogWsEvents ?? parseBooleanEnv(process.env.T3CODE_LOG_WS_EVENTS) ?? Boolean(devUrl); - const keybindingsConfig = loadResolvedKeybindingsConfig(logger); + let keybindingsConfig = loadResolvedKeybindingsConfig(logger); function logOutgoingPush(push: WsPush, recipients: number) { if (!logWebSocketEvents) return; @@ -309,6 +310,15 @@ export function createServer(options: ServerOptions) { case WS_METHODS.providersListSessions: return providerManager.listSessions(); + case WS_METHODS.providersListCheckpoints: + return providerManager.listCheckpoints(request.params as never); + + case WS_METHODS.providersGetCheckpointDiff: + return providerManager.getCheckpointDiff(request.params as never); + + case WS_METHODS.providersRevertToCheckpoint: + return providerManager.revertToCheckpoint(request.params as never); + case WS_METHODS.projectsList: return projectRegistry.list(); @@ -319,6 +329,11 @@ export function createServer(options: ServerOptions) { projectRegistry.remove(request.params as never); return undefined; + case WS_METHODS.projectsSearchEntries: + return searchWorkspaceEntries(request.params as never); + case WS_METHODS.projectsUpdateScripts: + return projectRegistry.updateScripts(request.params as never); + case WS_METHODS.shellOpenInEditor: { const params = request.params as { cwd: string; @@ -417,6 +432,12 @@ export function createServer(options: ServerOptions) { keybindings: keybindingsConfig, }; + case WS_METHODS.serverUpsertKeybinding: + keybindingsConfig = upsertKeybindingRule(logger, request.params); + return { + keybindings: keybindingsConfig, + }; + default: throw new Error(`Unknown method: ${request.method}`); } diff --git a/apps/web/index.html b/apps/web/index.html index 25aace82a32ba91765675ee14343205f69d2358e..0322f2d019cc2816baaed3ed1005073c16f0598a 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -3,13 +3,15 @@ + + - T3 Code + T3 Code (Alpha)
diff --git a/apps/web/package.json b/apps/web/package.json index 8f610f069f1d2c0a4a234516873e0647f2130df4..e3aa497bd76a29854840f65e5c9474cb3fbcce92 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,8 +12,12 @@ }, "dependencies": { "@base-ui/react": "^1.2.0", + "@pierre/diffs": "^1.1.0-beta.16", "@t3tools/contracts": "workspace:*", + "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-query": "^5.90.0", + "@tanstack/react-router": "^1.160.2", + "@tanstack/react-virtual": "^3.13.18", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "class-variance-authority": "^0.7.1", @@ -29,6 +33,7 @@ }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", + "@tanstack/router-plugin": "^1.161.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^5.1.4", diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..43dc5d21602cc217500121410827b603a6a6fe5e Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1636b52162945be4ea63d866a9cb22ae68bb4ed0 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b84a6ff27a093136a3511018cd15b6bb6bb9fff5 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx deleted file mode 100644 index fbb1177bf1bbfe6f2170a338e374082dbfc48cbd..0000000000000000000000000000000000000000 --- a/apps/web/src/App.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; -import { useEffect, useRef } from "react"; - -import ChatView from "./components/ChatView"; -import DiffPanel from "./components/DiffPanel"; -import Sidebar from "./components/Sidebar"; -import { isElectron } from "./env"; -import { DEFAULT_MODEL } from "./model-logic"; -import { StoreProvider, useStore } from "./store"; -import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./types"; -import { onServerWelcome } from "./wsNativeApi"; -import { useNativeApi } from "./hooks/useNativeApi"; -import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; -import { invalidateGitQueries } from "./lib/gitReactQuery"; - -function EventRouter() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const queryClient = useQueryClient(); - const activeAssistantItemRef = useRef(null); - - useEffect(() => { - if (!api) return; - return api.providers.onEvent((event) => { - if (event.method === "turn/completed") { - void invalidateGitQueries(queryClient); - } - dispatch({ - type: "APPLY_EVENT", - event, - activeAssistantItemRef, - }); - }); - }, [api, dispatch, queryClient]); - - useEffect(() => { - if (!api) return; - return api.terminal.onEvent((event) => { - dispatch({ - type: "APPLY_TERMINAL_EVENT", - event, - }); - }); - }, [api, dispatch]); - - return null; -} - -function AutoProjectBootstrap() { - const { state, dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - // Browser mode bootstraps from server welcome. - // Electron bootstraps from persisted projects via DesktopProjectBootstrap. - if (isElectron) return; - - return onServerWelcome((payload) => { - if (bootstrappedRef.current) return; - - // Don't create duplicate projects for the same cwd - const existing = state.projects.find((p) => p.cwd === payload.cwd); - if (existing) { - bootstrappedRef.current = true; - // Ensure a thread is active - const existingThread = state.threads.find((t) => t.projectId === existing.id); - if (existingThread && !state.activeThreadId) { - dispatch({ - type: "SET_ACTIVE_THREAD", - threadId: existingThread.id, - }); - } - return; - } - - bootstrappedRef.current = true; - - // Create project + thread from server cwd - const projectId = crypto.randomUUID(); - dispatch({ - type: "ADD_PROJECT", - project: { - id: projectId, - name: payload.projectName, - cwd: payload.cwd, - model: DEFAULT_MODEL, - expanded: true, - }, - }); - dispatch({ - type: "ADD_THREAD", - thread: { - id: crypto.randomUUID(), - codexThreadId: null, - projectId, - title: "New thread", - model: DEFAULT_MODEL, - terminalOpen: false, - terminalHeight: DEFAULT_THREAD_TERMINAL_HEIGHT, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - runningTerminalIds: [], - activeTerminalId: DEFAULT_THREAD_TERMINAL_ID, - terminalGroups: [ - { - id: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - terminalIds: [DEFAULT_THREAD_TERMINAL_ID], - }, - ], - activeTerminalGroupId: `group-${DEFAULT_THREAD_TERMINAL_ID}`, - session: null, - messages: [], - events: [], - error: null, - createdAt: new Date().toISOString(), - branch: null, - worktreePath: null, - }, - }); - }); - }, [state.projects, state.threads, state.activeThreadId, dispatch]); - - return null; -} - -function DesktopProjectBootstrap() { - const api = useNativeApi(); - const { dispatch } = useStore(); - const bootstrappedRef = useRef(false); - - useEffect(() => { - if (!isElectron || !api || bootstrappedRef.current) return; - - let disposed = false; - let retryDelayMs = 500; - let retryTimer: ReturnType | null = null; - - const attemptBootstrap = async () => { - try { - const projects = await api.projects.list(); - if (disposed) return; - dispatch({ - type: "SYNC_PROJECTS", - projects: projects.map((project) => ({ - id: project.id, - name: project.name, - cwd: project.cwd, - model: DEFAULT_MODEL, - expanded: true, - })), - }); - bootstrappedRef.current = true; - } catch { - if (disposed) return; - retryTimer = setTimeout(() => { - retryTimer = null; - void attemptBootstrap(); - }, retryDelayMs); - retryDelayMs = Math.min(retryDelayMs * 2, 5_000); - } - }; - - void attemptBootstrap(); - - return () => { - disposed = true; - if (retryTimer) { - clearTimeout(retryTimer); - } - }; - }, [api, dispatch]); - - return null; -} - -function Layout() { - const api = useNativeApi(); - const { state } = useStore(); - - if (!api) { - return ( -
-
-

Connecting to T3 Code server...

-
-
- ); - } - - return ( -
- - - - - - {state.diffOpen && } -
- ); -} - -const queryClient = new QueryClient(); - -export default function App() { - return ( - - - - - - - - - - ); -} diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd3da7224c7855b1c2cd446925bb5bcbe663e479 --- /dev/null +++ b/apps/web/src/appSettings.ts @@ -0,0 +1,109 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { z } from "zod"; + +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; + +const appSettingsSchema = z.object({ + codexBinaryPath: z.string().trim().max(4096).default(""), + codexHomePath: z.string().trim().max(4096).default(""), + confirmThreadDelete: z.boolean().default(true), +}); + +export type AppSettings = z.infer; + +const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); + +let listeners: Array<() => void> = []; +let cachedRawSettings: string | null | undefined; +let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +function parsePersistedSettings(value: string | null): AppSettings { + if (!value) { + return DEFAULT_APP_SETTINGS; + } + + try { + const parsed = JSON.parse(value) as unknown; + return appSettingsSchema.parse(parsed); + } catch { + return DEFAULT_APP_SETTINGS; + } +} + +export function getAppSettingsSnapshot(): AppSettings { + if (typeof window === "undefined") { + return DEFAULT_APP_SETTINGS; + } + + const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); + if (raw === cachedRawSettings) { + return cachedSnapshot; + } + + cachedRawSettings = raw; + cachedSnapshot = parsePersistedSettings(raw); + return cachedSnapshot; +} + +function persistSettings(next: AppSettings): void { + if (typeof window === "undefined") return; + + const raw = JSON.stringify(next); + try { + if (raw !== cachedRawSettings) { + window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); + } + } catch { + // Best-effort persistence only. + } + + cachedRawSettings = raw; + cachedSnapshot = next; +} + +function subscribe(listener: () => void): () => void { + listeners.push(listener); + + const onStorage = (event: StorageEvent) => { + if (event.key === APP_SETTINGS_STORAGE_KEY) { + emitChange(); + } + }; + + window.addEventListener("storage", onStorage); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + window.removeEventListener("storage", onStorage); + }; +} + +export function useAppSettings() { + const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); + + const updateSettings = useCallback((patch: Partial) => { + const next = appSettingsSchema.parse({ + ...getAppSettingsSnapshot(), + ...patch, + }); + persistSettings(next); + emitChange(); + }, []); + + const resetSettings = useCallback(() => { + persistSettings(DEFAULT_APP_SETTINGS); + emitChange(); + }, []); + + return { + settings, + updateSettings, + resetSettings, + defaults: DEFAULT_APP_SETTINGS, + } as const; +} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts new file mode 100644 index 0000000000000000000000000000000000000000..4adc50bc1932e991c7c6d730c982305f5036fdc2 --- /dev/null +++ b/apps/web/src/branding.ts @@ -0,0 +1,3 @@ +export const APP_BASE_NAME = "T3 Code"; +export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; +export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; diff --git a/apps/web/src/chat-scroll.test.ts b/apps/web/src/chat-scroll.test.ts index 4d85278bc1fcd0c2d729d8a41c1462c363aa9613..5311fb40aed522803511cda90ee52b38988f0267 100644 --- a/apps/web/src/chat-scroll.test.ts +++ b/apps/web/src/chat-scroll.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; -import { - AUTO_SCROLL_BOTTOM_THRESHOLD_PX, - isScrollContainerNearBottom, -} from "./chat-scroll"; +import { AUTO_SCROLL_BOTTOM_THRESHOLD_PX, isScrollContainerNearBottom } from "./chat-scroll"; describe("isScrollContainerNearBottom", () => { it("returns true when already at bottom", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd2a25d06999f166a6d838a349376b000b2c5246 --- /dev/null +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -0,0 +1,56 @@ +import type { GitBranch } from "@t3tools/contracts"; +import { describe, expect, it } from "vitest"; +import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; + +const branches: GitBranch[] = [ + { + name: "main", + current: true, + isDefault: true, + worktreePath: null, + }, + { + name: "feature/demo", + current: false, + isDefault: false, + worktreePath: null, + }, +]; + +describe("deriveSyncedLocalBranch", () => { + it("syncs to git current branch in local mode", () => { + const result = deriveSyncedLocalBranch({ + activeThreadId: "thread-1", + activeWorktreePath: null, + envMode: "local", + activeThreadBranch: "feature/demo", + queryBranches: branches, + }); + + expect(result).toBe("main"); + }); + + it("does not sync when creating a new worktree", () => { + const result = deriveSyncedLocalBranch({ + activeThreadId: "thread-1", + activeWorktreePath: null, + envMode: "worktree", + activeThreadBranch: "feature/demo", + queryBranches: branches, + }); + + expect(result).toBeNull(); + }); + + it("does not sync when thread already targets a worktree path", () => { + const result = deriveSyncedLocalBranch({ + activeThreadId: "thread-1", + activeWorktreePath: "/tmp/repo/worktrees/feature-demo", + envMode: "local", + activeThreadBranch: "feature/demo", + queryBranches: branches, + }); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts new file mode 100644 index 0000000000000000000000000000000000000000..03d04bc3cf39c65a66edd6480242c201abc9ca19 --- /dev/null +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -0,0 +1,28 @@ +import type { GitBranch } from "@t3tools/contracts"; + +interface DeriveSyncedLocalBranchInput { + activeThreadId: string | undefined; + activeWorktreePath: string | null; + envMode: "local" | "worktree"; + activeThreadBranch: string | null; + queryBranches: GitBranch[] | undefined; +} + +export function deriveSyncedLocalBranch({ + activeThreadId, + activeWorktreePath, + envMode, + activeThreadBranch, + queryBranches, +}: DeriveSyncedLocalBranchInput): string | null { + if (!activeThreadId || activeWorktreePath || envMode !== "local") { + return null; + } + + const currentBranch = queryBranches?.find((branch) => branch.current); + if (!currentBranch || currentBranch.name === activeThreadBranch) { + return null; + } + + return currentBranch.name; +} diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 1e6dd9e923c3f22153196bd54f5fabc61a5d1e33..21113658fcd3d99320b2a19cdbd1f5fa2873f5a8 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,6 +9,7 @@ import { gitCreateBranchAndCheckoutMutationOptions, } from "../lib/gitReactQuery"; import { useStore } from "../store"; +import { deriveSyncedLocalBranch } from "./BranchToolbar.logic"; import { Button } from "./ui/button"; import { Combobox, @@ -22,6 +23,7 @@ import { import { ChevronDownIcon } from "lucide-react"; interface BranchToolbarProps { + threadId: string; envMode: "local" | "worktree"; onEnvModeChange: (mode: "local" | "worktree") => void; envLocked: boolean; @@ -29,6 +31,7 @@ interface BranchToolbarProps { } export default function BranchToolbar({ + threadId, envMode, onEnvModeChange, envLocked, @@ -41,7 +44,7 @@ export default function BranchToolbar({ const [isBranchMenuOpen, setIsBranchMenuOpen] = useState(false); const [branchQuery, setBranchQuery] = useState(""); - const activeThread = state.threads.find((thread) => thread.id === state.activeThreadId); + const activeThread = state.threads.find((thread) => thread.id === threadId); const activeProject = state.projects.find((project) => project.id === activeThread?.projectId); const activeThreadId = activeThread?.id; const activeThreadBranch = activeThread?.branch ?? null; @@ -67,36 +70,35 @@ export default function BranchToolbar({ : branchNames; // ── Mutations ───────────────────────────────────────────────────────── - const checkoutMutation = useMutation({ - ...gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), - onError: (error) => { - setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); - setIsBranchMenuOpen(true); - }, - }); + const checkoutMutation = useMutation( + gitCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), + ); - const createBranchMutation = useMutation({ - ...gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), - onError: (error) => - setThreadError(error instanceof Error ? error.message : "Failed to create branch."), - }); + const createBranchMutation = useMutation( + gitCreateBranchAndCheckoutMutationOptions({ api, cwd: branchCwd, queryClient }), + ); // ── Effects ─────────────────────────────────────────────────────────── // Keep thread branch synced to git current branch for local threads. const queryBranches = branchesQuery.data?.branches; useEffect(() => { - if (!activeThreadId || activeWorktreePath) return; - const current = queryBranches?.find((branch) => branch.current); - if (!current) return; - if (current.name === activeThreadBranch) return; + const syncedBranch = deriveSyncedLocalBranch({ + activeThreadId, + activeWorktreePath, + envMode, + activeThreadBranch, + queryBranches, + }); + if (!activeThreadId || !syncedBranch) return; + dispatch({ type: "SET_THREAD_BRANCH", threadId: activeThreadId, - branch: current.name, + branch: syncedBranch, worktreePath: null, }); - }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, dispatch]); + }, [activeThreadId, activeWorktreePath, activeThreadBranch, queryBranches, envMode, dispatch]); useEffect(() => { if (isBranchMenuOpen) return; @@ -153,6 +155,10 @@ export default function BranchToolbar({ setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }, + onError: (error) => { + setThreadError(error instanceof Error ? error.message : "Failed to checkout branch."); + setIsBranchMenuOpen(true); + }, }); }; @@ -167,6 +173,9 @@ export default function BranchToolbar({ setIsBranchMenuOpen(false); onComposerFocusRequest?.(); }, + onError: (error) => { + setThreadError(error instanceof Error ? error.message : "Failed to create branch."); + }, }); }; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 382d30b53664880cbf5e3d48dc15a82606088085..92a6a7392854bf12d7a1a033bb5dc2b57c3ccc11 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,8 +1,10 @@ import { EDITORS, type EditorId, - ModelSlug, type NativeApi, + type ProjectEntry, + type ProjectScript, + ModelSlug, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, @@ -14,7 +16,6 @@ import { type ClipboardEvent, type DragEvent, type FormEvent, - Fragment, type KeyboardEvent, memo, type RefObject, @@ -25,12 +26,24 @@ import { useRef, useState, } from "react"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; -import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; +import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; +import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useAppSettings } from "../appSettings"; import { buildBootstrapInput } from "../historyBootstrap"; +import { + type ComposerTriggerKind, + detectComposerTrigger, + replaceTextRange, +} from "../composer-logic"; import { DEFAULT_MODEL, DEFAULT_REASONING, @@ -42,7 +55,9 @@ import { import { derivePendingApprovals, derivePhase, + deriveTurnDiffFilesFromUnifiedDiff, deriveTimelineEntries, + type TurnDiffSummary, type PendingApproval, deriveWorkLogEntries, formatDuration, @@ -51,15 +66,21 @@ import { } from "../session-logic"; import { isScrollContainerNearBottom } from "../chat-scroll"; import { useStore } from "../store"; -import type { ChatImageAttachment } from "../types"; +import { truncateTitle } from "../truncateTitle"; +import { + DEFAULT_THREAD_TERMINAL_ID, + MAX_THREAD_TERMINAL_COUNT, + type ChatImageAttachment, + type TurnDiffFileChange, +} from "../types"; +import { basenameOfPath, getVscodeIconUrlForEntry } from "../vscode-icons"; +import { useTheme } from "../hooks/useTheme"; +import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; import { isOpenFavoriteEditorShortcut, - isTerminalCloseShortcut, - isTerminalNewShortcut, - isTerminalSplitShortcut, - isTerminalToggleShortcut, + resolveShortcutCommand, shortcutLabelForCommand, } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; @@ -67,12 +88,17 @@ import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { useNativeApi } from "../hooks/useNativeApi"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; import { + BotIcon, ChevronDownIcon, CircleAlertIcon, + FileIcon, + FolderIcon, + DiffIcon, FolderClosedIcon, InfoIcon, LockIcon, LockOpenIcon, + Undo2Icon, XIcon, } from "lucide-react"; import { Button } from "./ui/button"; @@ -83,6 +109,16 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" import { CursorIcon, Icon } from "./Icons"; import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { Badge } from "./ui/badge"; +import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; +import ProjectScriptsControl, { type NewProjectScriptInput } from "./ProjectScriptsControl"; +import { + commandForProjectScript, + nextProjectScriptId, + projectScriptRuntimeEnv, + projectScriptIdFromCommand, + setupProjectScript, +} from "~/projectScripts"; +import { Toggle } from "./ui/toggle"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -90,11 +126,40 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { } const LAST_EDITOR_KEY = "t3code:last-editor"; +const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; const IMAGE_ONLY_BOOTSTRAP_PROMPT = "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; +const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; +const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; +const SEARCHABLE_MODEL_OPTIONS = MODEL_OPTIONS.map(({ slug, name }) => ({ + slug, + name, + searchSlug: slug.toLowerCase(), + searchName: name.toLowerCase(), +})); +const SCRIPT_TERMINAL_COLS = 120; +const SCRIPT_TERMINAL_ROWS = 30; + +function readLastInvokedScriptByProjectFromStorage(): Record { + const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); + if (!stored) return {}; + + try { + const parsed: unknown = JSON.parse(stored); + if (!parsed || typeof parsed !== "object") return {}; + return Object.fromEntries( + Object.entries(parsed).filter( + (entry): entry is [string, string] => + typeof entry[0] === "string" && typeof entry[1] === "string", + ), + ); + } catch { + return {}; + } +} function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { if (tone === "error") return "text-rose-300/50 dark:text-rose-300/50"; @@ -121,6 +186,56 @@ interface ExpandedImagePreview { name: string; } +type TurnCheckpointFilesState = + | { + status: "pending" | "loading"; + files: TurnDiffFileChange[]; + errorMessage?: undefined; + } + | { + status: "error"; + files: TurnDiffFileChange[]; + errorMessage: string; + } + | { + status: "ready"; + files: TurnDiffFileChange[]; + errorMessage?: undefined; + }; + +function checkpointErrorMessage(error: unknown): string { + if (error instanceof Error && error.message.trim().length > 0) { + return error.message; + } + if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return "Checkpoint diff is unavailable."; +} + +type ComposerCommandItem = + | { + id: string; + type: "path"; + path: string; + pathKind: ProjectEntry["kind"]; + label: string; + description: string; + } + | { + id: string; + type: "slash-command"; + label: string; + description: string; + } + | { + id: string; + type: "model"; + model: ModelSlug; + label: string; + description: string; + }; + function readFileAsDataUrl(file: File): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -138,19 +253,147 @@ function readFileAsDataUrl(file: File): Promise { }); } -export default function ChatView() { +const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { + pathValue: string; + kind: "file" | "directory"; + theme: "light" | "dark"; +}) { + const [failed, setFailed] = useState(false); + const iconUrl = useMemo( + () => getVscodeIconUrlForEntry(props.pathValue, props.kind, props.theme), + [props.kind, props.pathValue, props.theme], + ); + + useEffect(() => { + setFailed(false); + }, [iconUrl]); + + if (failed) { + return props.kind === "directory" ? ( + + ) : ( + + ); + } + + return ( + setFailed(true)} + /> + ); +}); + +const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { + item: ComposerCommandItem; + resolvedTheme: "light" | "dark"; + onSelect: (item: ComposerCommandItem) => void; +}) { + return ( + { + event.preventDefault(); + }} + onClick={() => { + props.onSelect(props.item); + }} + > + {props.item.type === "path" ? ( + + ) : null} + {props.item.type === "slash-command" ? ( + + ) : null} + {props.item.type === "model" ? ( + + model + + ) : null} + {props.item.label} + {props.item.description} + + ); +}); + +const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { + items: ComposerCommandItem[]; + resolvedTheme: "light" | "dark"; + isLoading: boolean; + triggerKind: ComposerTriggerKind | null; + onHighlightedItemChange: (itemId: string | null) => void; + onSelect: (item: ComposerCommandItem) => void; + commandInputRef: RefObject; +}) { + return ( + { + props.onHighlightedItemChange( + typeof highlightedValue === "string" ? highlightedValue : null, + ); + }} + > +
+
+ +
+ + {props.items.map((item) => ( + + ))} + + {props.items.length === 0 && ( +

+ {props.isLoading + ? "Searching workspace files..." + : props.triggerKind === "path" + ? "No matching files or folders." + : "No matching command."} +

+ )} +
+
+ ); +}); + +interface ChatViewProps { + threadId: string; +} + +export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); + const navigate = useNavigate(); + const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); + const { resolvedTheme } = useTheme(); + const { settings: appSettings } = useAppSettings(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), ); const [prompt, setPrompt] = useState(""); + const promptRef = useRef(prompt); const [composerImages, setComposerImages] = useState([]); const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); + const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [selectedEffort, setSelectedEffort] = useState(DEFAULT_REASONING); const [envMode, setEnvMode] = useState<"local" | "worktree">("local"); const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); @@ -158,23 +401,37 @@ export default function ChatView() { const [expandedWorkGroups, setExpandedWorkGroups] = useState>({}); const [nowTick, setNowTick] = useState(() => Date.now()); const [terminalFocusRequestId, setTerminalFocusRequestId] = useState(0); + const [composerCursor, setComposerCursor] = useState(0); + const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< + Record + >(() => readLastInvokedScriptByProjectFromStorage()); const messagesScrollRef = useRef(null); - const messagesEndRef = useRef(null); const shouldAutoScrollRef = useRef(true); const textareaRef = useRef(null); + const composerCommandInputRef = useRef(null); const composerImagesRef = useRef([]); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); + const checkpointHydrationSessionRequestRef = useRef(new Set()); + const checkpointTurnCountSyncFingerprintRef = useRef(new Map()); - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = state.threads.find((t) => t.id === threadId); + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; - const activeSessionId = activeThread?.session?.sessionId; + const activeSessionId = activeThread?.session?.sessionId ?? null; + const activeThreadRuntimeId = + activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const selectedModel = resolveModelSlug( activeThread?.model ?? activeProject?.model ?? DEFAULT_MODEL, ); const phase = derivePhase(activeThread?.session ?? null); - const isWorking = phase === "running" || isSending || isConnecting; + const isWorking = phase === "running" || isSending || isConnecting || isRevertingCheckpoint; const nowIso = new Date(nowTick).toISOString(); const workLogEntries = useMemo( () => deriveWorkLogEntries(activeThread?.events ?? [], undefined), @@ -202,6 +459,142 @@ export default function ChatView() { () => deriveTimelineEntries(activeThread?.messages ?? [], workLogEntries), [activeThread?.messages, workLogEntries], ); + const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = + useTurnDiffSummaries(activeThread); + const turnDiffSummaryByAssistantMessageId = useMemo(() => { + const byMessageId = new Map(); + for (const summary of turnDiffSummaries) { + if (!summary.assistantMessageId) continue; + byMessageId.set(summary.assistantMessageId, summary); + } + return byMessageId; + }, [turnDiffSummaries]); + const checkpointDiffInputsByTurn = useMemo( + () => + turnDiffSummaries + .filter((summary) => summary.assistantMessageId) + .map((summary) => { + const checkpointTurnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + return { + turnId: summary.turnId, + checkpointTurnCount: + typeof checkpointTurnCount === "number" ? checkpointTurnCount : null, + }; + }), + [inferredCheckpointTurnCountByTurnId, turnDiffSummaries], + ); + const checkpointDiffQueriesByTurn = useQueries({ + queries: checkpointDiffInputsByTurn.map((input) => + checkpointDiffQueryOptions(api, { + sessionId: activeSessionId, + threadRuntimeId: activeThreadRuntimeId, + fromTurnCount: + typeof input.checkpointTurnCount === "number" + ? Math.max(0, input.checkpointTurnCount - 1) + : null, + toTurnCount: input.checkpointTurnCount, + cacheScope: `turn:${input.turnId}`, + }), + ), + }); + const turnCheckpointFilesByTurnId = useMemo(() => { + const byTurnId = new Map(); + for (let index = 0; index < checkpointDiffInputsByTurn.length; index += 1) { + const input = checkpointDiffInputsByTurn[index]; + const query = checkpointDiffQueriesByTurn[index]; + if (!input || !query) continue; + + if (!activeSessionId || typeof input.checkpointTurnCount !== "number") { + byTurnId.set(input.turnId, { status: "pending", files: [] }); + continue; + } + + const diff = query.data?.diff; + if (typeof diff === "string") { + byTurnId.set(input.turnId, { + status: "ready", + files: deriveTurnDiffFilesFromUnifiedDiff(diff), + }); + continue; + } + + if (query.isError) { + byTurnId.set(input.turnId, { + status: "error", + files: [], + errorMessage: checkpointErrorMessage(query.error), + }); + continue; + } + + byTurnId.set(input.turnId, { status: "loading", files: [] }); + } + return byTurnId; + }, [activeSessionId, checkpointDiffInputsByTurn, checkpointDiffQueriesByTurn]); + const revertTurnCountByUserMessageId = useMemo(() => { + const byUserMessageId = new Map(); + for (let index = 0; index < timelineEntries.length; index += 1) { + const entry = timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < timelineEntries.length; nextIndex += 1) { + const nextEntry = timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + + return byUserMessageId; + }, [inferredCheckpointTurnCountByTurnId, timelineEntries, turnDiffSummaryByAssistantMessageId]); + + useEffect(() => { + if (!activeThreadId || turnDiffSummaries.length === 0) { + return; + } + + const inferredMissingTurnCounts = turnDiffSummaries.reduce>( + (acc, summary) => { + if (typeof summary.checkpointTurnCount === "number") { + return acc; + } + const inferredTurnCount = inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof inferredTurnCount !== "number") { + return acc; + } + acc[summary.turnId] = inferredTurnCount; + return acc; + }, + {}, + ); + if (Object.keys(inferredMissingTurnCounts).length === 0) { + return; + } + + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: activeThreadId, + checkpointTurnCountByTurnId: inferredMissingTurnCounts, + }); + }, [activeThreadId, dispatch, inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); + const completionSummary = useMemo(() => { if (!activeThread?.latestTurnStartedAt) return null; if (!activeThread.latestTurnCompletedAt) return null; @@ -257,23 +650,97 @@ export default function ChatView() { completionSummary, timelineEntries, ]); - const runtimeSessionConfig = - state.runtimeMode === "full-access" - ? ({ - approvalPolicy: "never", - sandboxMode: "danger-full-access", - } as const) - : ({ - approvalPolicy: "on-request", - sandboxMode: "workspace-write", - } as const); + const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; + const runtimeSandboxMode = + state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; + const codexBinaryPath = appSettings.codexBinaryPath.trim(); + const codexHomePath = appSettings.codexHomePath.trim(); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; + const composerTrigger = useMemo( + () => detectComposerTrigger(prompt, composerCursor), + [prompt, composerCursor], + ); + const composerTriggerKind = composerTrigger?.kind ?? null; + const pathTriggerQuery = composerTrigger?.kind === "path" ? composerTrigger.query : ""; + const isPathTrigger = composerTriggerKind === "path"; + const [debouncedPathQuery, composerPathQueryDebouncer] = useDebouncedValue( + pathTriggerQuery, + { wait: COMPOSER_PATH_QUERY_DEBOUNCE_MS }, + (debouncerState) => ({ isPending: debouncerState.isPending }), + ); + const effectivePathQuery = pathTriggerQuery.length > 0 ? debouncedPathQuery : ""; const branchesQuery = useQuery(gitBranchesQueryOptions(api, gitCwd)); const keybindingsQuery = useQuery({ ...serverConfigQueryOptions(api), select: (config) => config.keybindings, }); + const workspaceEntriesQuery = useQuery( + projectSearchEntriesQueryOptions({ + api, + cwd: gitCwd, + query: effectivePathQuery, + enabled: isPathTrigger, + limit: 80, + }), + ); + const workspaceEntries = workspaceEntriesQuery.data?.entries ?? EMPTY_PROJECT_ENTRIES; + const composerMenuItems = useMemo(() => { + if (!composerTrigger) return []; + if (composerTrigger.kind === "path") { + return workspaceEntries.map((entry) => ({ + id: `path:${entry.kind}:${entry.path}`, + type: "path", + path: entry.path, + pathKind: entry.kind, + label: basenameOfPath(entry.path), + description: entry.parentPath ?? "", + })); + } + + if (composerTrigger.kind === "slash-command") { + if (!"model".includes(composerTrigger.query.toLowerCase())) { + return []; + } + return [ + { + id: "slash:model", + type: "slash-command", + label: "/model", + description: "Switch response model for this thread", + }, + ]; + } + + return SEARCHABLE_MODEL_OPTIONS.filter(({ searchSlug, searchName }) => { + const query = composerTrigger.query.trim().toLowerCase(); + if (!query) return true; + return searchSlug.includes(query) || searchName.includes(query); + }).map(({ slug, name }) => ({ + id: `model:${slug}`, + type: "model", + model: slug, + label: name, + description: slug, + })); + }, [composerTrigger, workspaceEntries]); + const composerMenuOpen = Boolean(composerTrigger); + const activeComposerMenuItem = useMemo( + () => + composerMenuItems.find((item) => item.id === composerHighlightedItemId) ?? + composerMenuItems[0] ?? + null, + [composerHighlightedItemId, composerMenuItems], + ); const keybindings = keybindingsQuery.data ?? EMPTY_KEYBINDINGS; + const threadTerminalRuntimeEnv = useMemo(() => { + if (!activeProject?.cwd) return {}; + return projectScriptRuntimeEnv({ + project: { + cwd: activeProject.cwd, + }, + worktreePath: activeThread?.worktreePath ?? null, + }); + }, [activeProject?.cwd, activeThread?.worktreePath]); // Default true while loading to avoid toolbar flicker. const isGitRepo = branchesQuery.data?.isRepo ?? true; const splitTerminalShortcutLabel = useMemo( @@ -294,6 +761,8 @@ export default function ChatView() { (activeThread.messages.length > 0 || (activeThread.session !== null && activeThread.session.status !== "closed")), ); + const hasReachedTerminalLimit = + (activeThread?.terminalIds.length ?? 0) >= MAX_THREAD_TERMINAL_COUNT; const revokePreviewUrls = useCallback((images: Array<{ previewUrl?: string }>) => { for (const image of images) { @@ -322,23 +791,23 @@ export default function ChatView() { }); }, [activeThread?.terminalOpen, activeThreadId, dispatch]); const splitTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "SPLIT_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const createNewTerminal = useCallback(() => { - if (!activeThreadId) return; + if (!activeThreadId || hasReachedTerminalLimit) return; dispatch({ type: "NEW_THREAD_TERMINAL", threadId: activeThreadId, terminalId: `terminal-${crypto.randomUUID()}`, }); setTerminalFocusRequestId((value) => value + 1); - }, [activeThreadId, dispatch]); + }, [activeThreadId, dispatch, hasReachedTerminalLimit]); const activateTerminal = useCallback( (terminalId: string) => { if (!activeThreadId) return; @@ -380,6 +849,225 @@ export default function ChatView() { }, [activeThread?.terminalIds.length, activeThreadId, api, dispatch], ); + const runProjectScript = useCallback( + async ( + script: ProjectScript, + options?: { + cwd?: string; + env?: Record; + worktreePath?: string | null; + preferNewTerminal?: boolean; + rememberAsLastInvoked?: boolean; + }, + ) => { + if (!api || !activeThreadId || !activeProject || !activeThread) return; + if (options?.rememberAsLastInvoked !== false) { + setLastInvokedScriptByProjectId((current) => { + if (current[activeProject.id] === script.id) return current; + return { ...current, [activeProject.id]: script.id }; + }); + } + const targetCwd = options?.cwd ?? gitCwd ?? activeProject.cwd; + const baseTerminalId = + activeThread.activeTerminalId || activeThread.terminalIds[0] || DEFAULT_THREAD_TERMINAL_ID; + const isBaseTerminalBusy = activeThread.runningTerminalIds.includes(baseTerminalId); + const wantsNewTerminal = Boolean(options?.preferNewTerminal) || isBaseTerminalBusy; + const shouldCreateNewTerminal = + wantsNewTerminal && activeThread.terminalIds.length < MAX_THREAD_TERMINAL_COUNT; + const targetTerminalId = shouldCreateNewTerminal + ? `terminal-${crypto.randomUUID()}` + : baseTerminalId; + + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + if (shouldCreateNewTerminal) { + dispatch({ + type: "NEW_THREAD_TERMINAL", + threadId: activeThreadId, + terminalId: targetTerminalId, + }); + } else { + dispatch({ + type: "SET_THREAD_ACTIVE_TERMINAL", + threadId: activeThreadId, + terminalId: targetTerminalId, + }); + } + setTerminalFocusRequestId((value) => value + 1); + + const runtimeEnv = projectScriptRuntimeEnv({ + project: { + cwd: activeProject.cwd, + }, + worktreePath: options?.worktreePath ?? activeThread.worktreePath ?? null, + ...(options?.env ? { extraEnv: options.env } : {}), + }); + + try { + await api.terminal.open({ + threadId: activeThreadId, + terminalId: targetTerminalId, + cwd: targetCwd, + env: runtimeEnv, + ...(shouldCreateNewTerminal + ? { + cols: SCRIPT_TERMINAL_COLS, + rows: SCRIPT_TERMINAL_ROWS, + } + : {}), + }); + await api.terminal.write({ + threadId: activeThreadId, + terminalId: targetTerminalId, + data: `${script.command}\r`, + }); + } catch (error) { + dispatch({ + type: "SET_ERROR", + threadId: activeThreadId, + error: error instanceof Error ? error.message : `Failed to run script "${script.name}".`, + }); + } + }, + [activeProject, activeThread, activeThreadId, api, dispatch, gitCwd], + ); + const persistProjectScripts = useCallback( + async (input: { + projectId: string; + projectCwd: string; + previousScripts: ProjectScript[]; + nextScripts: ProjectScript[]; + keybinding?: string | null; + keybindingCommand: string; + }) => { + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: input.projectId, + scripts: input.nextScripts, + }); + + if (!isElectron || !api) return; + + let scriptsPersisted = false; + try { + const updated = await api.projects.updateScripts({ + id: input.projectId, + scripts: input.nextScripts, + }); + scriptsPersisted = true; + + if (input.keybinding) { + const keybindingUpdate = await api.server.upsertKeybinding({ + key: input.keybinding, + command: input.keybindingCommand, + }); + queryClient.setQueryData( + serverQueryKeys.config(), + (current: { cwd: string; keybindings: ResolvedKeybindingsConfig } | undefined) => + current + ? { ...current, keybindings: keybindingUpdate.keybindings } + : { + cwd: input.projectCwd, + keybindings: keybindingUpdate.keybindings, + }, + ); + } + + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: updated.project.id, + scripts: updated.project.scripts, + }); + } catch (error) { + if (scriptsPersisted) { + await api.projects + .updateScripts({ + id: input.projectId, + scripts: input.previousScripts, + }) + .catch(() => undefined); + } + dispatch({ + type: "SET_PROJECT_SCRIPTS", + projectId: input.projectId, + scripts: input.previousScripts, + }); + throw error; + } + }, + [api, dispatch, queryClient], + ); + const saveProjectScript = useCallback( + async (input: NewProjectScriptInput) => { + if (!activeProject) return; + const nextId = nextProjectScriptId( + input.name, + activeProject.scripts.map((script) => script.id), + ); + const nextScript: ProjectScript = { + id: nextId, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + }; + const nextScripts = input.runOnWorktreeCreate + ? [ + ...activeProject.scripts.map((script) => + script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, + ), + nextScript, + ] + : [...activeProject.scripts, nextScript]; + + await persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.cwd, + previousScripts: activeProject.scripts, + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(nextId), + }); + }, + [activeProject, persistProjectScripts], + ); + const updateProjectScript = useCallback( + async (scriptId: string, input: NewProjectScriptInput) => { + if (!activeProject) return; + const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + if (!existingScript) { + throw new Error("Script not found."); + } + + const updatedScript: ProjectScript = { + ...existingScript, + name: input.name, + command: input.command, + icon: input.icon, + runOnWorktreeCreate: input.runOnWorktreeCreate, + }; + const nextScripts = activeProject.scripts.map((script) => + script.id === scriptId + ? updatedScript + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + + await persistProjectScripts({ + projectId: activeProject.id, + projectCwd: activeProject.cwd, + previousScripts: activeProject.scripts, + nextScripts, + keybinding: input.keybinding, + keybindingCommand: commandForProjectScript(scriptId), + }); + }, + [activeProject, persistProjectScripts], + ); const handleRuntimeModeChange = async (mode: "approval-required" | "full-access") => { if (mode === state.runtimeMode) return; @@ -404,6 +1092,21 @@ export default function ChatView() { } }; + useEffect(() => { + try { + if (Object.keys(lastInvokedScriptByProjectId).length === 0) { + localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); + return; + } + localStorage.setItem( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + JSON.stringify(lastInvokedScriptByProjectId), + ); + } catch { + // Ignore storage write failures (private mode, quota exceeded, etc.) + } + }, [lastInvokedScriptByProjectId]); + // Auto-scroll on new messages const messageCount = activeThread?.messages.length ?? 0; const workLogCount = workLogEntries.length; @@ -436,6 +1139,20 @@ export default function ChatView() { setExpandedWorkGroups({}); }, [activeThread?.id]); + useEffect(() => { + if (!composerMenuOpen) { + setComposerHighlightedItemId(null); + return; + } + setComposerHighlightedItemId((existing) => + existing && composerMenuItems.some((item) => item.id === existing) ? existing : null, + ); + }, [composerMenuItems, composerMenuOpen]); + + useEffect(() => { + setIsRevertingCheckpoint(false); + }, [activeThread?.id]); + useEffect(() => { if (!activeThread?.id || activeThread.terminalOpen) return; const frame = window.requestAnimationFrame(() => { @@ -455,6 +1172,11 @@ export default function ChatView() { revokePreviewUrls(existing); return []; }); + setPrompt(""); + promptRef.current = ""; + setIsSending(false); + setComposerCursor(0); + setComposerHighlightedItemId(null); dragDepthRef.current = 0; setIsDragOverComposer(false); setExpandedImage(null); @@ -543,14 +1265,17 @@ export default function ChatView() { terminalOpen: Boolean(activeThread?.terminalOpen), }; - if (isTerminalToggleShortcut(event, keybindings, { context: shortcutContext })) { + const command = resolveShortcutCommand(event, keybindings, { context: shortcutContext }); + if (!command) return; + + if (command === "terminal.toggle") { event.preventDefault(); event.stopPropagation(); toggleTerminalVisibility(); return; } - if (isTerminalSplitShortcut(event, keybindings, { context: shortcutContext })) { + if (command === "terminal.split") { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) { @@ -564,7 +1289,7 @@ export default function ChatView() { return; } - if (isTerminalCloseShortcut(event, keybindings, { context: shortcutContext })) { + if (command === "terminal.close") { event.preventDefault(); event.stopPropagation(); if (!activeThread?.terminalOpen) return; @@ -572,40 +1297,55 @@ export default function ChatView() { return; } - if (!isTerminalNewShortcut(event, keybindings, { context: shortcutContext })) return; + if (command === "terminal.new") { + event.preventDefault(); + event.stopPropagation(); + if (!activeThread?.terminalOpen) { + dispatch({ + type: "SET_THREAD_TERMINAL_OPEN", + threadId: activeThreadId, + open: true, + }); + } + createNewTerminal(); + return; + } + + const scriptId = projectScriptIdFromCommand(command); + if (!scriptId || !activeProject) return; + const script = activeProject.scripts.find((entry) => entry.id === scriptId); + if (!script) return; event.preventDefault(); event.stopPropagation(); - if (!activeThread?.terminalOpen) { - dispatch({ - type: "SET_THREAD_TERMINAL_OPEN", - threadId: activeThreadId, - open: true, - }); - } - createNewTerminal(); + void runProjectScript(script); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, [ + activeProject, activeThread?.terminalOpen, activeThread?.activeTerminalId, activeThreadId, closeTerminal, createNewTerminal, dispatch, + runProjectScript, splitTerminal, keybindings, toggleTerminalVisibility, ]); - const setThreadError = (threadId: string | null, error: string | null) => { - if (!threadId) return; - dispatch({ - type: "SET_ERROR", - threadId, - error, - }); - }; + const setThreadError = useCallback( + (threadId: string | null, error: string | null) => { + if (!threadId) return; + dispatch({ + type: "SET_ERROR", + threadId, + error, + }); + }, + [dispatch], + ); const addComposerImages = (files: File[]) => { if (!activeThreadId || files.length === 0) return; @@ -710,62 +1450,240 @@ export default function ChatView() { focusComposer(); }; - const ensureSession = async (cwdOverride?: string): Promise => { - if (!api || !activeThread || !activeProject) return null; - if (activeThread.session && activeThread.session.status !== "closed") { - const sessionThreadId = activeThread.session.threadId ?? null; - const continuityState: SessionContinuityState = - activeThread.codexThreadId === null - ? "new" - : sessionThreadId === activeThread.codexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: activeThread.session.sessionId, - resolvedThreadId: sessionThreadId, - continuityState, - } satisfies EnsuredSessionInfo; - } + const syncCheckpointTurnCounts = useCallback( + async (input: { + threadId: string; + sessionId: string; + threadRuntimeId: string | null; + }): Promise => { + if (!api || turnDiffSummaries.length === 0) { + return; + } + + const turnIds = turnDiffSummaries.map((summary) => summary.turnId); + if (turnIds.length === 0) { + return; + } + + const syncKey = `${input.threadId}:${input.sessionId}:${input.threadRuntimeId ?? "none"}`; + const turnFingerprint = turnIds.join(","); + if (checkpointTurnCountSyncFingerprintRef.current.get(syncKey) === turnFingerprint) { + return; + } + checkpointTurnCountSyncFingerprintRef.current.set(syncKey, turnFingerprint); + + try { + const result = await api.providers.listCheckpoints({ + sessionId: input.sessionId, + }); + const turnIdSet = new Set(turnIds); + const checkpointTurnCountByTurnId = Object.fromEntries( + result.checkpoints + .filter((checkpoint) => checkpoint.turnCount > 0 && turnIdSet.has(checkpoint.id)) + .map((checkpoint) => [checkpoint.id, checkpoint.turnCount] as const), + ); + if (Object.keys(checkpointTurnCountByTurnId).length === 0) { + return; + } + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: input.threadId, + checkpointTurnCountByTurnId, + }); + } catch { + checkpointTurnCountSyncFingerprintRef.current.delete(syncKey); + } + }, + [api, dispatch, turnDiffSummaries], + ); + + const ensureSession = useCallback( + async (cwdOverride?: string): Promise => { + if (!api || !activeThread || !activeProject) return null; + if (activeThread.session && activeThread.session.status !== "closed") { + const sessionThreadId = activeThread.session.threadId ?? null; + const continuityState: SessionContinuityState = + activeThread.codexThreadId === null + ? "new" + : sessionThreadId === activeThread.codexThreadId + ? "resumed" + : "fallback_new"; + await syncCheckpointTurnCounts({ + threadId: activeThread.id, + sessionId: activeThread.session.sessionId, + threadRuntimeId: sessionThreadId, + }); + return { + sessionId: activeThread.session.sessionId, + resolvedThreadId: sessionThreadId, + continuityState, + } satisfies EnsuredSessionInfo; + } - const priorCodexThreadId = activeThread.codexThreadId; - setIsConnecting(true); - try { - const session = await api.providers.startSession({ - provider: "codex", - cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, - model: selectedModel || undefined, - resumeThreadId: priorCodexThreadId ?? undefined, - approvalPolicy: runtimeSessionConfig.approvalPolicy, - sandboxMode: runtimeSessionConfig.sandboxMode, - }); - dispatch({ - type: "UPDATE_SESSION", - threadId: activeThread.id, - session, - }); - const resolvedThreadId = session.threadId ?? null; - const continuityState: SessionContinuityState = - priorCodexThreadId === null - ? "new" - : resolvedThreadId === priorCodexThreadId - ? "resumed" - : "fallback_new"; - return { - sessionId: session.sessionId, - resolvedThreadId, - continuityState, - }; - } catch (err) { - dispatch({ - type: "SET_ERROR", - threadId: activeThread.id, - error: err instanceof Error ? err.message : "Failed to connect.", - }); - return null; - } finally { - setIsConnecting(false); + const priorCodexThreadId = activeThread.codexThreadId; + setIsConnecting(true); + try { + const session = await api.providers.startSession({ + provider: "codex", + cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, + model: selectedModel || undefined, + resumeThreadId: priorCodexThreadId ?? undefined, + ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), + ...(codexHomePath.length > 0 ? { codexHomePath } : {}), + approvalPolicy: runtimeApprovalPolicy, + sandboxMode: runtimeSandboxMode, + }); + dispatch({ + type: "UPDATE_SESSION", + threadId: activeThread.id, + session, + }); + const resolvedThreadId = session.threadId ?? null; + const continuityState: SessionContinuityState = + priorCodexThreadId === null + ? "new" + : resolvedThreadId === priorCodexThreadId + ? "resumed" + : "fallback_new"; + await syncCheckpointTurnCounts({ + threadId: activeThread.id, + sessionId: session.sessionId, + threadRuntimeId: resolvedThreadId, + }); + return { + sessionId: session.sessionId, + resolvedThreadId, + continuityState, + }; + } catch (err) { + dispatch({ + type: "SET_ERROR", + threadId: activeThread.id, + error: err instanceof Error ? err.message : "Failed to connect.", + }); + return null; + } finally { + setIsConnecting(false); + } + }, + [ + activeProject, + activeThread, + api, + codexBinaryPath, + codexHomePath, + dispatch, + runtimeApprovalPolicy, + runtimeSandboxMode, + selectedModel, + syncCheckpointTurnCounts, + ], + ); + + useEffect(() => { + if (!api || !activeThreadId || activeSessionId) return; + if (phase === "running" || isSending || isConnecting || isRevertingCheckpoint) return; + const hasThreadIdentity = Boolean(activeThreadRuntimeId); + if (!hasThreadIdentity) return; + if (turnDiffSummaries.length === 0) return; + + const requestKey = `${activeThreadId}:${activeThreadRuntimeId ?? "none"}`; + if (checkpointHydrationSessionRequestRef.current.has(requestKey)) { + return; } - }; + checkpointHydrationSessionRequestRef.current.add(requestKey); + setThreadError(activeThreadId, null); + void ensureSession().then( + () => { + checkpointHydrationSessionRequestRef.current.delete(requestKey); + }, + () => { + // Keep the key on failure so this effect does not re-enter in a tight loop. + }, + ); + }, [ + activeThreadId, + activeThreadRuntimeId, + activeSessionId, + api, + ensureSession, + isConnecting, + isRevertingCheckpoint, + isSending, + phase, + setThreadError, + turnDiffSummaries, + ]); + + const onRevertToTurnCount = useCallback( + async (turnCount: number) => { + if (!api || !activeThread || isRevertingCheckpoint) { + return; + } + if (phase === "running" || isSending || isConnecting) { + setThreadError(activeThread.id, "Interrupt the current turn before reverting checkpoints."); + return; + } + const confirmed = await api.dialogs.confirm( + [ + `Revert this thread to checkpoint ${turnCount}?`, + "This will discard newer messages and turn diffs in this thread.", + "This action cannot be undone.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + + setIsRevertingCheckpoint(true); + setThreadError(activeThread.id, null); + try { + const sessionInfo = await ensureSession(); + if (!sessionInfo) { + return; + } + const result = await api.providers.revertToCheckpoint({ + sessionId: sessionInfo.sessionId, + turnCount, + }); + dispatch({ + type: "REVERT_TO_CHECKPOINT", + threadId: activeThread.id, + sessionId: sessionInfo.sessionId, + threadRuntimeId: result.threadId, + turnCount: result.turnCount, + messageCount: result.messageCount, + }); + dispatch({ + type: "SET_THREAD_TURN_CHECKPOINT_COUNTS", + threadId: activeThread.id, + checkpointTurnCountByTurnId: Object.fromEntries( + result.checkpoints + .filter((entry) => entry.turnCount > 0) + .map((entry) => [entry.id, entry.turnCount] as const), + ), + }); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to revert thread state.", + ); + } finally { + setIsRevertingCheckpoint(false); + } + }, + [ + activeThread, + api, + dispatch, + ensureSession, + isConnecting, + isRevertingCheckpoint, + isSending, + phase, + setThreadError, + ], + ); const onSend = async (e: FormEvent) => { e.preventDefault(); @@ -797,6 +1715,14 @@ export default function ChatView() { branch: result.worktree.branch, worktreePath: result.worktree.path, }); + const setupScript = setupProjectScript(activeProject.scripts); + if (setupScript) { + void runProjectScript(setupScript, { + cwd: result.worktree.path, + worktreePath: result.worktree.path, + rememberAsLastInvoked: false, + }); + } } catch (err) { dispatch({ type: "SET_ERROR", @@ -814,7 +1740,7 @@ export default function ChatView() { (composerImagesSnapshot.length > 0 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` : "New thread"); - const title = titleSeed.length > 50 ? `${titleSeed.slice(0, 50)}...` : titleSeed; + const title = truncateTitle(titleSeed); dispatch({ type: "SET_THREAD_TITLE", threadId: activeThread.id, @@ -839,8 +1765,11 @@ export default function ChatView() { ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), }); const previousMessages = activeThread.messages; + promptRef.current = ""; setPrompt(""); setComposerImages([]); + setComposerCursor(0); + setComposerHighlightedItemId(null); const sessionInfo = await ensureSession(sessionCwd); if (!sessionInfo) return; @@ -947,7 +1876,86 @@ export default function ChatView() { [scheduleComposerFocus], ); + const applyPromptReplacement = useCallback( + (rangeStart: number, rangeEnd: number, replacement: string) => { + const next = replaceTextRange(promptRef.current, rangeStart, rangeEnd, replacement); + promptRef.current = next.text; + setPrompt(next.text); + window.requestAnimationFrame(() => { + const textarea = textareaRef.current; + if (!textarea) return; + textarea.focus(); + textarea.setSelectionRange(next.cursor, next.cursor); + setComposerCursor(next.cursor); + }); + }, + [], + ); + + const onSelectComposerItem = useCallback( + (item: ComposerCommandItem) => { + if (!composerTrigger) return; + if (item.type === "path") { + applyPromptReplacement( + composerTrigger.rangeStart, + composerTrigger.rangeEnd, + `@${item.path} `, + ); + return; + } + if (item.type === "slash-command") { + applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "/model "); + return; + } + onModelSelect(item.model); + applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); + }, + [applyPromptReplacement, composerTrigger, onModelSelect], + ); + const onComposerMenuItemHighlighted = useCallback((itemId: string | null) => { + setComposerHighlightedItemId(itemId); + }, []); + const nudgeComposerMenuHighlight = useCallback((key: "ArrowDown" | "ArrowUp") => { + const commandInput = composerCommandInputRef.current; + if (!commandInput) return; + commandInput.dispatchEvent( + new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }), + ); + }, []); + const isComposerMenuLoading = + composerTriggerKind === "path" && + ((pathTriggerQuery.length > 0 && composerPathQueryDebouncer.state.isPending) || + workspaceEntriesQuery.isLoading || + workspaceEntriesQuery.isFetching); + + const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { + promptRef.current = nextPrompt; + setPrompt(nextPrompt); + setComposerCursor(nextCursor); + }, []); + const onKeyDown = (e: KeyboardEvent) => { + if (composerMenuOpen && composerMenuItems.length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault(); + nudgeComposerMenuHighlight("ArrowDown"); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + nudgeComposerMenuHighlight("ArrowUp"); + return; + } + if (e.key === "Tab" || e.key === "Enter") { + const selectedItem = activeComposerMenuItem ?? composerMenuItems[0]; + if (selectedItem) { + e.preventDefault(); + onSelectComposerItem(selectedItem); + return; + } + } + } + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void onSend(e as unknown as FormEvent); @@ -963,13 +1971,45 @@ export default function ChatView() { setExpandedImage(image); }, []); const onToggleDiff = useCallback(() => { - dispatch({ type: "TOGGLE_DIFF" }); - }, [dispatch]); + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return diffOpen ? rest : { ...rest, diff: "1" }; + }, + }); + }, [diffOpen, navigate, threadId]); + const onOpenTurnDiff = useCallback( + (turnId: string, filePath?: string) => { + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return filePath + ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, diff: "1", diffTurnId: turnId }; + }, + }); + }, + [navigate, threadId], + ); + const onRevertUserMessage = useCallback( + (messageId: string) => { + const targetTurnCount = revertTurnCountByUserMessageId.get(messageId); + if (typeof targetTurnCount !== "number") { + return; + } + void onRevertToTurnCount(targetTurnCount); + }, + [onRevertToTurnCount, revertTurnCountByUserMessageId], + ); // Empty state: no active thread if (!activeThread) { return ( -
+
{isElectron && (
No active thread @@ -985,18 +2025,28 @@ export default function ChatView() { } return ( -
+
{/* Top bar */}
{ + void runProjectScript(script); + }} + onAddProjectScript={saveProjectScript} + onUpdateProjectScript={updateProjectScript} onToggleDiff={onToggleDiff} />
@@ -1018,15 +2068,21 @@ export default function ChatView() { 0} isWorking={isWorking} + scrollContainerRef={messagesScrollRef} timelineEntries={timelineEntries} completionDividerBeforeEntryId={completionDividerBeforeEntryId} completionSummary={completionSummary} assistantCompletionByItemId={assistantCompletionByItemId} + turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + turnCheckpointFilesByTurnId={turnCheckpointFilesByTurnId} nowIso={nowIso} expandedWorkGroups={expandedWorkGroups} onToggleWorkGroup={onToggleWorkGroup} + onOpenTurnDiff={onOpenTurnDiff} + revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} + onRevertUserMessage={onRevertUserMessage} + isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} - messagesEndRef={messagesEndRef} />
@@ -1043,7 +2099,21 @@ export default function ChatView() { onDrop={onComposerDrop} > {/* Textarea area */} -
+
+ {composerMenuOpen && ( +
+ +
+ )} + {composerImages.length > 0 && (
{composerImages.map((image) => ( @@ -1083,13 +2153,33 @@ export default function ChatView() { className="w-full resize-none bg-transparent text-[14px] leading-relaxed text-foreground placeholder:text-muted-foreground/35 focus:outline-none" rows={2} value={prompt} - onChange={(e) => setPrompt(e.target.value)} + onChange={(event) => + onPromptChange( + event.target.value, + event.target.selectionStart ?? event.target.value.length, + ) + } onKeyDown={onKeyDown} + onKeyUp={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } + onClick={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } + onSelect={(event) => + setComposerCursor( + event.currentTarget.selectionStart ?? event.currentTarget.value.length, + ) + } onPaste={onComposerPaste} placeholder={ phase === "disconnected" ? "Ask for follow-up changes or attach images" - : "Ask anything, or attach images..." + : "Ask anything, @tag files/folders, or use /model" } disabled={isSending || isConnecting} /> @@ -1209,6 +2299,7 @@ export default function ChatView() { {isGitRepo && ( void; + onAddProjectScript: (input: NewProjectScriptInput) => Promise; + onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; onToggleDiff: () => void; } const ChatHeader = memo(function ChatHeader({ + activeThreadId, activeThreadTitle, activeProjectName, + activeProjectScripts, + preferredScriptId, keybindings, api, gitCwd, diffOpen, + onRunProjectScript, + onAddProjectScript, + onUpdateProjectScript, onToggleDiff, }: ChatHeaderProps) { return ( <> -
-

{activeThreadTitle}

+
+

+ {activeThreadTitle} +

{activeProjectName && {activeProjectName}}
-
- {activeProjectName && } - {activeProjectName && } - + +
); @@ -1416,214 +2534,443 @@ const PendingApprovalsPanel = memo(function PendingApprovalsPanel({ interface MessagesTimelineProps { hasMessages: boolean; isWorking: boolean; + scrollContainerRef: RefObject; timelineEntries: ReturnType; completionDividerBeforeEntryId: string | null; completionSummary: string | null; assistantCompletionByItemId: Map; + turnDiffSummaryByAssistantMessageId: Map; + turnCheckpointFilesByTurnId: Map; nowIso: string; expandedWorkGroups: Record; onToggleWorkGroup: (groupId: string) => void; + onOpenTurnDiff: (turnId: string, filePath?: string) => void; + revertTurnCountByUserMessageId: Map; + onRevertUserMessage: (messageId: string) => void; + isRevertingCheckpoint: boolean; onImageExpand: (image: ExpandedImagePreview) => void; - messagesEndRef: RefObject; } +type TimelineEntry = ReturnType[number]; +type TimelineMessage = Extract["message"]; +type TimelineWorkEntry = Extract["entry"]; +type TimelineRow = + | { + kind: "work"; + id: string; + groupedEntries: TimelineWorkEntry[]; + } + | { + kind: "message"; + id: string; + message: TimelineMessage; + showCompletionDivider: boolean; + } + | { kind: "working"; id: string }; + const MessagesTimeline = memo(function MessagesTimeline({ hasMessages, isWorking, + scrollContainerRef, timelineEntries, completionDividerBeforeEntryId, completionSummary, assistantCompletionByItemId, + turnDiffSummaryByAssistantMessageId, + turnCheckpointFilesByTurnId, nowIso, expandedWorkGroups, onToggleWorkGroup, + onOpenTurnDiff, + revertTurnCountByUserMessageId, + onRevertUserMessage, + isRevertingCheckpoint, onImageExpand, - messagesEndRef, }: MessagesTimelineProps) { + const rows = useMemo(() => { + const nextRows: TimelineRow[] = []; + + for (let index = 0; index < timelineEntries.length; index += 1) { + const timelineEntry = timelineEntries[index]; + if (!timelineEntry) { + continue; + } + + if (timelineEntry.kind === "work") { + const groupedEntries = [timelineEntry.entry]; + let cursor = index + 1; + while (cursor < timelineEntries.length) { + const nextEntry = timelineEntries[cursor]; + if (!nextEntry || nextEntry.kind !== "work") break; + groupedEntries.push(nextEntry.entry); + cursor += 1; + } + nextRows.push({ + kind: "work", + id: timelineEntry.id, + groupedEntries, + }); + index = cursor - 1; + continue; + } + + nextRows.push({ + kind: "message", + id: timelineEntry.id, + message: timelineEntry.message, + showCompletionDivider: + timelineEntry.message.role === "assistant" && + completionDividerBeforeEntryId === timelineEntry.id, + }); + } + + if (isWorking) { + nextRows.push({ kind: "working", id: "working-indicator-row" }); + } + + return nextRows; + }, [timelineEntries, completionDividerBeforeEntryId, isWorking]); + + const rowVirtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index: number) => { + const row = rows[index]; + if (!row) return 96; + if (row.kind === "work") return 112; + if (row.kind === "working") return 40; + return row.message.role === "assistant" ? 220 : 170; + }, + measureElement: (element: HTMLElement) => element.getBoundingClientRect().height, + overscan: 8, + }); + + const virtualRows = rowVirtualizer.getVirtualItems(); + if (!hasMessages && !isWorking) { return (
-

Send a message to start the conversation.

+

+ Send a message to start the conversation. +

); } return ( -
- {timelineEntries.map((timelineEntry, index) => { - if (timelineEntry.kind === "work" && timelineEntries[index - 1]?.kind === "work") { - return null; - } +
+ {virtualRows.map((virtualRow: VirtualItem) => { + const row = rows[virtualRow.index]; + if (!row) return null; - const showCompletionDivider = - timelineEntry.kind === "message" && - timelineEntry.message.role === "assistant" && - completionDividerBeforeEntryId === timelineEntry.id; + return ( +
+
+ {row.kind === "work" && + (() => { + const groupId = row.id; + const groupedEntries = row.groupedEntries; + const isExpanded = expandedWorkGroups[groupId] ?? false; + const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; + const visibleEntries = + hasOverflow && !isExpanded + ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) + : groupedEntries; + const hiddenCount = groupedEntries.length - visibleEntries.length; + const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); + const groupLabel = onlyToolEntries + ? groupedEntries.length === 1 + ? "Tool call" + : `Tool calls (${groupedEntries.length})` + : groupedEntries.length === 1 + ? "Work event" + : `Work log (${groupedEntries.length})`; - if (timelineEntry.kind === "work") { - const groupedEntries = [timelineEntry.entry]; - let cursor = index + 1; - while (cursor < timelineEntries.length) { - const nextEntry = timelineEntries[cursor]; - if (!nextEntry || nextEntry.kind !== "work") break; - groupedEntries.push(nextEntry.entry); - cursor += 1; - } - - const groupId = timelineEntry.id; - const isExpanded = expandedWorkGroups[groupId] ?? false; - const hasOverflow = groupedEntries.length > MAX_VISIBLE_WORK_LOG_ENTRIES; - const visibleEntries = - hasOverflow && !isExpanded - ? groupedEntries.slice(-MAX_VISIBLE_WORK_LOG_ENTRIES) - : groupedEntries; - const hiddenCount = groupedEntries.length - visibleEntries.length; - const onlyToolEntries = groupedEntries.every((entry) => entry.tone === "tool"); - const groupLabel = onlyToolEntries - ? groupedEntries.length === 1 - ? "Tool call" - : `Tool calls (${groupedEntries.length})` - : groupedEntries.length === 1 - ? "Work event" - : `Work log (${groupedEntries.length})`; - - return ( - -
-
-

- {groupLabel} -

- {hasOverflow && ( - - )} -
-
- {visibleEntries.map((workEntry) => ( -
- -

- {workEntry.detail ? ( - <> - {workEntry.label} - +

+

+ {groupLabel} +

+ {hasOverflow && ( + + )} +
+
+ {visibleEntries.map((workEntry) => ( +
+ +

- {workEntry.detail} - - - ) : ( - workEntry.label + {workEntry.detail ? ( + <> + {workEntry.label} + + {workEntry.detail} + + + ) : ( + workEntry.label + )} +

+
+ ))} +
+
+ ); + })()} + + {row.kind === "message" && + row.message.role === "user" && + (() => { + const userImages = row.message.attachments ?? []; + const canRevertAgentWork = revertTurnCountByUserMessageId.has(row.message.id); + return ( +
+
+ {userImages.length > 0 && ( +
+ {userImages.map( + (image: NonNullable[number]) => ( +
+ {image.previewUrl ? ( + {image.name} + onImageExpand({ src: image.previewUrl!, name: image.name }) + } + /> + ) : ( +
+ {image.name} +
+ )} +
+ ), + )} +
+ )} + {row.message.text && ( +
+                            {row.message.text}
+                          
)} -

+
+ {canRevertAgentWork && ( + + )} +

+ {formatTimestamp(row.message.createdAt)} +

+
+
- ))} -
-
-
- ); - } + ); + })()} - if (timelineEntry.message.role === "user") { - const userImages = timelineEntry.message.attachments ?? []; - return ( - -
-
- {userImages.length > 0 && ( -
- {userImages.map((image) => ( -
- {image.previewUrl ? ( - {image.name} - onImageExpand({ src: image.previewUrl!, name: image.name }) + {row.kind === "message" && + row.message.role === "assistant" && + (() => { + const messageText = + row.message.text || (row.message.streaming ? "" : "(empty response)"); + return ( + <> + {row.showCompletionDivider && ( +
+ + + {completionSummary ? `Response • ${completionSummary}` : "Response"} + + +
+ )} +
+ + {(() => { + const turnSummary = turnDiffSummaryByAssistantMessageId.get( + row.message.id, + ); + if (!turnSummary) return null; + const checkpointFilesState = turnCheckpointFilesByTurnId.get( + turnSummary.turnId, + ); + const checkpointFiles = checkpointFilesState?.files ?? []; + const summaryStat = checkpointFiles.reduce( + (acc, file) => { + if ( + typeof file.additions !== "number" || + typeof file.deletions !== "number" + ) { + return acc; } - /> - ) : ( -
- {image.name} + return { + additions: acc.additions + file.additions, + deletions: acc.deletions + file.deletions, + }; + }, + { additions: 0, deletions: 0 }, + ); + const changedFileCountLabel = + checkpointFilesState?.status === "ready" + ? String(checkpointFiles.length) + : "..."; + return ( +
+
+

+ Changed files ({changedFileCountLabel}) + {(summaryStat.additions > 0 || summaryStat.deletions > 0) && ( + <> + • + +{summaryStat.additions} + / + + -{summaryStat.deletions} + + + )} +

+ +
+ {!checkpointFilesState || + checkpointFilesState.status === "pending" ? ( +

+ Waiting for checkpoint metadata... +

+ ) : checkpointFilesState?.status === "loading" ? ( +

+ Loading checkpoint diff... +

+ ) : checkpointFilesState?.status === "error" ? ( +

+ {checkpointFilesState.errorMessage} +

+ ) : checkpointFiles.length > 0 ? ( +
+ {checkpointFiles.map((file) => ( + + ))} +
+ ) : ( +

+ No changed files. +

+ )}
+ ); + })()} +

+ {formatMessageMeta( + row.message.createdAt, + row.message.streaming + ? formatElapsed(row.message.createdAt, nowIso) + : formatElapsed( + row.message.createdAt, + assistantCompletionByItemId.get(row.message.id), + ), )} -

- ))} -
- )} - {timelineEntry.message.text && ( -
-                      {timelineEntry.message.text}
-                    
- )} -

- {formatTimestamp(timelineEntry.message.createdAt)} -

-
-
- - ); - } +

+
+ + ); + })()} - return ( - - {showCompletionDivider && ( -
- - - {completionSummary ? `Response • ${completionSummary}` : "Response"} - - -
- )} -
- -

- {formatMessageMeta( - timelineEntry.message.createdAt, - timelineEntry.message.streaming - ? formatElapsed(timelineEntry.message.createdAt, nowIso) - : formatElapsed( - timelineEntry.message.createdAt, - assistantCompletionByItemId.get(timelineEntry.message.id), - ), - )} -

+ {row.kind === "working" && ( +
+ +
+ + + + + +
+
+ )}
-
+
); })} - {isWorking && ( -
- -
- - - - - -
-
- )} -
); }); @@ -1678,8 +3025,10 @@ const ReasoningEffortPicker = memo(function ReasoningEffortPicker(props: { const OpenInPicker = memo(function OpenInPicker({ keybindings, + activeThreadId, }: { keybindings: ResolvedKeybindingsConfig; + activeThreadId: string | null; }) { const [lastEditor, setLastEditor] = useState(() => { const stored = localStorage.getItem(LAST_EDITOR_KEY); @@ -1706,7 +3055,7 @@ const OpenInPicker = memo(function OpenInPicker({ const api = useNativeApi(); const { state } = useStore(); - const activeThread = state.threads.find((t) => t.id === state.activeThreadId); + const activeThread = state.threads.find((t) => t.id === activeThreadId); const activeProject = state.projects.find((p) => p.id === activeThread?.projectId); const openInEditor = useCallback( diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 84a6cad50831d99a6d14174207309cecd3298b03..4b867209108223ed0e1ac0dcf4d9d504facdab99 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,58 +1,476 @@ -import { XIcon } from "lucide-react"; +import { parsePatchFiles } from "@pierre/diffs"; +import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; +import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; +import { cn } from "~/lib/utils"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { isElectron } from "../env"; +import { useNativeApi } from "../hooks/useNativeApi"; +import { useTheme } from "../hooks/useTheme"; +import { buildPatchCacheKey } from "../lib/diffRendering"; +import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { useStore } from "../store"; -import { Button } from "./ui/button"; +import { ToggleGroup, Toggle } from "./ui/toggle-group"; -export default function DiffPanel() { - const { dispatch } = useStore(); +type DiffRenderMode = "stacked" | "split"; +type DiffThemeType = "light" | "dark" | "system"; + +type RenderablePatch = + | { + kind: "files"; + files: FileDiffMetadata[]; + } + | { + kind: "raw"; + text: string; + reason: string; + }; + +function getRenderablePatch(patch: string | undefined): RenderablePatch | null { + if (!patch) return null; + const normalizedPatch = patch.trim(); + if (normalizedPatch.length === 0) return null; + + try { + const parsedPatches = parsePatchFiles(normalizedPatch, buildPatchCacheKey(normalizedPatch)); + const files = parsedPatches.flatMap((parsedPatch) => parsedPatch.files); + if (files.length > 0) { + return { kind: "files", files }; + } + + return { + kind: "raw", + text: normalizedPatch, + reason: "Unsupported diff format. Showing raw patch.", + }; + } catch { + return { + kind: "raw", + text: normalizedPatch, + reason: "Failed to parse patch. Showing raw patch.", + }; + } +} + +function resolveFileDiffPath(fileDiff: FileDiffMetadata): string { + const raw = fileDiff.name ?? fileDiff.prevName ?? ""; + if (raw.startsWith("a/") || raw.startsWith("b/")) { + return raw.slice(2); + } + return raw; +} + +function buildFileDiffRenderKey(fileDiff: FileDiffMetadata): string { + return fileDiff.cacheKey ?? `${fileDiff.prevName ?? "none"}:${fileDiff.name}`; +} + +function formatTurnChipTimestamp(isoDate: string): string { + return new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + }).format(new Date(isoDate)); +} + +interface DiffPanelProps { + mode?: "inline" | "sheet"; +} + +export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; + +export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { + const api = useNativeApi(); + const navigate = useNavigate(); + const { resolvedTheme } = useTheme(); + const { state } = useStore(); + const [diffRenderMode, setDiffRenderMode] = useState("stacked"); + const patchViewportRef = useRef(null); + const turnStripRef = useRef(null); + const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); + const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); + const params = useParams({ strict: false }); + const rawSearch = useSearch({ strict: false }); + const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const activeThreadId = routeThreadId; + const activeThread = state.threads.find((thread) => thread.id === activeThreadId); + const activeThreadRuntimeId = + activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; + const activeSessionId = activeThread?.session?.sessionId ?? null; + const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = + useTurnDiffSummaries(activeThread); + + const selectedTurnId = diffSearch.diffTurnId ?? null; + const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; + const selectedTurn = + selectedTurnId === null + ? undefined + : (turnDiffSummaries.find((summary) => summary.turnId === selectedTurnId) ?? + turnDiffSummaries[0]); + const selectedCheckpointTurnCount = + selectedTurn && + (selectedTurn.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[selectedTurn.turnId]); + const selectedCheckpointRange = useMemo( + () => + typeof selectedCheckpointTurnCount === "number" + ? { + fromTurnCount: Math.max(0, selectedCheckpointTurnCount - 1), + toTurnCount: selectedCheckpointTurnCount, + } + : null, + [selectedCheckpointTurnCount], + ); + const conversationCheckpointTurnCount = useMemo(() => { + const turnCounts = turnDiffSummaries + .map( + (summary) => + summary.checkpointTurnCount ?? inferredCheckpointTurnCountByTurnId[summary.turnId], + ) + .filter((value): value is number => typeof value === "number"); + if (turnCounts.length === 0) { + return undefined; + } + const latest = Math.max(...turnCounts); + return latest > 0 ? latest : undefined; + }, [inferredCheckpointTurnCountByTurnId, turnDiffSummaries]); + const conversationCheckpointRange = useMemo( + () => + !selectedTurn && typeof conversationCheckpointTurnCount === "number" + ? { + fromTurnCount: 0, + toTurnCount: conversationCheckpointTurnCount, + } + : null, + [conversationCheckpointTurnCount, selectedTurn], + ); + const activeCheckpointRange = selectedTurn + ? selectedCheckpointRange + : conversationCheckpointRange; + const conversationCacheScope = useMemo(() => { + if (selectedTurn || turnDiffSummaries.length === 0) { + return null; + } + return `conversation:${turnDiffSummaries.map((summary) => summary.turnId).join(",")}`; + }, [selectedTurn, turnDiffSummaries]); + const activeCheckpointDiffQuery = useQuery( + checkpointDiffQueryOptions(api, { + sessionId: activeSessionId, + threadRuntimeId: activeThreadRuntimeId, + fromTurnCount: activeCheckpointRange?.fromTurnCount ?? null, + toTurnCount: activeCheckpointRange?.toTurnCount ?? null, + cacheScope: selectedTurn ? `turn:${selectedTurn.turnId}` : conversationCacheScope, + }), + ); + const selectedTurnCheckpointDiff = selectedTurn + ? activeCheckpointDiffQuery.data?.diff + : undefined; + const conversationCheckpointDiff = selectedTurn + ? undefined + : activeCheckpointDiffQuery.data?.diff; + const isLoadingCheckpointDiff = activeCheckpointDiffQuery.isLoading; + const checkpointDiffError = + activeCheckpointDiffQuery.error instanceof Error + ? activeCheckpointDiffQuery.error.message + : activeCheckpointDiffQuery.error + ? "Failed to load checkpoint diff." + : null; + + const selectedPatch = selectedTurn ? selectedTurnCheckpointDiff : conversationCheckpointDiff; + const renderablePatch = useMemo(() => getRenderablePatch(selectedPatch), [selectedPatch]); + const renderableFiles = useMemo(() => { + if (!renderablePatch || renderablePatch.kind !== "files") { + return []; + } + return renderablePatch.files.toSorted((left, right) => + resolveFileDiffPath(left).localeCompare(resolveFileDiffPath(right), undefined, { + numeric: true, + sensitivity: "base", + }), + ); + }, [renderablePatch]); + + useEffect(() => { + if (!selectedFilePath || !patchViewportRef.current) { + return; + } + const target = Array.from( + patchViewportRef.current.querySelectorAll("[data-diff-file-path]"), + ).find((element) => element.dataset.diffFilePath === selectedFilePath); + target?.scrollIntoView({ block: "nearest" }); + }, [selectedFilePath, renderableFiles]); + + const selectTurn = (turnId: string) => { + if (!activeThread) return; + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1", diffTurnId: turnId }; + }, + }); + }; + const selectWholeConversation = () => { + if (!activeThread) return; + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1" }; + }, + }); + }; + const updateTurnStripScrollState = useCallback(() => { + const element = turnStripRef.current; + if (!element) { + setCanScrollTurnStripLeft(false); + setCanScrollTurnStripRight(false); + return; + } + + const maxScrollLeft = Math.max(0, element.scrollWidth - element.clientWidth); + setCanScrollTurnStripLeft(element.scrollLeft > 4); + setCanScrollTurnStripRight(element.scrollLeft < maxScrollLeft - 4); + }, []); + const scrollTurnStripBy = useCallback((offset: number) => { + const element = turnStripRef.current; + if (!element) return; + element.scrollBy({ left: offset, behavior: "smooth" }); + }, []); + const onTurnStripWheel = useCallback((event: WheelEvent) => { + const element = turnStripRef.current; + if (!element) return; + if (element.scrollWidth <= element.clientWidth + 1) return; + if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; + + event.preventDefault(); + element.scrollBy({ left: event.deltaY, behavior: "auto" }); + }, []); + + useEffect(() => { + const element = turnStripRef.current; + if (!element) return; + + updateTurnStripScrollState(); + const onScroll = () => updateTurnStripScrollState(); + + element.addEventListener("scroll", onScroll, { passive: true }); + element.addEventListener("wheel", onTurnStripWheel, { passive: false }); + + const resizeObserver = new ResizeObserver(() => updateTurnStripScrollState()); + resizeObserver.observe(element); + + return () => { + element.removeEventListener("scroll", onScroll); + element.removeEventListener("wheel", onTurnStripWheel); + resizeObserver.disconnect(); + }; + }, [updateTurnStripScrollState, onTurnStripWheel]); + + useEffect(() => { + updateTurnStripScrollState(); + }, [turnDiffSummaries, selectedTurnId, updateTurnStripScrollState]); + + useEffect(() => { + const element = turnStripRef.current; + if (!element) return; + + const selectedChip = element.querySelector("[data-turn-chip-selected='true']"); + selectedChip?.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "smooth" }); + }, [selectedTurn?.turnId, selectedTurnId]); + + const shouldUseDragRegion = isElectron && mode === "inline"; return ( -
- {/* Placeholder content */} -
-
-
-
- - M - - src/App.tsx -
-
-
- const oldValue = "hello";
-
+ const newValue = "world";
-
-
- -
-
- - A - - src/utils.ts -
-
+ {!activeThread ? ( +
+ Select a thread to inspect turn diffs.
- -

- Diff integration coming soon -

-
+ ) : turnDiffSummaries.length === 0 ? ( +
+ No completed turns yet. +
+ ) : ( + <> +
+ {checkpointDiffError && !renderablePatch && ( +
+

{checkpointDiffError}

+
+ )} + {!renderablePatch ? ( +
+

+ {isLoadingCheckpointDiff + ? "Loading checkpoint diff..." + : "No patch available for this selection."} +

+
+ ) : renderablePatch.kind === "files" ? ( + + {renderableFiles.map((fileDiff) => { + const filePath = resolveFileDiffPath(fileDiff); + const fileKey = buildFileDiffRenderKey(fileDiff); + return ( +
+ +
+ ); + })} +
+ ) : ( +
+
+

{renderablePatch.reason}

+
+                    {renderablePatch.text}
+                  
+
+
+ )} +
+ + )} ); } diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53939cf81cfefa1371776945a059c4e6df247ba8 --- /dev/null +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -0,0 +1,26 @@ +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import { useMemo, type ReactNode } from "react"; + +export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { + const workerPoolSize = useMemo(() => { + const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); + return Math.max(2, Math.min(6, Math.floor(cores / 2))); + }, []); + + return ( + + new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), + poolSize: workerPoolSize, + totalASTLRUCacheSize: 240, + }} + highlighterOptions={{ + tokenizeMaxLineLength: 1_000, + }} + > + {children} + + ); +} + diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 44497f48fe5e57c547a816369eac18059f2f7a6d..9b39904c60bee4544798558d619b52133f00abd2 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -67,13 +67,13 @@ export function buildGitActionProgressStages(input: { return [...commitStages, pushStage, "Creating PR..."]; } +const withDescription = (title: string, description: string | undefined) => + description ? { title, description } : { title }; + export function summarizeGitResult(result: GitRunStackedActionResult): { title: string; description?: string; } { - const withDescription = (title: string, description: string | undefined) => - description ? { title, description } : { title }; - if (result.pr.status === "created" || result.pr.status === "opened_existing") { const prNumber = result.pr.number ? ` #${result.pr.number}` : ""; const title = `${result.pr.status === "created" ? "Created PR" : "Opened PR"}${prNumber}`; diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 438c0ac63e58a46dc7c18a0340b084327c994a9e..ed55c5cce7ffdb8c12f90be7a2ee79034828e580 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -38,12 +38,12 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { useStore } from "~/store"; import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; interface GitActionsControlProps { api: NativeApi | undefined; gitCwd: string | null; + activeThreadId: string | null; } function getMenuActionDisabledReason( @@ -136,9 +136,11 @@ function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { return ; } -export default function GitActionsControl({ api, gitCwd }: GitActionsControlProps) { - const { state } = useStore(); - const activeThreadId = state.activeThreadId; +export default function GitActionsControl({ + api, + gitCwd, + activeThreadId, +}: GitActionsControlProps) { const threadToastData = useMemo( () => (activeThreadId ? { threadId: activeThreadId } : undefined), [activeThreadId], @@ -226,18 +228,15 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp }); return; } - const promise = api.shell.openExternal(prUrl); - toastManager.promise(promise, { - loading: { title: "Opening PR...", data: threadToastData }, - success: { title: "PR opened", data: threadToastData }, - error: (err) => ({ + void api.shell.openExternal(prUrl).catch((err) => { + toastManager.add({ + type: "error", title: "Unable to open PR link", description: err instanceof Error ? err.message : "An error occurred.", data: threadToastData, - }), + }); }); - void promise.catch(() => undefined); - }, [api, gitStatusForActions?.pr, threadToastData]); + }, [api, gitStatusForActions?.pr?.state, gitStatusForActions?.pr?.url, threadToastData]); const runGitActionWithToast = useCallback( async ({ @@ -632,7 +631,8 @@ export default function GitActionsControl({ api, gitCwd }: GitActionsControlProp : gitStatusForActions.aheadCount === 0 && gitStatusForActions.behindCount === 0 ? "Up to date" - : gitStatusForActions.aheadCount > 0 && gitStatusForActions.behindCount > 0 + : gitStatusForActions.aheadCount > 0 && + gitStatusForActions.behindCount > 0 ? `Diverged (+${gitStatusForActions.aheadCount} / -${gitStatusForActions.behindCount})` : gitStatusForActions.aheadCount > 0 ? `Ahead by ${gitStatusForActions.aheadCount}` diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx new file mode 100644 index 0000000000000000000000000000000000000000..71d1edad9757762c0b3481afc83f6ae23eee9195 --- /dev/null +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -0,0 +1,464 @@ +import type { + ProjectScript, + ProjectScriptIcon, + ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { + BugIcon, + ChevronDownIcon, + FlaskConicalIcon, + HammerIcon, + ListChecksIcon, + PlayIcon, + PlusIcon, + SettingsIcon, + WrenchIcon, +} from "lucide-react"; +import React, { type FormEvent, type KeyboardEvent, useMemo, useState } from "react"; + +import { commandForProjectScript, primaryProjectScript } from "~/projectScripts"; +import { shortcutLabelForCommand } from "~/keybindings"; +import { isMacPlatform } from "~/lib/utils"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Group, GroupSeparator } from "./ui/group"; +import { Input } from "./ui/input"; +import { Label } from "./ui/label"; +import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"; +import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; +import { Switch } from "./ui/switch"; +import { Textarea } from "./ui/textarea"; + +const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ + { id: "play", label: "Play" }, + { id: "test", label: "Test" }, + { id: "lint", label: "Lint" }, + { id: "configure", label: "Configure" }, + { id: "build", label: "Build" }, + { id: "debug", label: "Debug" }, +]; + +function ScriptIcon({ + icon, + className = "size-3.5", +}: { + icon: ProjectScriptIcon; + className?: string; +}) { + if (icon === "test") return ; + if (icon === "lint") return ; + if (icon === "configure") return ; + if (icon === "build") return ; + if (icon === "debug") return ; + return ; +} + +export interface NewProjectScriptInput { + name: string; + command: string; + icon: ProjectScriptIcon; + runOnWorktreeCreate: boolean; + keybinding: string | null; +} + +interface ProjectScriptsControlProps { + scripts: ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + preferredScriptId?: string | null; + onRunScript: (script: ProjectScript) => void; + onAddScript: (input: NewProjectScriptInput) => Promise | void; + onUpdateScript: (scriptId: string, input: NewProjectScriptInput) => Promise | void; +} + +function normalizeShortcutKeyToken(key: string): string | null { + const normalized = key.toLowerCase(); + if ( + normalized === "meta" || + normalized === "control" || + normalized === "ctrl" || + normalized === "shift" || + normalized === "alt" || + normalized === "option" + ) { + return null; + } + if (normalized === " ") return "space"; + if (normalized === "escape") return "esc"; + if (normalized === "arrowup") return "arrowup"; + if (normalized === "arrowdown") return "arrowdown"; + if (normalized === "arrowleft") return "arrowleft"; + if (normalized === "arrowright") return "arrowright"; + if (normalized.length === 1) return normalized; + if (normalized.startsWith("f") && normalized.length <= 3) return normalized; + if (normalized === "enter" || normalized === "tab" || normalized === "backspace") { + return normalized; + } + if (normalized === "delete" || normalized === "home" || normalized === "end") { + return normalized; + } + if (normalized === "pageup" || normalized === "pagedown") return normalized; + return null; +} + +function keybindingFromEvent(event: KeyboardEvent): string | null { + const keyToken = normalizeShortcutKeyToken(event.key); + if (!keyToken) return null; + + const parts: string[] = []; + if (isMacPlatform(navigator.platform)) { + if (event.metaKey) parts.push("mod"); + if (event.ctrlKey) parts.push("ctrl"); + } else { + if (event.ctrlKey) parts.push("mod"); + if (event.metaKey) parts.push("meta"); + } + if (event.altKey) parts.push("alt"); + if (event.shiftKey) parts.push("shift"); + if (parts.length === 0) { + return null; + } + parts.push(keyToken); + return parts.join("+"); +} + +function keybindingValueForCommand( + keybindings: ResolvedKeybindingsConfig, + command: string, +): string | null { + for (let index = keybindings.length - 1; index >= 0; index -= 1) { + const binding = keybindings[index]; + if (!binding || binding.command !== command) continue; + + const parts: string[] = []; + if (binding.shortcut.modKey) parts.push("mod"); + if (binding.shortcut.ctrlKey) parts.push("ctrl"); + if (binding.shortcut.metaKey) parts.push("meta"); + if (binding.shortcut.altKey) parts.push("alt"); + if (binding.shortcut.shiftKey) parts.push("shift"); + const keyToken = + binding.shortcut.key === " " + ? "space" + : binding.shortcut.key === "escape" + ? "esc" + : binding.shortcut.key; + parts.push(keyToken); + return parts.join("+"); + } + return null; +} + +export default function ProjectScriptsControl({ + scripts, + keybindings, + preferredScriptId = null, + onRunScript, + onAddScript, + onUpdateScript, +}: ProjectScriptsControlProps) { + const addScriptFormId = React.useId(); + const [editingScriptId, setEditingScriptId] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [name, setName] = useState(""); + const [command, setCommand] = useState(""); + const [icon, setIcon] = useState("play"); + const [iconPickerOpen, setIconPickerOpen] = useState(false); + const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [keybinding, setKeybinding] = useState(""); + const [validationError, setValidationError] = useState(null); + + const primaryScript = useMemo(() => { + if (preferredScriptId) { + const preferred = scripts.find((script) => script.id === preferredScriptId); + if (preferred) return preferred; + } + return primaryProjectScript(scripts); + }, [preferredScriptId, scripts]); + const isEditing = editingScriptId !== null; + const dropdownItemClassName = + "data-highlighted:bg-transparent data-highlighted:text-foreground hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground data-highlighted:hover:bg-accent data-highlighted:hover:text-accent-foreground data-highlighted:focus-visible:bg-accent data-highlighted:focus-visible:text-accent-foreground"; + + const captureKeybinding = (event: KeyboardEvent) => { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Backspace" || event.key === "Delete") { + setKeybinding(""); + return; + } + const next = keybindingFromEvent(event); + if (!next) return; + setKeybinding(next); + }; + + const submitAddScript = async (event: FormEvent) => { + event.preventDefault(); + const trimmedName = name.trim(); + const trimmedCommand = command.trim(); + if (trimmedName.length === 0) { + setValidationError("Name is required."); + return; + } + if (trimmedCommand.length === 0) { + setValidationError("Command is required."); + return; + } + + setValidationError(null); + try { + const payload = { + name: trimmedName, + command: trimmedCommand, + icon, + runOnWorktreeCreate, + keybinding: keybinding.trim() || null, + } satisfies NewProjectScriptInput; + if (editingScriptId) { + await onUpdateScript(editingScriptId, payload); + } else { + await onAddScript(payload); + } + setDialogOpen(false); + setIconPickerOpen(false); + } catch (error) { + setValidationError(error instanceof Error ? error.message : "Failed to save action."); + } + }; + + const openAddDialog = () => { + setEditingScriptId(null); + setName(""); + setCommand(""); + setIcon("play"); + setIconPickerOpen(false); + setRunOnWorktreeCreate(false); + setKeybinding(""); + setValidationError(null); + setDialogOpen(true); + }; + + const openEditDialog = (script: ProjectScript) => { + setEditingScriptId(script.id); + setName(script.name); + setCommand(script.command); + setIcon(script.icon); + setIconPickerOpen(false); + setRunOnWorktreeCreate(script.runOnWorktreeCreate); + setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); + setValidationError(null); + setDialogOpen(true); + }; + + return ( + <> + {primaryScript ? ( + + + + + } + > + + + + {scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand( + keybindings, + commandForProjectScript(script.id), + ); + return ( + onRunScript(script)} + > + + + {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} + + + {shortcutLabel && ( + + {shortcutLabel} + + )} + + + + ); + })} + + + Add action + + + + + ) : ( + + )} + + { + setDialogOpen(open); + if (!open) { + setIconPickerOpen(false); + } + }} + onOpenChangeComplete={(open) => { + if (open) return; + setEditingScriptId(null); + setName(""); + setCommand(""); + setIcon("play"); + setRunOnWorktreeCreate(false); + setKeybinding(""); + setValidationError(null); + }} + open={dialogOpen} + > + + + {isEditing ? "Edit Action" : "Add Action"} + + Actions are project-scoped commands you can run from the top bar or keybindings. + + + +
+
+ +
+ + + } + > + + + +
+ {SCRIPT_ICONS.map((entry) => { + const isSelected = entry.id === icon; + return ( + + ); + })} +
+
+
+ setName(event.target.value)} + /> +
+
+
+ + +

+ Press a shortcut. Use Backspace to clear. +

+
+
+ +