| author | |
| committer | |
| log | a002239fc40750ca04fe1c890cd5b9395cceb89b |
| tree | 81d2d4d6623a037ccb97cf048f933bf2f747cf4e |
| parent | f3b773f1f0eca53ff6e5a70df4bcb5ae3eb1ffd4 |
| parent | 70a13c298f50be8c620020708d8b1db6ae80557e |
Co-authored-by: codex <codex@users.noreply.github.com>52 files changed, 1682 insertions(+), 321 deletions(-)
.gitignore+1| ... | ... | @@ -10,3 +10,4 @@ packages/*/dist |
| 10 | 10 | .env.local |
| 11 | 11 | build/ |
| 12 | 12 | .logs/ |
| 13 | release/ |
README.md+14| ... | ... | @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop |
| 56 | 56 | bun run build |
| 57 | 57 | bun run start |
| 58 | 58 | |
| 59 | # Build a shareable macOS .dmg (arm64 by default) | |
| 60 | bun run dist:desktop:dmg | |
| 61 | ||
| 59 | 62 | # Or from any project directory after publishing: |
| 60 | 63 | npx t3 |
| 61 | 64 | ``` |
| ... | ... | @@ -69,6 +72,17 @@ npx t3 |
| 69 | 72 | - `bun run build` — Builds contracts, web app, and server through Turbo. |
| 70 | 73 | - `bun run typecheck` — Strict TypeScript checks for all packages. |
| 71 | 74 | - `bun run test` — Runs workspace tests. |
| 75 | - `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. | |
| 76 | - `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. | |
| 77 | ||
| 78 | ### Desktop `.dmg` packaging notes | |
| 79 | ||
| 80 | - Default build is unsigned/not notarized for local sharing. | |
| 81 | - The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. | |
| 82 | - Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). | |
| 83 | - Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. | |
| 84 | - Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. | |
| 85 | - To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` | |
| 72 | 86 | |
| 73 | 87 | ### Running multiple dev instances |
| 74 | 88 |
TODO.md+3-1| ... | ... | @@ -2,6 +2,8 @@ |
| 2 | 2 | |
| 3 | 3 | ## Small things |
| 4 | 4 | |
| 5 | - [ ] Plan mode | |
| 6 | - [ ] Submitting new messages should scroll to bottom | |
| 5 | 7 | - [ ] 1 click copy in codeblocks |
| 6 | 8 | - [x] Pasting/adding images to submit with prompts |
| 7 | 9 | - [ ] Only show last 10 threads for a given project |
| ... | ... | @@ -12,7 +14,7 @@ |
| 12 | 14 | - [x] "Response" bar should ONLY appear on work completed |
| 13 | 15 | - [x] "Response" bar should ONLY appear above the FINAL message in a series |
| 14 | 16 | - [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) |
| 15 | - [ ] Insta focus input on new thread creation | |
| 17 | - [x] Insta focus input on new thread creation | |
| 16 | 18 | - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it |
| 17 | 19 | - [x] Making new thread should auto-focus input box |
| 18 | 20 | - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) |
apps/desktop/.gitignore+1| ... | ... | @@ -1 +1,2 @@ |
| 1 | 1 | dist-electron/ |
| 2 | .electron-runtime/ |
apps/desktop/package.json+2-1| ... | ... | @@ -2,13 +2,14 @@ |
| 2 | 2 | "name": "@t3tools/desktop", |
| 3 | 3 | "version": "0.0.0", |
| 4 | 4 | "private": true, |
| 5 | "productName": "T3 Code (Alpha)", | |
| 5 | 6 | "main": "dist-electron/main.js", |
| 6 | 7 | "scripts": { |
| 7 | 8 | "dev": "bun run --parallel dev:bundle dev:electron", |
| 8 | 9 | "dev:bundle": "tsdown --watch", |
| 9 | 10 | "dev:electron": "bun run scripts/dev-electron.mjs", |
| 10 | 11 | "build": "tsdown", |
| 11 | "start": "electron dist-electron/main.js", | |
| 12 | "start": "bun run scripts/start-electron.mjs", | |
| 12 | 13 | "typecheck": "tsc --noEmit", |
| 13 | 14 | "test": "vitest run --passWithNoTests", |
| 14 | 15 | "smoke-test": "node scripts/smoke-test.mjs" |
apps/desktop/resources/icon.icns created| Binary files /dev/null and b/apps/desktop/resources/icon.icns differ |
apps/desktop/resources/icon.ico created| Binary files /dev/null and b/apps/desktop/resources/icon.ico differ |
apps/desktop/resources/icon.png created| Binary files /dev/null and b/apps/desktop/resources/icon.png differ |
apps/desktop/scripts/dev-electron.mjs+48-6| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 1 | import { spawnSync } from "node:child_process"; | |
| 2 | 2 | |
| 3 | import electronmon from "electronmon"; | |
| 3 | 4 | import waitOn from "wait-on"; |
| 4 | 5 | |
| 6 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 7 | ||
| 5 | 8 | const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); |
| 6 | 9 | const devServerUrl = `http://localhost:${port}`; |
| 7 | 10 | |
| ... | ... | @@ -14,18 +17,57 @@ await waitOn({ |
| 14 | 17 | ], |
| 15 | 18 | }); |
| 16 | 19 | |
| 17 | const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; | |
| 18 | 20 | const childEnv = { ...process.env }; |
| 19 | 21 | delete childEnv.ELECTRON_RUN_AS_NODE; |
| 20 | 22 | |
| 21 | const child = spawn(command, ["dist-electron/main.js"], { | |
| 22 | stdio: "inherit", | |
| 23 | const monitor = await electronmon({ | |
| 24 | cwd: desktopDir, | |
| 25 | args: ["dist-electron/main.js"], | |
| 23 | 26 | env: { |
| 24 | 27 | ...childEnv, |
| 25 | 28 | VITE_DEV_SERVER_URL: devServerUrl, |
| 26 | 29 | }, |
| 30 | electronPath: resolveElectronPath(), | |
| 27 | 31 | }); |
| 28 | 32 | |
| 29 | child.on("exit", (code) => { | |
| 30 | process.exit(code ?? 0); | |
| 33 | let shuttingDown = false; | |
| 34 | ||
| 35 | function killChildTree(signal) { | |
| 36 | if (process.platform === "win32") { | |
| 37 | return; | |
| 38 | } | |
| 39 | ||
| 40 | // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. | |
| 41 | spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); | |
| 42 | } | |
| 43 | ||
| 44 | async function shutdown(exitCode) { | |
| 45 | if (shuttingDown) return; | |
| 46 | shuttingDown = true; | |
| 47 | ||
| 48 | try { | |
| 49 | await Promise.race([ | |
| 50 | monitor.destroy(), | |
| 51 | new Promise((resolve) => setTimeout(resolve, 1_500)), | |
| 52 | ]); | |
| 53 | } catch { | |
| 54 | // Best effort only; fallback process kill below handles stubborn children. | |
| 55 | } | |
| 56 | ||
| 57 | killChildTree("TERM"); | |
| 58 | setTimeout(() => { | |
| 59 | killChildTree("KILL"); | |
| 60 | }, 1_200).unref(); | |
| 61 | ||
| 62 | process.exit(exitCode); | |
| 63 | } | |
| 64 | ||
| 65 | process.once("SIGINT", () => { | |
| 66 | void shutdown(130); | |
| 67 | }); | |
| 68 | process.once("SIGTERM", () => { | |
| 69 | void shutdown(143); | |
| 70 | }); | |
| 71 | process.once("SIGHUP", () => { | |
| 72 | void shutdown(129); | |
| 31 | 73 | }); |
apps/desktop/scripts/electron-launcher.mjs created+173| ... | ... | @@ -0,0 +1,173 @@ |
| 1 | // This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" | |
| 2 | ||
| 3 | import { spawnSync } from "node:child_process"; | |
| 4 | import { | |
| 5 | copyFileSync, | |
| 6 | cpSync, | |
| 7 | existsSync, | |
| 8 | mkdirSync, | |
| 9 | readFileSync, | |
| 10 | readdirSync, | |
| 11 | rmSync, | |
| 12 | statSync, | |
| 13 | writeFileSync, | |
| 14 | } from "node:fs"; | |
| 15 | import { createRequire } from "node:module"; | |
| 16 | import { dirname, join, resolve } from "node:path"; | |
| 17 | import { fileURLToPath } from "node:url"; | |
| 18 | ||
| 19 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); | |
| 20 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 21 | const APP_BUNDLE_ID = "com.t3tools.t3code"; | |
| 22 | const LAUNCHER_VERSION = 1; | |
| 23 | ||
| 24 | const __dirname = dirname(fileURLToPath(import.meta.url)); | |
| 25 | export const desktopDir = resolve(__dirname, ".."); | |
| 26 | ||
| 27 | function setPlistString(plistPath, key, value) { | |
| 28 | const replaceResult = spawnSync( | |
| 29 | "plutil", | |
| 30 | ["-replace", key, "-string", value, plistPath], | |
| 31 | { | |
| 32 | encoding: "utf8", | |
| 33 | }, | |
| 34 | ); | |
| 35 | if (replaceResult.status === 0) { | |
| 36 | return; | |
| 37 | } | |
| 38 | ||
| 39 | const insertResult = spawnSync( | |
| 40 | "plutil", | |
| 41 | ["-insert", key, "-string", value, plistPath], | |
| 42 | { | |
| 43 | encoding: "utf8", | |
| 44 | }, | |
| 45 | ); | |
| 46 | if (insertResult.status === 0) { | |
| 47 | return; | |
| 48 | } | |
| 49 | ||
| 50 | const details = [replaceResult.stderr, insertResult.stderr] | |
| 51 | .filter(Boolean) | |
| 52 | .join("\n"); | |
| 53 | throw new Error( | |
| 54 | `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), | |
| 55 | ); | |
| 56 | } | |
| 57 | ||
| 58 | function patchMainBundleInfoPlist(appBundlePath, iconPath) { | |
| 59 | const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); | |
| 60 | setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); | |
| 61 | setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); | |
| 62 | setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); | |
| 63 | setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); | |
| 64 | ||
| 65 | const resourcesDir = join(appBundlePath, "Contents", "Resources"); | |
| 66 | copyFileSync(iconPath, join(resourcesDir, "icon.icns")); | |
| 67 | copyFileSync(iconPath, join(resourcesDir, "electron.icns")); | |
| 68 | } | |
| 69 | ||
| 70 | function patchHelperBundleInfoPlists(appBundlePath) { | |
| 71 | const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); | |
| 72 | if (!existsSync(frameworksDir)) { | |
| 73 | return; | |
| 74 | } | |
| 75 | ||
| 76 | for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { | |
| 77 | if (!entry.isDirectory() || !entry.name.endsWith(".app")) { | |
| 78 | continue; | |
| 79 | } | |
| 80 | if (!entry.name.startsWith("Electron Helper")) { | |
| 81 | continue; | |
| 82 | } | |
| 83 | ||
| 84 | const helperPlistPath = join( | |
| 85 | frameworksDir, | |
| 86 | entry.name, | |
| 87 | "Contents", | |
| 88 | "Info.plist", | |
| 89 | ); | |
| 90 | if (!existsSync(helperPlistPath)) { | |
| 91 | continue; | |
| 92 | } | |
| 93 | ||
| 94 | const suffix = entry.name | |
| 95 | .replace("Electron Helper", "") | |
| 96 | .replace(".app", "") | |
| 97 | .trim(); | |
| 98 | const helperName = suffix | |
| 99 | ? `${APP_DISPLAY_NAME} Helper ${suffix}` | |
| 100 | : `${APP_DISPLAY_NAME} Helper`; | |
| 101 | const helperIdSuffix = suffix | |
| 102 | .replace(/[()]/g, "") | |
| 103 | .trim() | |
| 104 | .toLowerCase() | |
| 105 | .replace(/\s+/g, "-"); | |
| 106 | const helperBundleId = helperIdSuffix | |
| 107 | ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` | |
| 108 | : `${APP_BUNDLE_ID}.helper`; | |
| 109 | ||
| 110 | setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); | |
| 111 | setPlistString(helperPlistPath, "CFBundleName", helperName); | |
| 112 | setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); | |
| 113 | } | |
| 114 | } | |
| 115 | ||
| 116 | function readJson(path) { | |
| 117 | try { | |
| 118 | return JSON.parse(readFileSync(path, "utf8")); | |
| 119 | } catch { | |
| 120 | return null; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | function buildMacLauncher(electronBinaryPath) { | |
| 125 | const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); | |
| 126 | const runtimeDir = join(desktopDir, ".electron-runtime"); | |
| 127 | const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); | |
| 128 | const targetBinaryPath = join( | |
| 129 | targetAppBundlePath, | |
| 130 | "Contents", | |
| 131 | "MacOS", | |
| 132 | "Electron", | |
| 133 | ); | |
| 134 | const iconPath = join(desktopDir, "resources", "icon.icns"); | |
| 135 | const metadataPath = join(runtimeDir, "metadata.json"); | |
| 136 | ||
| 137 | mkdirSync(runtimeDir, { recursive: true }); | |
| 138 | ||
| 139 | const expectedMetadata = { | |
| 140 | launcherVersion: LAUNCHER_VERSION, | |
| 141 | sourceAppBundlePath, | |
| 142 | sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, | |
| 143 | iconMtimeMs: statSync(iconPath).mtimeMs, | |
| 144 | }; | |
| 145 | ||
| 146 | const currentMetadata = readJson(metadataPath); | |
| 147 | if ( | |
| 148 | existsSync(targetBinaryPath) && | |
| 149 | currentMetadata && | |
| 150 | JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) | |
| 151 | ) { | |
| 152 | return targetBinaryPath; | |
| 153 | } | |
| 154 | ||
| 155 | rmSync(targetAppBundlePath, { recursive: true, force: true }); | |
| 156 | cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); | |
| 157 | patchMainBundleInfoPlist(targetAppBundlePath, iconPath); | |
| 158 | patchHelperBundleInfoPlists(targetAppBundlePath); | |
| 159 | writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); | |
| 160 | ||
| 161 | return targetBinaryPath; | |
| 162 | } | |
| 163 | ||
| 164 | export function resolveElectronPath() { | |
| 165 | const require = createRequire(import.meta.url); | |
| 166 | const electronBinaryPath = require("electron"); | |
| 167 | ||
| 168 | if (process.platform !== "darwin") { | |
| 169 | return electronBinaryPath; | |
| 170 | } | |
| 171 | ||
| 172 | return buildMacLauncher(electronBinaryPath); | |
| 173 | } |
apps/desktop/scripts/start-electron.mjs created+20| ... | ... | @@ -0,0 +1,20 @@ |
| 1 | import { spawn } from "node:child_process"; | |
| 2 | ||
| 3 | import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; | |
| 4 | ||
| 5 | const childEnv = { ...process.env }; | |
| 6 | delete childEnv.ELECTRON_RUN_AS_NODE; | |
| 7 | ||
| 8 | const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { | |
| 9 | stdio: "inherit", | |
| 10 | cwd: desktopDir, | |
| 11 | env: childEnv, | |
| 12 | }); | |
| 13 | ||
| 14 | child.on("exit", (code, signal) => { | |
| 15 | if (signal) { | |
| 16 | process.kill(process.pid, signal); | |
| 17 | return; | |
| 18 | } | |
| 19 | process.exit(code ?? 0); | |
| 20 | }); |
apps/desktop/src/main.ts+289-14| ... | ... | @@ -5,7 +5,16 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; | |
| 8 | import { | |
| 9 | app, | |
| 10 | BrowserWindow, | |
| 11 | dialog, | |
| 12 | ipcMain, | |
| 13 | Menu, | |
| 14 | type MenuItemConstructorOptions, | |
| 15 | protocol, | |
| 16 | shell, | |
| 17 | } from "electron"; | |
| 9 | 18 | |
| 10 | 19 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 11 | 20 | import { fixPath } from "./fixPath"; |
| ... | ... | @@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 16 | 25 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 17 | 26 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 18 | 27 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 19 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 20 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); | |
| 21 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | |
| 28 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 22 | 29 | const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); |
| 30 | const DESKTOP_SCHEME = "t3"; | |
| 31 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | |
| 23 | 32 | const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); |
| 33 | const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; | |
| 34 | const APP_USER_MODEL_ID = "com.t3tools.t3code"; | |
| 24 | 35 | |
| 25 | 36 | let mainWindow: BrowserWindow | null = null; |
| 26 | 37 | let backendProcess: ChildProcess | null = null; |
| ... | ... | @@ -30,6 +41,235 @@ let backendWsUrl = ""; |
| 30 | 41 | let restartAttempt = 0; |
| 31 | 42 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 32 | 43 | let isQuitting = false; |
| 44 | let desktopProtocolRegistered = false; | |
| 45 | ||
| 46 | protocol.registerSchemesAsPrivileged([ | |
| 47 | { | |
| 48 | scheme: DESKTOP_SCHEME, | |
| 49 | privileges: { | |
| 50 | standard: true, | |
| 51 | secure: true, | |
| 52 | supportFetchAPI: true, | |
| 53 | corsEnabled: true, | |
| 54 | }, | |
| 55 | }, | |
| 56 | ]); | |
| 57 | ||
| 58 | function resolveAppRoot(): string { | |
| 59 | if (!app.isPackaged) { | |
| 60 | return ROOT_DIR; | |
| 61 | } | |
| 62 | return app.getAppPath(); | |
| 63 | } | |
| 64 | ||
| 65 | function resolveBackendEntry(): string { | |
| 66 | return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); | |
| 67 | } | |
| 68 | ||
| 69 | function resolveBackendCwd(): string { | |
| 70 | if (!app.isPackaged) { | |
| 71 | return resolveAppRoot(); | |
| 72 | } | |
| 73 | return os.homedir(); | |
| 74 | } | |
| 75 | ||
| 76 | function resolveDesktopStaticDir(): string | null { | |
| 77 | const appRoot = resolveAppRoot(); | |
| 78 | const candidates = [ | |
| 79 | path.join(appRoot, "apps/server/dist/client"), | |
| 80 | path.join(appRoot, "apps/web/dist"), | |
| 81 | ]; | |
| 82 | ||
| 83 | for (const candidate of candidates) { | |
| 84 | if (fs.existsSync(path.join(candidate, "index.html"))) { | |
| 85 | return candidate; | |
| 86 | } | |
| 87 | } | |
| 88 | ||
| 89 | return null; | |
| 90 | } | |
| 91 | ||
| 92 | function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { | |
| 93 | const url = new URL(requestUrl); | |
| 94 | const rawPath = decodeURIComponent(url.pathname); | |
| 95 | const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); | |
| 96 | if (normalizedPath.includes("..")) { | |
| 97 | return path.join(staticRoot, "index.html"); | |
| 98 | } | |
| 99 | ||
| 100 | const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; | |
| 101 | const resolvedPath = path.join(staticRoot, requestedPath); | |
| 102 | ||
| 103 | if (path.extname(resolvedPath)) { | |
| 104 | return resolvedPath; | |
| 105 | } | |
| 106 | ||
| 107 | const nestedIndex = path.join(resolvedPath, "index.html"); | |
| 108 | if (fs.existsSync(nestedIndex)) { | |
| 109 | return nestedIndex; | |
| 110 | } | |
| 111 | ||
| 112 | return path.join(staticRoot, "index.html"); | |
| 113 | } | |
| 114 | ||
| 115 | function isStaticAssetRequest(requestUrl: string): boolean { | |
| 116 | try { | |
| 117 | const url = new URL(requestUrl); | |
| 118 | return path.extname(url.pathname).length > 0; | |
| 119 | } catch { | |
| 120 | return false; | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | function registerDesktopProtocol(): void { | |
| 125 | if (isDevelopment || desktopProtocolRegistered) return; | |
| 126 | ||
| 127 | const staticRoot = resolveDesktopStaticDir(); | |
| 128 | if (!staticRoot) { | |
| 129 | throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); | |
| 130 | } | |
| 131 | ||
| 132 | const staticRootResolved = path.resolve(staticRoot); | |
| 133 | const staticRootPrefix = `${staticRootResolved}${path.sep}`; | |
| 134 | const fallbackIndex = path.join(staticRootResolved, "index.html"); | |
| 135 | ||
| 136 | protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { | |
| 137 | try { | |
| 138 | const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); | |
| 139 | const resolvedCandidate = path.resolve(candidate); | |
| 140 | const isInRoot = | |
| 141 | resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); | |
| 142 | const isAssetRequest = isStaticAssetRequest(request.url); | |
| 143 | ||
| 144 | if (!isInRoot || !fs.existsSync(resolvedCandidate)) { | |
| 145 | if (isAssetRequest) { | |
| 146 | callback({ error: -6 }); | |
| 147 | return; | |
| 148 | } | |
| 149 | callback({ path: fallbackIndex }); | |
| 150 | return; | |
| 151 | } | |
| 152 | ||
| 153 | callback({ path: resolvedCandidate }); | |
| 154 | } catch { | |
| 155 | callback({ path: fallbackIndex }); | |
| 156 | } | |
| 157 | }); | |
| 158 | ||
| 159 | desktopProtocolRegistered = true; | |
| 160 | } | |
| 161 | ||
| 162 | function dispatchMenuAction(action: string): void { | |
| 163 | const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; | |
| 164 | const targetWindow = existingWindow ?? createWindow(); | |
| 165 | if (!existingWindow) { | |
| 166 | mainWindow = targetWindow; | |
| 167 | } | |
| 168 | ||
| 169 | const send = () => { | |
| 170 | if (targetWindow.isDestroyed()) return; | |
| 171 | targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); | |
| 172 | if (!targetWindow.isVisible()) { | |
| 173 | targetWindow.show(); | |
| 174 | } | |
| 175 | targetWindow.focus(); | |
| 176 | }; | |
| 177 | ||
| 178 | if (targetWindow.webContents.isLoadingMainFrame()) { | |
| 179 | targetWindow.webContents.once("did-finish-load", send); | |
| 180 | return; | |
| 181 | } | |
| 182 | ||
| 183 | send(); | |
| 184 | } | |
| 185 | ||
| 186 | function configureApplicationMenu(): void { | |
| 187 | const template: MenuItemConstructorOptions[] = []; | |
| 188 | ||
| 189 | if (process.platform === "darwin") { | |
| 190 | template.push({ | |
| 191 | label: app.name, | |
| 192 | submenu: [ | |
| 193 | { role: "about" }, | |
| 194 | { | |
| 195 | label: "Settings...", | |
| 196 | accelerator: "CmdOrCtrl+,", | |
| 197 | click: () => dispatchMenuAction("open-settings"), | |
| 198 | }, | |
| 199 | { type: "separator" }, | |
| 200 | { role: "services" }, | |
| 201 | { type: "separator" }, | |
| 202 | { role: "hide" }, | |
| 203 | { role: "hideOthers" }, | |
| 204 | { role: "unhide" }, | |
| 205 | { type: "separator" }, | |
| 206 | { role: "quit" }, | |
| 207 | ], | |
| 208 | }); | |
| 209 | } | |
| 210 | ||
| 211 | template.push( | |
| 212 | { | |
| 213 | label: "File", | |
| 214 | submenu: [ | |
| 215 | ...(process.platform === "darwin" | |
| 216 | ? [] | |
| 217 | : [ | |
| 218 | { | |
| 219 | label: "Settings...", | |
| 220 | accelerator: "CmdOrCtrl+,", | |
| 221 | click: () => dispatchMenuAction("open-settings"), | |
| 222 | }, | |
| 223 | { type: "separator" as const }, | |
| 224 | ]), | |
| 225 | { role: process.platform === "darwin" ? "close" : "quit" }, | |
| 226 | ], | |
| 227 | }, | |
| 228 | { role: "editMenu" }, | |
| 229 | { role: "viewMenu" }, | |
| 230 | { role: "windowMenu" }, | |
| 231 | ); | |
| 232 | ||
| 233 | Menu.setApplicationMenu(Menu.buildFromTemplate(template)); | |
| 234 | } | |
| 235 | ||
| 236 | function resolveResourcePath(fileName: string): string | null { | |
| 237 | const candidates = [ | |
| 238 | path.join(__dirname, "../resources", fileName), | |
| 239 | path.join(process.resourcesPath, "resources", fileName), | |
| 240 | path.join(process.resourcesPath, fileName), | |
| 241 | ]; | |
| 242 | ||
| 243 | for (const candidate of candidates) { | |
| 244 | if (fs.existsSync(candidate)) { | |
| 245 | return candidate; | |
| 246 | } | |
| 247 | } | |
| 248 | ||
| 249 | return null; | |
| 250 | } | |
| 251 | ||
| 252 | function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { | |
| 253 | return resolveResourcePath(`icon.${ext}`); | |
| 254 | } | |
| 255 | ||
| 256 | function configureAppIdentity(): void { | |
| 257 | app.setName(APP_DISPLAY_NAME); | |
| 258 | app.setAboutPanelOptions({ | |
| 259 | applicationName: APP_DISPLAY_NAME, | |
| 260 | }); | |
| 261 | ||
| 262 | if (process.platform === "win32") { | |
| 263 | app.setAppUserModelId(APP_USER_MODEL_ID); | |
| 264 | } | |
| 265 | ||
| 266 | if (process.platform === "darwin" && app.dock) { | |
| 267 | const iconPath = resolveIconPath("png"); | |
| 268 | if (iconPath) { | |
| 269 | app.dock.setIcon(iconPath); | |
| 270 | } | |
| 271 | } | |
| 272 | } | |
| 33 | 273 | |
| 34 | 274 | async function reserveLoopbackPort(): Promise<number> { |
| 35 | 275 | return new Promise((resolve, reject) => { |
| ... | ... | @@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { |
| 76 | 316 | function startBackend(): void { |
| 77 | 317 | if (isQuitting || backendProcess) return; |
| 78 | 318 | |
| 79 | if (!fs.existsSync(BACKEND_ENTRY)) { | |
| 80 | scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); | |
| 319 | const backendEntry = resolveBackendEntry(); | |
| 320 | if (!fs.existsSync(backendEntry)) { | |
| 321 | scheduleBackendRestart(`missing server entry at ${backendEntry}`); | |
| 81 | 322 | return; |
| 82 | 323 | } |
| 83 | 324 | |
| 84 | const child = spawn(process.execPath, [BACKEND_ENTRY], { | |
| 85 | cwd: ROOT_DIR, | |
| 325 | const child = spawn(process.execPath, [backendEntry], { | |
| 326 | cwd: resolveBackendCwd(), | |
| 86 | 327 | // In Electron main, process.execPath points to the Electron binary. |
| 87 | 328 | // Run the child in Node mode so this backend process does not become a GUI app instance. |
| 88 | 329 | env: { |
| ... | ... | @@ -124,10 +365,10 @@ function stopBackend(): void { |
| 124 | 365 | backendProcess = null; |
| 125 | 366 | if (!child) return; |
| 126 | 367 | |
| 127 | if (!child.killed) { | |
| 368 | if (child.exitCode === null && child.signalCode === null) { | |
| 128 | 369 | child.kill("SIGTERM"); |
| 129 | 370 | setTimeout(() => { |
| 130 | if (!child.killed) { | |
| 371 | if (child.exitCode === null && child.signalCode === null) { | |
| 131 | 372 | child.kill("SIGKILL"); |
| 132 | 373 | } |
| 133 | 374 | }, 2_000).unref(); |
| ... | ... | @@ -204,6 +445,13 @@ function registerIpcHandlers(): void { |
| 204 | 445 | }); |
| 205 | 446 | } |
| 206 | 447 | |
| 448 | function getIconOption(): { icon: string } | Record<string, never> { | |
| 449 | if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle | |
| 450 | const ext = process.platform === "win32" ? "ico" : "png"; | |
| 451 | const iconPath = resolveIconPath(ext); | |
| 452 | return iconPath ? { icon: iconPath } : {}; | |
| 453 | } | |
| 454 | ||
| 207 | 455 | function createWindow(): BrowserWindow { |
| 208 | 456 | const window = new BrowserWindow({ |
| 209 | 457 | width: 1100, |
| ... | ... | @@ -212,6 +460,8 @@ function createWindow(): BrowserWindow { |
| 212 | 460 | minHeight: 620, |
| 213 | 461 | show: false, |
| 214 | 462 | autoHideMenuBar: true, |
| 463 | ...getIconOption(), | |
| 464 | title: APP_DISPLAY_NAME, | |
| 215 | 465 | titleBarStyle: "hiddenInset", |
| 216 | 466 | trafficLightPosition: { x: 16, y: 18 }, |
| 217 | 467 | webPreferences: { |
| ... | ... | @@ -223,6 +473,13 @@ function createWindow(): BrowserWindow { |
| 223 | 473 | }); |
| 224 | 474 | |
| 225 | 475 | window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); |
| 476 | window.on("page-title-updated", (event) => { | |
| 477 | event.preventDefault(); | |
| 478 | window.setTitle(APP_DISPLAY_NAME); | |
| 479 | }); | |
| 480 | window.webContents.on("did-finish-load", () => { | |
| 481 | window.setTitle(APP_DISPLAY_NAME); | |
| 482 | }); | |
| 226 | 483 | window.once("ready-to-show", () => { |
| 227 | 484 | window.show(); |
| 228 | 485 | }); |
| ... | ... | @@ -231,10 +488,7 @@ function createWindow(): BrowserWindow { |
| 231 | 488 | void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); |
| 232 | 489 | window.webContents.openDevTools({ mode: "detach" }); |
| 233 | 490 | } else { |
| 234 | if (!fs.existsSync(WEB_ENTRY)) { | |
| 235 | throw new Error(`Web bundle missing at ${WEB_ENTRY}`); | |
| 236 | } | |
| 237 | void window.loadFile(WEB_ENTRY); | |
| 491 | void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); | |
| 238 | 492 | } |
| 239 | 493 | |
| 240 | 494 | window.on("closed", () => { |
| ... | ... | @@ -246,6 +500,8 @@ function createWindow(): BrowserWindow { |
| 246 | 500 | return window; |
| 247 | 501 | } |
| 248 | 502 | |
| 503 | configureAppIdentity(); | |
| 504 | ||
| 249 | 505 | async function bootstrap(): Promise<void> { |
| 250 | 506 | backendPort = await reserveLoopbackPort(); |
| 251 | 507 | backendAuthToken = randomBytes(24).toString("hex"); |
| ... | ... | @@ -263,6 +519,9 @@ app.on("before-quit", () => { |
| 263 | 519 | }); |
| 264 | 520 | |
| 265 | 521 | app.whenReady().then(() => { |
| 522 | configureAppIdentity(); | |
| 523 | configureApplicationMenu(); | |
| 524 | registerDesktopProtocol(); | |
| 266 | 525 | void bootstrap(); |
| 267 | 526 | |
| 268 | 527 | app.on("activate", () => { |
| ... | ... | @@ -277,3 +536,19 @@ app.on("window-all-closed", () => { |
| 277 | 536 | app.quit(); |
| 278 | 537 | } |
| 279 | 538 | }); |
| 539 | ||
| 540 | if (process.platform !== "win32") { | |
| 541 | process.on("SIGINT", () => { | |
| 542 | if (isQuitting) return; | |
| 543 | isQuitting = true; | |
| 544 | stopBackend(); | |
| 545 | app.quit(); | |
| 546 | }); | |
| 547 | ||
| 548 | process.on("SIGTERM", () => { | |
| 549 | if (isQuitting) return; | |
| 550 | isQuitting = true; | |
| 551 | stopBackend(); | |
| 552 | app.quit(); | |
| 553 | }); | |
| 554 | } |
apps/desktop/src/preload.ts+12| ... | ... | @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 4 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 5 | 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 6 | 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 7 | 8 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 8 | 9 | |
| 9 | 10 | contextBridge.exposeInMainWorld("desktopBridge", { |
| ... | ... | @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 13 | 14 | showContextMenu: (items: { id: string; label: string }[]) => |
| 14 | 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, |
| 15 | 16 | openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>, |
| 17 | onMenuAction: (listener: (action: string) => void) => { | |
| 18 | const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { | |
| 19 | if (typeof action !== "string") return; | |
| 20 | listener(action); | |
| 21 | }; | |
| 22 | ||
| 23 | ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); | |
| 24 | return () => { | |
| 25 | ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); | |
| 26 | }; | |
| 27 | }, | |
| 16 | 28 | }); |
apps/server/src/codexAppServerManager.ts+7-2| ... | ... | @@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve |
| 163 | 163 | updatedAt: now, |
| 164 | 164 | }; |
| 165 | 165 | |
| 166 | const child = spawn("codex", ["app-server"], { | |
| 166 | const codexBinaryPath = input.codexBinaryPath?.trim() || "codex"; | |
| 167 | const codexHomePath = input.codexHomePath?.trim(); | |
| 168 | const child = spawn(codexBinaryPath, ["app-server"], { | |
| 167 | 169 | cwd: resolvedCwd, |
| 168 | env: process.env, | |
| 170 | env: { | |
| 171 | ...process.env, | |
| 172 | ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), | |
| 173 | }, | |
| 169 | 174 | stdio: ["pipe", "pipe", "pipe"], |
| 170 | 175 | }); |
| 171 | 176 | const output = readline.createInterface({ input: child.stdout }); |
apps/web/index.html+3-1| ... | ... | @@ -3,13 +3,15 @@ |
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | <link rel="icon" href="/favicon.ico" sizes="48x48" /> | |
| 7 | <link rel="apple-touch-icon" href="/apple-touch-icon.png" /> | |
| 6 | 8 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 7 | 9 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 8 | 10 | <link |
| 9 | 11 | href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap" |
| 10 | 12 | rel="stylesheet" |
| 11 | 13 | /> |
| 12 | <title>T3 Code</title> | |
| 14 | <title>T3 Code (Alpha)</title> | |
| 13 | 15 | </head> |
| 14 | 16 | <body> |
| 15 | 17 | <div id="root"></div> |
apps/web/public/apple-touch-icon.png created| Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ |
apps/web/public/favicon-16x16.png created| Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ |
apps/web/public/favicon-32x32.png created| Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ |
apps/web/public/favicon.ico created| Binary files /dev/null and b/apps/web/public/favicon.ico differ |
apps/web/public/favicon.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
apps/web/src/appSettings.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | |
| 2 | import { z } from "zod"; | |
| 3 | ||
| 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | |
| 5 | ||
| 6 | const appSettingsSchema = z.object({ | |
| 7 | codexBinaryPath: z.string().trim().max(4096).default(""), | |
| 8 | codexHomePath: z.string().trim().max(4096).default(""), | |
| 9 | confirmThreadDelete: z.boolean().default(true), | |
| 10 | }); | |
| 11 | ||
| 12 | export type AppSettings = z.infer<typeof appSettingsSchema>; | |
| 13 | ||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | |
| 15 | ||
| 16 | let listeners: Array<() => void> = []; | |
| 17 | let cachedRawSettings: string | null | undefined; | |
| 18 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; | |
| 19 | ||
| 20 | function emitChange(): void { | |
| 21 | for (const listener of listeners) { | |
| 22 | listener(); | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | function parsePersistedSettings(value: string | null): AppSettings { | |
| 27 | if (!value) { | |
| 28 | return DEFAULT_APP_SETTINGS; | |
| 29 | } | |
| 30 | ||
| 31 | try { | |
| 32 | const parsed = JSON.parse(value) as unknown; | |
| 33 | return appSettingsSchema.parse(parsed); | |
| 34 | } catch { | |
| 35 | return DEFAULT_APP_SETTINGS; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | export function getAppSettingsSnapshot(): AppSettings { | |
| 40 | if (typeof window === "undefined") { | |
| 41 | return DEFAULT_APP_SETTINGS; | |
| 42 | } | |
| 43 | ||
| 44 | const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); | |
| 45 | if (raw === cachedRawSettings) { | |
| 46 | return cachedSnapshot; | |
| 47 | } | |
| 48 | ||
| 49 | cachedRawSettings = raw; | |
| 50 | cachedSnapshot = parsePersistedSettings(raw); | |
| 51 | return cachedSnapshot; | |
| 52 | } | |
| 53 | ||
| 54 | function persistSettings(next: AppSettings): void { | |
| 55 | if (typeof window === "undefined") return; | |
| 56 | ||
| 57 | const raw = JSON.stringify(next); | |
| 58 | try { | |
| 59 | if (raw !== cachedRawSettings) { | |
| 60 | window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); | |
| 61 | } | |
| 62 | } catch { | |
| 63 | // Best-effort persistence only. | |
| 64 | } | |
| 65 | ||
| 66 | cachedRawSettings = raw; | |
| 67 | cachedSnapshot = next; | |
| 68 | } | |
| 69 | ||
| 70 | function subscribe(listener: () => void): () => void { | |
| 71 | listeners.push(listener); | |
| 72 | ||
| 73 | const onStorage = (event: StorageEvent) => { | |
| 74 | if (event.key === APP_SETTINGS_STORAGE_KEY) { | |
| 75 | emitChange(); | |
| 76 | } | |
| 77 | }; | |
| 78 | ||
| 79 | window.addEventListener("storage", onStorage); | |
| 80 | return () => { | |
| 81 | listeners = listeners.filter((entry) => entry !== listener); | |
| 82 | window.removeEventListener("storage", onStorage); | |
| 83 | }; | |
| 84 | } | |
| 85 | ||
| 86 | export function useAppSettings() { | |
| 87 | const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); | |
| 88 | ||
| 89 | const updateSettings = useCallback((patch: Partial<AppSettings>) => { | |
| 90 | const next = appSettingsSchema.parse({ | |
| 91 | ...getAppSettingsSnapshot(), | |
| 92 | ...patch, | |
| 93 | }); | |
| 94 | persistSettings(next); | |
| 95 | emitChange(); | |
| 96 | }, []); | |
| 97 | ||
| 98 | const resetSettings = useCallback(() => { | |
| 99 | persistSettings(DEFAULT_APP_SETTINGS); | |
| 100 | emitChange(); | |
| 101 | }, []); | |
| 102 | ||
| 103 | return { | |
| 104 | settings, | |
| 105 | updateSettings, | |
| 106 | resetSettings, | |
| 107 | defaults: DEFAULT_APP_SETTINGS, | |
| 108 | } as const; | |
| 109 | } |
apps/web/src/branding.ts created+3| ... | ... | @@ -0,0 +1,3 @@ |
| 1 | export const APP_BASE_NAME = "T3 Code"; | |
| 2 | export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; | |
| 3 | export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; |
apps/web/src/components/ChatView.tsx+54-28| ... | ... | @@ -28,6 +28,7 @@ import { |
| 28 | 28 | } from "react"; |
| 29 | 29 | import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 30 | 30 | import { useDebouncedValue } from "@tanstack/react-pacer"; |
| 31 | import { useNavigate, useSearch } from "@tanstack/react-router"; | |
| 31 | 32 | import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; |
| 32 | 33 | import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; |
| 33 | 34 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| ... | ... | @@ -35,6 +36,8 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 35 | 36 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 36 | 37 | |
| 37 | 38 | import { isElectron } from "../env"; |
| 39 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 40 | import { useAppSettings } from "../appSettings"; | |
| 38 | 41 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 39 | 42 | import { |
| 40 | 43 | type ComposerTriggerKind, |
| ... | ... | @@ -374,8 +377,11 @@ interface ChatViewProps { |
| 374 | 377 | |
| 375 | 378 | export default function ChatView({ threadId }: ChatViewProps) { |
| 376 | 379 | const { state, dispatch } = useStore(); |
| 380 | const navigate = useNavigate(); | |
| 381 | const rawSearch = useSearch({ strict: false }); | |
| 377 | 382 | const api = useNativeApi(); |
| 378 | 383 | const { resolvedTheme } = useTheme(); |
| 384 | const { settings: appSettings } = useAppSettings(); | |
| 379 | 385 | const queryClient = useQueryClient(); |
| 380 | 386 | const createWorktreeMutation = useMutation( |
| 381 | 387 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| ... | ... | @@ -411,6 +417,11 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 411 | 417 | const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>()); |
| 412 | 418 | |
| 413 | 419 | const activeThread = state.threads.find((t) => t.id === threadId); |
| 420 | const diffSearch = useMemo( | |
| 421 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 422 | [rawSearch], | |
| 423 | ); | |
| 424 | const diffOpen = diffSearch.diff === "1"; | |
| 414 | 425 | const activeThreadId = activeThread?.id ?? null; |
| 415 | 426 | const activeSessionId = activeThread?.session?.sessionId ?? null; |
| 416 | 427 | const activeThreadRuntimeId = |
| ... | ... | @@ -642,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 642 | 653 | const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; |
| 643 | 654 | const runtimeSandboxMode = |
| 644 | 655 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; |
| 656 | const codexBinaryPath = appSettings.codexBinaryPath.trim(); | |
| 657 | const codexHomePath = appSettings.codexHomePath.trim(); | |
| 645 | 658 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 646 | 659 | const composerTrigger = useMemo( |
| 647 | 660 | () => detectComposerTrigger(prompt, composerCursor), |
| ... | ... | @@ -1515,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1515 | 1528 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, |
| 1516 | 1529 | model: selectedModel || undefined, |
| 1517 | 1530 | resumeThreadId: priorCodexThreadId ?? undefined, |
| 1531 | ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), | |
| 1532 | ...(codexHomePath.length > 0 ? { codexHomePath } : {}), | |
| 1518 | 1533 | approvalPolicy: runtimeApprovalPolicy, |
| 1519 | 1534 | sandboxMode: runtimeSandboxMode, |
| 1520 | 1535 | }); |
| ... | ... | @@ -1555,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1555 | 1570 | activeProject, |
| 1556 | 1571 | activeThread, |
| 1557 | 1572 | api, |
| 1573 | codexBinaryPath, | |
| 1574 | codexHomePath, | |
| 1558 | 1575 | dispatch, |
| 1559 | 1576 | runtimeApprovalPolicy, |
| 1560 | 1577 | runtimeSandboxMode, |
| ... | ... | @@ -1954,32 +1971,29 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1954 | 1971 | setExpandedImage(image); |
| 1955 | 1972 | }, []); |
| 1956 | 1973 | const onToggleDiff = useCallback(() => { |
| 1957 | if (state.diffOpen) { | |
| 1958 | dispatch({ type: "CLOSE_DIFF" }); | |
| 1959 | return; | |
| 1960 | } | |
| 1961 | ||
| 1962 | if (!activeThread) { | |
| 1963 | dispatch({ type: "TOGGLE_DIFF" }); | |
| 1964 | return; | |
| 1965 | } | |
| 1966 | ||
| 1967 | dispatch({ | |
| 1968 | type: "OPEN_DIFF", | |
| 1969 | threadId: activeThread.id, | |
| 1974 | void navigate({ | |
| 1975 | to: "/$threadId", | |
| 1976 | params: { threadId }, | |
| 1977 | search: (previous) => { | |
| 1978 | const rest = stripDiffSearchParams(previous); | |
| 1979 | return diffOpen ? rest : { ...rest, diff: "1" }; | |
| 1980 | }, | |
| 1970 | 1981 | }); |
| 1971 | }, [activeThread, dispatch, state.diffOpen]); | |
| 1982 | }, [diffOpen, navigate, threadId]); | |
| 1972 | 1983 | const onOpenTurnDiff = useCallback( |
| 1973 | 1984 | (turnId: string, filePath?: string) => { |
| 1974 | if (!activeThread) return; | |
| 1975 | dispatch({ | |
| 1976 | type: "OPEN_DIFF", | |
| 1977 | threadId: activeThread.id, | |
| 1978 | turnId, | |
| 1979 | ...(filePath ? { filePath } : {}), | |
| 1985 | void navigate({ | |
| 1986 | to: "/$threadId", | |
| 1987 | params: { threadId }, | |
| 1988 | search: (previous) => { | |
| 1989 | const rest = stripDiffSearchParams(previous); | |
| 1990 | return filePath | |
| 1991 | ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } | |
| 1992 | : { ...rest, diff: "1", diffTurnId: turnId }; | |
| 1993 | }, | |
| 1980 | 1994 | }); |
| 1981 | 1995 | }, |
| 1982 | [activeThread, dispatch], | |
| 1996 | [navigate, threadId], | |
| 1983 | 1997 | ); |
| 1984 | 1998 | const onRevertUserMessage = useCallback( |
| 1985 | 1999 | (messageId: string) => { |
| ... | ... | @@ -2027,7 +2041,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 2027 | 2041 | keybindings={keybindings} |
| 2028 | 2042 | api={api} |
| 2029 | 2043 | gitCwd={gitCwd} |
| 2030 | diffOpen={state.diffOpen} | |
| 2044 | diffOpen={diffOpen} | |
| 2031 | 2045 | onRunProjectScript={(script) => { |
| 2032 | 2046 | void runProjectScript(script); |
| 2033 | 2047 | }} |
| ... | ... | @@ -2812,11 +2826,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2812 | 2826 | row.message.id, |
| 2813 | 2827 | ); |
| 2814 | 2828 | if (!turnSummary) return null; |
| 2815 | const checkpointFilesState = turnCheckpointFilesByTurnId.get(turnSummary.turnId); | |
| 2829 | const checkpointFilesState = turnCheckpointFilesByTurnId.get( | |
| 2830 | turnSummary.turnId, | |
| 2831 | ); | |
| 2816 | 2832 | const checkpointFiles = checkpointFilesState?.files ?? []; |
| 2817 | 2833 | const summaryStat = checkpointFiles.reduce( |
| 2818 | 2834 | (acc, file) => { |
| 2819 | if (typeof file.additions !== "number" || typeof file.deletions !== "number") { | |
| 2835 | if ( | |
| 2836 | typeof file.additions !== "number" || | |
| 2837 | typeof file.deletions !== "number" | |
| 2838 | ) { | |
| 2820 | 2839 | return acc; |
| 2821 | 2840 | } |
| 2822 | 2841 | return { |
| ... | ... | @@ -2827,7 +2846,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2827 | 2846 | { additions: 0, deletions: 0 }, |
| 2828 | 2847 | ); |
| 2829 | 2848 | const changedFileCountLabel = |
| 2830 | checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "..."; | |
| 2849 | checkpointFilesState?.status === "ready" | |
| 2850 | ? String(checkpointFiles.length) | |
| 2851 | : "..."; | |
| 2831 | 2852 | return ( |
| 2832 | 2853 | <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5"> |
| 2833 | 2854 | <div className="mb-1.5 flex items-center justify-between gap-2"> |
| ... | ... | @@ -2838,7 +2859,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2838 | 2859 | <span className="mx-1">•</span> |
| 2839 | 2860 | <span className="text-success">+{summaryStat.additions}</span> |
| 2840 | 2861 | <span className="mx-0.5 text-muted-foreground/70">/</span> |
| 2841 | <span className="text-destructive">-{summaryStat.deletions}</span> | |
| 2862 | <span className="text-destructive"> | |
| 2863 | -{summaryStat.deletions} | |
| 2864 | </span> | |
| 2842 | 2865 | </> |
| 2843 | 2866 | )} |
| 2844 | 2867 | </p> |
| ... | ... | @@ -2846,12 +2869,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ |
| 2846 | 2869 | type="button" |
| 2847 | 2870 | size="xs" |
| 2848 | 2871 | variant="outline" |
| 2849 | onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} | |
| 2872 | onClick={() => | |
| 2873 | onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) | |
| 2874 | } | |
| 2850 | 2875 | > |
| 2851 | 2876 | View diff |
| 2852 | 2877 | </Button> |
| 2853 | 2878 | </div> |
| 2854 | {!checkpointFilesState || checkpointFilesState.status === "pending" ? ( | |
| 2879 | {!checkpointFilesState || | |
| 2880 | checkpointFilesState.status === "pending" ? ( | |
| 2855 | 2881 | <p className="text-[11px] text-muted-foreground/70"> |
| 2856 | 2882 | Waiting for checkpoint metadata... |
| 2857 | 2883 | </p> |
apps/web/src/components/DiffPanel.tsx+28-50| ... | ... | @@ -1,15 +1,9 @@ |
| 1 | 1 | import { parsePatchFiles } from "@pierre/diffs"; |
| 2 | import { | |
| 3 | FileDiff, | |
| 4 | type FileDiffMetadata, | |
| 5 | Virtualizer, | |
| 6 | WorkerPoolContextProvider, | |
| 7 | } from "@pierre/diffs/react"; | |
| 2 | import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; | |
| 8 | 3 | import { useQuery } from "@tanstack/react-query"; |
| 9 | import { useParams } from "@tanstack/react-router"; | |
| 4 | import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; | |
| 10 | 5 | import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; |
| 11 | 6 | import { |
| 12 | type ReactNode, | |
| 13 | 7 | type WheelEvent as ReactWheelEvent, |
| 14 | 8 | useCallback, |
| 15 | 9 | useEffect, |
| ... | ... | @@ -19,6 +13,7 @@ import { |
| 19 | 13 | } from "react"; |
| 20 | 14 | import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; |
| 21 | 15 | import { cn } from "~/lib/utils"; |
| 16 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 22 | 17 | import { isElectron } from "../env"; |
| 23 | 18 | import { useNativeApi } from "../hooks/useNativeApi"; |
| 24 | 19 | import { useTheme } from "../hooks/useTheme"; |
| ... | ... | @@ -90,41 +85,26 @@ interface DiffPanelProps { |
| 90 | 85 | mode?: "inline" | "sheet"; |
| 91 | 86 | } |
| 92 | 87 | |
| 93 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { | |
| 94 | const workerPoolSize = useMemo(() => { | |
| 95 | const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | |
| 96 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); | |
| 97 | }, []); | |
| 98 | ||
| 99 | return ( | |
| 100 | <WorkerPoolContextProvider | |
| 101 | poolOptions={{ | |
| 102 | workerFactory: () => | |
| 103 | new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), | |
| 104 | poolSize: workerPoolSize, | |
| 105 | totalASTLRUCacheSize: 240, | |
| 106 | }} | |
| 107 | highlighterOptions={{ | |
| 108 | tokenizeMaxLineLength: 1_000, | |
| 109 | }} | |
| 110 | > | |
| 111 | {children} | |
| 112 | </WorkerPoolContextProvider> | |
| 113 | ); | |
| 114 | } | |
| 88 | export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; | |
| 115 | 89 | |
| 116 | 90 | export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 117 | 91 | const api = useNativeApi(); |
| 92 | const navigate = useNavigate(); | |
| 118 | 93 | const { resolvedTheme } = useTheme(); |
| 119 | const { state, dispatch } = useStore(); | |
| 94 | const { state } = useStore(); | |
| 120 | 95 | const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked"); |
| 121 | 96 | const patchViewportRef = useRef<HTMLDivElement>(null); |
| 122 | 97 | const turnStripRef = useRef<HTMLDivElement>(null); |
| 123 | 98 | const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); |
| 124 | 99 | const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); |
| 125 | 100 | const params = useParams({ strict: false }); |
| 101 | const rawSearch = useSearch({ strict: false }); | |
| 126 | 102 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| 127 | const activeThreadId = state.diffThreadId ?? routeThreadId; | |
| 103 | const diffSearch = useMemo( | |
| 104 | () => parseDiffRouteSearch(rawSearch as Record<string, unknown>), | |
| 105 | [rawSearch], | |
| 106 | ); | |
| 107 | const activeThreadId = routeThreadId; | |
| 128 | 108 | const activeThread = state.threads.find((thread) => thread.id === activeThreadId); |
| 129 | 109 | const activeThreadRuntimeId = |
| 130 | 110 | activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; |
| ... | ... | @@ -132,10 +112,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 132 | 112 | const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = |
| 133 | 113 | useTurnDiffSummaries(activeThread); |
| 134 | 114 | |
| 135 | const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); | |
| 136 | const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; | |
| 137 | const selectedFilePath = | |
| 138 | canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null; | |
| 115 | const selectedTurnId = diffSearch.diffTurnId ?? null; | |
| 116 | const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; | |
| 139 | 117 | const selectedTurn = |
| 140 | 118 | selectedTurnId === null |
| 141 | 119 | ? undefined |
| ... | ... | @@ -235,17 +213,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 235 | 213 | |
| 236 | 214 | const selectTurn = (turnId: string) => { |
| 237 | 215 | if (!activeThread) return; |
| 238 | dispatch({ | |
| 239 | type: "SET_DIFF_TARGET", | |
| 240 | threadId: activeThread.id, | |
| 241 | turnId, | |
| 216 | void navigate({ | |
| 217 | to: "/$threadId", | |
| 218 | params: { threadId: activeThread.id }, | |
| 219 | search: (previous) => { | |
| 220 | const rest = stripDiffSearchParams(previous); | |
| 221 | return { ...rest, diff: "1", diffTurnId: turnId }; | |
| 222 | }, | |
| 242 | 223 | }); |
| 243 | 224 | }; |
| 244 | 225 | const selectWholeConversation = () => { |
| 245 | 226 | if (!activeThread) return; |
| 246 | dispatch({ | |
| 247 | type: "SET_DIFF_TARGET", | |
| 248 | threadId: activeThread.id, | |
| 227 | void navigate({ | |
| 228 | to: "/$threadId", | |
| 229 | params: { threadId: activeThread.id }, | |
| 230 | search: (previous) => { | |
| 231 | const rest = stripDiffSearchParams(previous); | |
| 232 | return { ...rest, diff: "1" }; | |
| 233 | }, | |
| 249 | 234 | }); |
| 250 | 235 | }; |
| 251 | 236 | const updateTurnStripScrollState = useCallback(() => { |
| ... | ... | @@ -440,13 +425,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { |
| 440 | 425 | ) : ( |
| 441 | 426 | <> |
| 442 | 427 | <div ref={patchViewportRef} className="min-h-0 min-w-0 flex-1 overflow-hidden"> |
| 443 | {!canApplyStoredTarget && state.diffThreadId && ( | |
| 444 | <div className="px-3 pt-2"> | |
| 445 | <p className="mb-2 text-[11px] text-muted-foreground/65"> | |
| 446 | Showing diffs for the active thread. | |
| 447 | </p> | |
| 448 | </div> | |
| 449 | )} | |
| 450 | 428 | {checkpointDiffError && !renderablePatch && ( |
| 451 | 429 | <div className="px-3"> |
| 452 | 430 | <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p> |
apps/web/src/components/DiffWorkerPoolProvider.tsx created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import { WorkerPoolContextProvider } from "@pierre/diffs/react"; | |
| 2 | import { useMemo, type ReactNode } from "react"; | |
| 3 | ||
| 4 | export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { | |
| 5 | const workerPoolSize = useMemo(() => { | |
| 6 | const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); | |
| 7 | return Math.max(2, Math.min(6, Math.floor(cores / 2))); | |
| 8 | }, []); | |
| 9 | ||
| 10 | return ( | |
| 11 | <WorkerPoolContextProvider | |
| 12 | poolOptions={{ | |
| 13 | workerFactory: () => | |
| 14 | new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), | |
| 15 | poolSize: workerPoolSize, | |
| 16 | totalASTLRUCacheSize: 240, | |
| 17 | }} | |
| 18 | highlighterOptions={{ | |
| 19 | tokenizeMaxLineLength: 1_000, | |
| 20 | }} | |
| 21 | > | |
| 22 | {children} | |
| 23 | </WorkerPoolContextProvider> | |
| 24 | ); | |
| 25 | } | |
| 26 |
apps/web/src/components/Sidebar.tsx+33-23| ... | ... | @@ -1,10 +1,11 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | |
| 1 | import { TerminalIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 4 | 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 5 | 5 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 6 | import { useAppSettings } from "../appSettings"; | |
| 6 | 7 | import { isElectron } from "../env"; |
| 7 | import { useTheme } from "../hooks/useTheme"; | |
| 8 | import { APP_STAGE_LABEL } from "../branding"; | |
| 8 | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 9 | 10 | import { derivePendingApprovals } from "../session-logic"; |
| 10 | 11 | import { useStore } from "../store"; |
| ... | ... | @@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast"; |
| 17 | 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 18 | 19 | import { createThread } from "../threadFactory"; |
| 19 | 20 | |
| 20 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | |
| 21 | 21 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 22 | 22 | |
| 23 | 23 | function formatRelativeTime(iso: string): string { |
| ... | ... | @@ -116,6 +116,7 @@ export default function Sidebar() { |
| 116 | 116 | const { state, dispatch } = useStore(); |
| 117 | 117 | const api = useNativeApi(); |
| 118 | 118 | const navigate = useNavigate(); |
| 119 | const { settings: appSettings } = useAppSettings(); | |
| 119 | 120 | const params = useParams({ strict: false }); |
| 120 | 121 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| 121 | 122 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| ... | ... | @@ -126,7 +127,6 @@ export default function Sidebar() { |
| 126 | 127 | const removeWorktreeMutation = useMutation( |
| 127 | 128 | gitRemoveWorktreeMutationOptions({ api, queryClient }), |
| 128 | 129 | ); |
| 129 | const { theme, setTheme } = useTheme(); | |
| 130 | 130 | const [addingProject, setAddingProject] = useState(false); |
| 131 | 131 | const [newCwd, setNewCwd] = useState(""); |
| 132 | 132 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| ... | ... | @@ -271,6 +271,17 @@ export default function Sidebar() { |
| 271 | 271 | |
| 272 | 272 | const thread = state.threads.find((t) => t.id === threadId); |
| 273 | 273 | if (!thread) return; |
| 274 | if (appSettings.confirmThreadDelete) { | |
| 275 | const confirmed = await api.dialogs.confirm( | |
| 276 | [ | |
| 277 | `Delete thread \"${thread.title}\"?`, | |
| 278 | "This permanently clears conversation history for this thread.", | |
| 279 | ].join("\n"), | |
| 280 | ); | |
| 281 | if (!confirmed) { | |
| 282 | return; | |
| 283 | } | |
| 284 | } | |
| 274 | 285 | const threadProject = state.projects.find((project) => project.id === thread.projectId); |
| 275 | 286 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); |
| 276 | 287 | const displayWorktreePath = orphanedWorktreePath |
| ... | ... | @@ -348,7 +359,16 @@ export default function Sidebar() { |
| 348 | 359 | }); |
| 349 | 360 | } |
| 350 | 361 | }, |
| 351 | [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads], | |
| 362 | [ | |
| 363 | api, | |
| 364 | appSettings.confirmThreadDelete, | |
| 365 | dispatch, | |
| 366 | navigate, | |
| 367 | removeWorktreeMutation, | |
| 368 | routeThreadId, | |
| 369 | state.projects, | |
| 370 | state.threads, | |
| 371 | ], | |
| 352 | 372 | ); |
| 353 | 373 | |
| 354 | 374 | const handleProjectContextMenu = useCallback( |
| ... | ... | @@ -431,24 +451,14 @@ export default function Sidebar() { |
| 431 | 451 | <div |
| 432 | 452 | className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`} |
| 433 | 453 | > |
| 434 | <span className="flex-1 text-sm font-semibold tracking-tight text-foreground"> | |
| 435 | T3 <span className="font-normal text-muted-foreground">Code</span> | |
| 436 | </span> | |
| 437 | <button | |
| 438 | type="button" | |
| 439 | className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground" | |
| 440 | onClick={() => setTheme(THEME_CYCLE[theme])} | |
| 441 | aria-label={`Theme: ${theme}`} | |
| 442 | title={`Theme: ${theme}`} | |
| 443 | > | |
| 444 | {theme === "system" ? ( | |
| 445 | <MonitorIcon className="size-3.5" /> | |
| 446 | ) : theme === "light" ? ( | |
| 447 | <SunIcon className="size-3.5" /> | |
| 448 | ) : ( | |
| 449 | <MoonIcon className="size-3.5" /> | |
| 450 | )} | |
| 451 | </button> | |
| 454 | <div className="flex min-w-0 flex-1 items-center gap-2"> | |
| 455 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> | |
| 456 | T3 <span className="font-normal text-muted-foreground">Code</span> | |
| 457 | </span> | |
| 458 | <span className="rounded-full bg-muted/50 px-1.5 py-0.5 text-[9px] font-medium uppercase tracking-widest text-muted-foreground/60"> | |
| 459 | {APP_STAGE_LABEL} | |
| 460 | </span> | |
| 461 | </div> | |
| 452 | 462 | </div> |
| 453 | 463 | |
| 454 | 464 | {/* New thread (global) */} |
apps/web/src/diffRouteSearch.test.ts created+53| ... | ... | @@ -0,0 +1,53 @@ |
| 1 | import { describe, expect, it } from "vitest"; | |
| 2 | ||
| 3 | import { parseDiffRouteSearch } from "./diffRouteSearch"; | |
| 4 | ||
| 5 | describe("parseDiffRouteSearch", () => { | |
| 6 | it("parses valid diff search values", () => { | |
| 7 | const parsed = parseDiffRouteSearch({ | |
| 8 | diff: "1", | |
| 9 | diffTurnId: "turn-1", | |
| 10 | diffFilePath: "src/app.ts", | |
| 11 | }); | |
| 12 | ||
| 13 | expect(parsed).toEqual({ | |
| 14 | diff: "1", | |
| 15 | diffTurnId: "turn-1", | |
| 16 | diffFilePath: "src/app.ts", | |
| 17 | }); | |
| 18 | }); | |
| 19 | ||
| 20 | it("drops turn and file values when diff is closed", () => { | |
| 21 | const parsed = parseDiffRouteSearch({ | |
| 22 | diff: "0", | |
| 23 | diffTurnId: "turn-1", | |
| 24 | diffFilePath: "src/app.ts", | |
| 25 | }); | |
| 26 | ||
| 27 | expect(parsed).toEqual({}); | |
| 28 | }); | |
| 29 | ||
| 30 | it("drops file value when turn is not selected", () => { | |
| 31 | const parsed = parseDiffRouteSearch({ | |
| 32 | diff: "1", | |
| 33 | diffFilePath: "src/app.ts", | |
| 34 | }); | |
| 35 | ||
| 36 | expect(parsed).toEqual({ | |
| 37 | diff: "1", | |
| 38 | }); | |
| 39 | }); | |
| 40 | ||
| 41 | it("normalizes whitespace-only values", () => { | |
| 42 | const parsed = parseDiffRouteSearch({ | |
| 43 | diff: "1", | |
| 44 | diffTurnId: " ", | |
| 45 | diffFilePath: " ", | |
| 46 | }); | |
| 47 | ||
| 48 | expect(parsed).toEqual({ | |
| 49 | diff: "1", | |
| 50 | }); | |
| 51 | }); | |
| 52 | }); | |
| 53 |
apps/web/src/diffRouteSearch.ts created+32| ... | ... | @@ -0,0 +1,32 @@ |
| 1 | export interface DiffRouteSearch { | |
| 2 | diff?: "1"; | |
| 3 | diffTurnId?: string; | |
| 4 | diffFilePath?: string; | |
| 5 | } | |
| 6 | ||
| 7 | function normalizeSearchString(value: unknown): string | undefined { | |
| 8 | if (typeof value !== "string") { | |
| 9 | return undefined; | |
| 10 | } | |
| 11 | const normalized = value.trim(); | |
| 12 | return normalized.length > 0 ? normalized : undefined; | |
| 13 | } | |
| 14 | ||
| 15 | export function stripDiffSearchParams<T extends Record<string, unknown>>( | |
| 16 | params: T, | |
| 17 | ): Omit<T, "diff" | "diffTurnId" | "diffFilePath"> { | |
| 18 | const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; | |
| 19 | return rest as Omit<T, "diff" | "diffTurnId" | "diffFilePath">; | |
| 20 | } | |
| 21 | ||
| 22 | export function parseDiffRouteSearch(search: Record<string, unknown>): DiffRouteSearch { | |
| 23 | const diff = search.diff === "1" ? "1" : undefined; | |
| 24 | const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; | |
| 25 | const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; | |
| 26 | ||
| 27 | return { | |
| 28 | ...(diff ? { diff } : {}), | |
| 29 | ...(diffTurnId ? { diffTurnId } : {}), | |
| 30 | ...(diffFilePath ? { diffFilePath } : {}), | |
| 31 | }; | |
| 32 | } |
apps/web/src/main.tsx+2| ... | ... | @@ -9,12 +9,14 @@ import "@xterm/xterm/css/xterm.css"; |
| 9 | 9 | import "highlight.js/styles/github-dark.css"; |
| 10 | 10 | import "./index.css"; |
| 11 | 11 | |
| 12 | import { APP_DISPLAY_NAME } from "./branding"; | |
| 12 | 13 | import { isElectron } from "./env"; |
| 13 | 14 | import { routeTree } from "./routeTree.gen"; |
| 14 | 15 | |
| 15 | 16 | const history = isElectron ? createHashHistory() : createBrowserHistory(); |
| 16 | 17 | |
| 17 | 18 | const queryClient = new QueryClient(); |
| 19 | document.title = APP_DISPLAY_NAME; | |
| 18 | 20 | |
| 19 | 21 | const router = createRouter({ |
| 20 | 22 | routeTree, |
apps/web/src/routeTree.gen.ts+21-3| ... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as ChatRouteImport } from './routes/_chat' |
| 13 | 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 14 | import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' | |
| 14 | 15 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' |
| 15 | 16 | |
| 16 | 17 | const ChatRoute = ChatRouteImport.update({ |
| ... | ... | @@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ |
| 22 | 23 | path: '/', |
| 23 | 24 | getParentRoute: () => ChatRoute, |
| 24 | 25 | } as any) |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | |
| 27 | id: '/settings', | |
| 28 | path: '/settings', | |
| 29 | getParentRoute: () => ChatRoute, | |
| 30 | } as any) | |
| 25 | 31 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 26 | 32 | id: '/$threadId', |
| 27 | 33 | path: '/$threadId', |
| ... | ... | @@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 31 | 37 | export interface FileRoutesByFullPath { |
| 32 | 38 | '/': typeof ChatIndexRoute |
| 33 | 39 | '/$threadId': typeof ChatThreadIdRoute |
| 40 | '/settings': typeof ChatSettingsRoute | |
| 34 | 41 | } |
| 35 | 42 | export interface FileRoutesByTo { |
| 36 | 43 | '/$threadId': typeof ChatThreadIdRoute |
| 44 | '/settings': typeof ChatSettingsRoute | |
| 37 | 45 | '/': typeof ChatIndexRoute |
| 38 | 46 | } |
| 39 | 47 | export interface FileRoutesById { |
| 40 | 48 | __root__: typeof rootRouteImport |
| 41 | 49 | '/_chat': typeof ChatRouteWithChildren |
| 42 | 50 | '/_chat/$threadId': typeof ChatThreadIdRoute |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | |
| 43 | 52 | '/_chat/': typeof ChatIndexRoute |
| 44 | 53 | } |
| 45 | 54 | export interface FileRouteTypes { |
| 46 | 55 | fileRoutesByFullPath: FileRoutesByFullPath |
| 47 | fullPaths: '/' | '/$threadId' | |
| 56 | fullPaths: '/' | '/$threadId' | '/settings' | |
| 48 | 57 | fileRoutesByTo: FileRoutesByTo |
| 49 | to: '/$threadId' | '/' | |
| 50 | id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/' | |
| 58 | to: '/$threadId' | '/settings' | '/' | |
| 59 | id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' | |
| 51 | 60 | fileRoutesById: FileRoutesById |
| 52 | 61 | } |
| 53 | 62 | export interface RootRouteChildren { |
| ... | ... | @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { |
| 70 | 79 | preLoaderRoute: typeof ChatIndexRouteImport |
| 71 | 80 | parentRoute: typeof ChatRoute |
| 72 | 81 | } |
| 82 | '/_chat/settings': { | |
| 83 | id: '/_chat/settings' | |
| 84 | path: '/settings' | |
| 85 | fullPath: '/settings' | |
| 86 | preLoaderRoute: typeof ChatSettingsRouteImport | |
| 87 | parentRoute: typeof ChatRoute | |
| 88 | } | |
| 73 | 89 | '/_chat/$threadId': { |
| 74 | 90 | id: '/_chat/$threadId' |
| 75 | 91 | path: '/$threadId' |
| ... | ... | @@ -82,11 +98,13 @@ declare module '@tanstack/react-router' { |
| 82 | 98 | |
| 83 | 99 | interface ChatRouteChildren { |
| 84 | 100 | ChatThreadIdRoute: typeof ChatThreadIdRoute |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | |
| 85 | 102 | ChatIndexRoute: typeof ChatIndexRoute |
| 86 | 103 | } |
| 87 | 104 | |
| 88 | 105 | const ChatRouteChildren: ChatRouteChildren = { |
| 89 | 106 | ChatThreadIdRoute: ChatThreadIdRoute, |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | |
| 90 | 108 | ChatIndexRoute: ChatIndexRoute, |
| 91 | 109 | } |
| 92 | 110 |
apps/web/src/routes/__root.tsx+4-1| ... | ... | @@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery"; |
| 9 | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | 10 | import { useStore } from "../store"; |
| 11 | 11 | import { onServerWelcome } from "../wsNativeApi"; |
| 12 | import { APP_DISPLAY_NAME } from "../branding"; | |
| 12 | 13 | |
| 13 | 14 | export const Route = createRootRouteWithContext<{ |
| 14 | 15 | queryClient: QueryClient; |
| ... | ... | @@ -23,7 +24,9 @@ function RootRouteView() { |
| 23 | 24 | return ( |
| 24 | 25 | <div className="flex h-screen flex-col bg-background text-foreground"> |
| 25 | 26 | <div className="flex flex-1 items-center justify-center"> |
| 26 | <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p> | |
| 27 | <p className="text-sm text-muted-foreground"> | |
| 28 | Connecting to {APP_DISPLAY_NAME} server... | |
| 29 | </p> | |
| 27 | 30 | </div> |
| 28 | 31 | </div> |
| 29 | 32 | ); |
apps/web/src/routes/_chat.$threadId.tsx+78-2| ... | ... | @@ -1,14 +1,67 @@ |
| 1 | 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { useEffect } from "react"; | |
| 2 | import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import ChatView from "../components/ChatView"; |
| 5 | import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; | |
| 6 | import { useMediaQuery } from "../hooks/useMediaQuery"; | |
| 5 | 7 | import { useStore } from "../store"; |
| 8 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | |
| 9 | ||
| 10 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | |
| 11 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 12 | ||
| 13 | const DiffPanelWrapper = (props: { | |
| 14 | children: ReactNode; | |
| 15 | sheet: boolean; | |
| 16 | diffOpen: boolean; | |
| 17 | onCloseDiff: () => void; | |
| 18 | }) => { | |
| 19 | if (props.sheet) { | |
| 20 | return ( | |
| 21 | <Sheet | |
| 22 | open={props.diffOpen} | |
| 23 | onOpenChange={(open) => { | |
| 24 | if (!open) { | |
| 25 | props.onCloseDiff(); | |
| 26 | } | |
| 27 | }} | |
| 28 | > | |
| 29 | <SheetPopup | |
| 30 | side="right" | |
| 31 | showCloseButton={false} | |
| 32 | keepMounted | |
| 33 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 34 | > | |
| 35 | {props.children} | |
| 36 | </SheetPopup> | |
| 37 | </Sheet> | |
| 38 | ); | |
| 39 | } | |
| 40 | ||
| 41 | return ( | |
| 42 | <aside className={props.diffOpen ? undefined : "hidden"} aria-hidden={!props.diffOpen}> | |
| 43 | {props.children} | |
| 44 | </aside> | |
| 45 | ); | |
| 46 | }; | |
| 6 | 47 | |
| 7 | 48 | function ChatThreadRouteView() { |
| 8 | 49 | const { state } = useStore(); |
| 9 | 50 | const navigate = useNavigate(); |
| 10 | 51 | const { threadId } = Route.useParams(); |
| 52 | const search = Route.useSearch(); | |
| 11 | 53 | const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; |
| 54 | const diffOpen = search.diff === "1"; | |
| 55 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 56 | const closeDiff = useCallback(() => { | |
| 57 | void navigate({ | |
| 58 | to: "/$threadId", | |
| 59 | params: { threadId }, | |
| 60 | search: (previous) => { | |
| 61 | return stripDiffSearchParams(previous); | |
| 62 | }, | |
| 63 | }); | |
| 64 | }, [navigate, threadId]); | |
| 12 | 65 | |
| 13 | 66 | useEffect(() => { |
| 14 | 67 | if (!threadId) { |
| ... | ... | @@ -30,9 +83,32 @@ function ChatThreadRouteView() { |
| 30 | 83 | return null; |
| 31 | 84 | } |
| 32 | 85 | |
| 33 | return <ChatView threadId={threadId} />; | |
| 86 | const diffLoadingFallback = | |
| 87 | !diffOpen || shouldUseDiffSheet ? ( | |
| 88 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | |
| 89 | Loading diff viewer... | |
| 90 | </div> | |
| 91 | ) : ( | |
| 92 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | |
| 93 | Loading diff viewer... | |
| 94 | </aside> | |
| 95 | ); | |
| 96 | ||
| 97 | return ( | |
| 98 | <> | |
| 99 | <ChatView threadId={threadId} /> | |
| 100 | <Activity mode={diffOpen ? "visible" : "hidden"}> | |
| 101 | <DiffPanelWrapper sheet={shouldUseDiffSheet} diffOpen={diffOpen} onCloseDiff={closeDiff}> | |
| 102 | <Suspense fallback={diffLoadingFallback}> | |
| 103 | <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} /> | |
| 104 | </Suspense> | |
| 105 | </DiffPanelWrapper> | |
| 106 | </Activity> | |
| 107 | </> | |
| 108 | ); | |
| 34 | 109 | } |
| 35 | 110 | |
| 36 | 111 | export const Route = createFileRoute("/_chat/$threadId")({ |
| 112 | validateSearch: (search) => parseDiffRouteSearch(search), | |
| 37 | 113 | component: ChatThreadRouteView, |
| 38 | 114 | }); |
apps/web/src/routes/_chat.settings.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { useAppSettings } from "../appSettings"; | |
| 4 | import { isElectron } from "../env"; | |
| 5 | import { useTheme } from "../hooks/useTheme"; | |
| 6 | import { useStore } from "../store"; | |
| 7 | import { Button } from "../components/ui/button"; | |
| 8 | import { Input } from "../components/ui/input"; | |
| 9 | import { Switch } from "../components/ui/switch"; | |
| 10 | ||
| 11 | const THEME_OPTIONS = [ | |
| 12 | { | |
| 13 | value: "system", | |
| 14 | label: "System", | |
| 15 | description: "Match your OS appearance setting.", | |
| 16 | }, | |
| 17 | { | |
| 18 | value: "light", | |
| 19 | label: "Light", | |
| 20 | description: "Always use the light theme.", | |
| 21 | }, | |
| 22 | { | |
| 23 | value: "dark", | |
| 24 | label: "Dark", | |
| 25 | description: "Always use the dark theme.", | |
| 26 | }, | |
| 27 | ] as const; | |
| 28 | ||
| 29 | const RUNTIME_MODE_OPTIONS = [ | |
| 30 | { | |
| 31 | value: "full-access", | |
| 32 | label: "Full access", | |
| 33 | description: "Allow commands to run without confirmation prompts.", | |
| 34 | }, | |
| 35 | { | |
| 36 | value: "approval-required", | |
| 37 | label: "Supervised", | |
| 38 | description: "Require approval prompts before command execution.", | |
| 39 | }, | |
| 40 | ] as const; | |
| 41 | ||
| 42 | function SettingsRouteView() { | |
| 43 | const { theme, setTheme, resolvedTheme } = useTheme(); | |
| 44 | const { state, dispatch } = useStore(); | |
| 45 | const { settings, defaults, updateSettings } = useAppSettings(); | |
| 46 | ||
| 47 | const codexBinaryPath = settings.codexBinaryPath; | |
| 48 | const codexHomePath = settings.codexHomePath; | |
| 49 | ||
| 50 | return ( | |
| 51 | <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> | |
| 52 | {isElectron && ( | |
| 53 | <div className="drag-region flex h-[52px] shrink-0 items-center border-b border-border px-5"> | |
| 54 | <span className="text-xs font-medium tracking-wide text-muted-foreground/70">Settings</span> | |
| 55 | </div> | |
| 56 | )} | |
| 57 | ||
| 58 | <div className="flex-1 overflow-y-auto p-6"> | |
| 59 | <div className="mx-auto flex w-full max-w-3xl flex-col gap-6"> | |
| 60 | <header className="space-y-1"> | |
| 61 | <h1 className="text-2xl font-semibold tracking-tight text-foreground">Settings</h1> | |
| 62 | <p className="text-sm text-muted-foreground"> | |
| 63 | Configure app-level preferences for this device. | |
| 64 | </p> | |
| 65 | </header> | |
| 66 | ||
| 67 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 68 | <div className="mb-4"> | |
| 69 | <h2 className="text-sm font-medium text-foreground">Appearance</h2> | |
| 70 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 71 | Choose how T3 Code handles light and dark mode. | |
| 72 | </p> | |
| 73 | </div> | |
| 74 | ||
| 75 | <div className="space-y-2" role="radiogroup" aria-label="Theme preference"> | |
| 76 | {THEME_OPTIONS.map((option) => { | |
| 77 | const selected = theme === option.value; | |
| 78 | return ( | |
| 79 | <button | |
| 80 | key={option.value} | |
| 81 | type="button" | |
| 82 | role="radio" | |
| 83 | aria-checked={selected} | |
| 84 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 85 | selected | |
| 86 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 87 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 88 | }`} | |
| 89 | onClick={() => setTheme(option.value)} | |
| 90 | > | |
| 91 | <span className="flex flex-col"> | |
| 92 | <span className="text-sm font-medium">{option.label}</span> | |
| 93 | <span className="text-xs">{option.description}</span> | |
| 94 | </span> | |
| 95 | {selected ? ( | |
| 96 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 97 | Selected | |
| 98 | </span> | |
| 99 | ) : null} | |
| 100 | </button> | |
| 101 | ); | |
| 102 | })} | |
| 103 | </div> | |
| 104 | ||
| 105 | <p className="mt-4 text-xs text-muted-foreground"> | |
| 106 | Active theme: <span className="font-medium text-foreground">{resolvedTheme}</span> | |
| 107 | </p> | |
| 108 | </section> | |
| 109 | ||
| 110 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 111 | <div className="mb-4"> | |
| 112 | <h2 className="text-sm font-medium text-foreground">Codex App Server</h2> | |
| 113 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 114 | These overrides apply to new sessions and let you use a non-default Codex install. | |
| 115 | </p> | |
| 116 | </div> | |
| 117 | ||
| 118 | <div className="space-y-4"> | |
| 119 | <label className="block space-y-1"> | |
| 120 | <span className="text-xs font-medium text-foreground">Codex binary path</span> | |
| 121 | <Input | |
| 122 | value={codexBinaryPath} | |
| 123 | onChange={(event) => updateSettings({ codexBinaryPath: event.target.value })} | |
| 124 | placeholder="codex" | |
| 125 | spellCheck={false} | |
| 126 | /> | |
| 127 | <span className="text-xs text-muted-foreground"> | |
| 128 | Leave blank to use <code>codex</code> from your PATH. | |
| 129 | </span> | |
| 130 | </label> | |
| 131 | ||
| 132 | <label className="block space-y-1"> | |
| 133 | <span className="text-xs font-medium text-foreground">CODEX_HOME path</span> | |
| 134 | <Input | |
| 135 | value={codexHomePath} | |
| 136 | onChange={(event) => updateSettings({ codexHomePath: event.target.value })} | |
| 137 | placeholder="/Users/you/.codex" | |
| 138 | spellCheck={false} | |
| 139 | /> | |
| 140 | <span className="text-xs text-muted-foreground"> | |
| 141 | Optional custom Codex home/config directory. | |
| 142 | </span> | |
| 143 | </label> | |
| 144 | ||
| 145 | <div className="flex items-center justify-between gap-2 text-xs text-muted-foreground"> | |
| 146 | <p> | |
| 147 | Binary source: <span className="font-medium text-foreground">{codexBinaryPath || "PATH"}</span> | |
| 148 | </p> | |
| 149 | <Button | |
| 150 | size="xs" | |
| 151 | variant="outline" | |
| 152 | onClick={() => | |
| 153 | updateSettings({ | |
| 154 | codexBinaryPath: defaults.codexBinaryPath, | |
| 155 | codexHomePath: defaults.codexHomePath, | |
| 156 | }) | |
| 157 | } | |
| 158 | > | |
| 159 | Reset codex overrides | |
| 160 | </Button> | |
| 161 | </div> | |
| 162 | </div> | |
| 163 | </section> | |
| 164 | ||
| 165 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 166 | <div className="mb-4"> | |
| 167 | <h2 className="text-sm font-medium text-foreground">Runtime Mode</h2> | |
| 168 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 169 | Select the default execution policy for this client. | |
| 170 | </p> | |
| 171 | </div> | |
| 172 | ||
| 173 | <div className="space-y-2" role="radiogroup" aria-label="Runtime mode preference"> | |
| 174 | {RUNTIME_MODE_OPTIONS.map((option) => { | |
| 175 | const selected = state.runtimeMode === option.value; | |
| 176 | return ( | |
| 177 | <button | |
| 178 | key={option.value} | |
| 179 | type="button" | |
| 180 | role="radio" | |
| 181 | aria-checked={selected} | |
| 182 | className={`flex w-full items-start justify-between rounded-lg border px-3 py-2 text-left transition-colors ${ | |
| 183 | selected | |
| 184 | ? "border-primary/60 bg-primary/8 text-foreground" | |
| 185 | : "border-border bg-background text-muted-foreground hover:bg-accent" | |
| 186 | }`} | |
| 187 | onClick={() => { | |
| 188 | dispatch({ | |
| 189 | type: "SET_RUNTIME_MODE", | |
| 190 | mode: option.value, | |
| 191 | }); | |
| 192 | }} | |
| 193 | > | |
| 194 | <span className="flex flex-col"> | |
| 195 | <span className="text-sm font-medium">{option.label}</span> | |
| 196 | <span className="text-xs">{option.description}</span> | |
| 197 | </span> | |
| 198 | {selected ? ( | |
| 199 | <span className="rounded bg-primary/14 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-primary"> | |
| 200 | Selected | |
| 201 | </span> | |
| 202 | ) : null} | |
| 203 | </button> | |
| 204 | ); | |
| 205 | })} | |
| 206 | </div> | |
| 207 | </section> | |
| 208 | ||
| 209 | <section className="rounded-2xl border border-border bg-card p-5"> | |
| 210 | <div className="mb-4"> | |
| 211 | <h2 className="text-sm font-medium text-foreground">Safety</h2> | |
| 212 | <p className="mt-1 text-xs text-muted-foreground"> | |
| 213 | Additional guardrails for destructive local actions. | |
| 214 | </p> | |
| 215 | </div> | |
| 216 | ||
| 217 | <div className="flex items-center justify-between rounded-lg border border-border bg-background px-3 py-2"> | |
| 218 | <div> | |
| 219 | <p className="text-sm font-medium text-foreground">Confirm thread deletion</p> | |
| 220 | <p className="text-xs text-muted-foreground"> | |
| 221 | Ask for confirmation before deleting a thread and its chat history. | |
| 222 | </p> | |
| 223 | </div> | |
| 224 | <Switch | |
| 225 | checked={settings.confirmThreadDelete} | |
| 226 | onCheckedChange={(checked) => | |
| 227 | updateSettings({ | |
| 228 | confirmThreadDelete: Boolean(checked), | |
| 229 | }) | |
| 230 | } | |
| 231 | aria-label="Confirm thread deletion" | |
| 232 | /> | |
| 233 | </div> | |
| 234 | ||
| 235 | {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( | |
| 236 | <div className="mt-3 flex justify-end"> | |
| 237 | <Button | |
| 238 | size="xs" | |
| 239 | variant="outline" | |
| 240 | onClick={() => | |
| 241 | updateSettings({ | |
| 242 | confirmThreadDelete: defaults.confirmThreadDelete, | |
| 243 | }) | |
| 244 | } | |
| 245 | > | |
| 246 | Restore default | |
| 247 | </Button> | |
| 248 | </div> | |
| 249 | ) : null} | |
| 250 | </section> | |
| 251 | </div> | |
| 252 | </div> | |
| 253 | </div> | |
| 254 | ); | |
| 255 | } | |
| 256 | ||
| 257 | export const Route = createFileRoute("/_chat/settings")({ | |
| 258 | component: SettingsRouteView, | |
| 259 | }); |
apps/web/src/routes/_chat.tsx+22-65| ... | ... | @@ -1,78 +1,34 @@ |
| 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | |
| 2 | import { Activity, Suspense, lazy, type ReactNode } from "react"; | |
| 1 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { useEffect } from "react"; | |
| 3 | 3 | |
| 4 | import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; | |
| 4 | 5 | import Sidebar from "../components/Sidebar"; |
| 5 | import { useMediaQuery } from "../hooks/useMediaQuery"; | |
| 6 | import { useStore } from "../store"; | |
| 7 | import { Sheet, SheetPopup } from "../components/ui/sheet"; | |
| 8 | 6 | |
| 9 | const DiffPanel = lazy(() => import("../components/DiffPanel")); | |
| 10 | const DiffWorkerPoolProvider = lazy(() => | |
| 11 | import("../components/DiffPanel").then((module) => ({ | |
| 12 | default: module.DiffWorkerPoolProvider, | |
| 13 | })), | |
| 14 | ); | |
| 15 | const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; | |
| 16 | ||
| 17 | const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => { | |
| 18 | const { state, dispatch } = useStore(); | |
| 19 | if (props.sheet) { | |
| 20 | return ( | |
| 21 | <Sheet | |
| 22 | open={state.diffOpen} | |
| 23 | onOpenChange={(open) => { | |
| 24 | if (!open) { | |
| 25 | dispatch({ type: "CLOSE_DIFF" }); | |
| 26 | } | |
| 27 | }} | |
| 28 | > | |
| 29 | <SheetPopup | |
| 30 | side="right" | |
| 31 | showCloseButton={false} | |
| 32 | keepMounted | |
| 33 | className="w-[min(88vw,820px)] max-w-[820px] p-0" | |
| 34 | > | |
| 35 | {props.children} | |
| 36 | </SheetPopup> | |
| 37 | </Sheet> | |
| 38 | ); | |
| 39 | } | |
| 7 | function ChatRouteLayout() { | |
| 8 | const navigate = useNavigate(); | |
| 40 | 9 | |
| 41 | return ( | |
| 42 | <aside className={state.diffOpen ? undefined : "hidden"} aria-hidden={!state.diffOpen}> | |
| 43 | {props.children} | |
| 44 | </aside> | |
| 45 | ); | |
| 46 | }; | |
| 10 | useEffect(() => { | |
| 11 | const onMenuAction = window.desktopBridge?.onMenuAction; | |
| 12 | if (typeof onMenuAction !== "function") { | |
| 13 | return; | |
| 14 | } | |
| 47 | 15 | |
| 48 | function ChatRouteLayout() { | |
| 49 | const { state } = useStore(); | |
| 50 | const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); | |
| 16 | const unsubscribe = onMenuAction((action) => { | |
| 17 | if (action !== "open-settings") return; | |
| 18 | void navigate({ to: "/settings" }); | |
| 19 | }); | |
| 51 | 20 | |
| 52 | const diffLoadingFallback = | |
| 53 | !state.diffOpen || shouldUseDiffSheet ? ( | |
| 54 | <div className="flex h-full min-h-0 items-center justify-center px-4 text-center text-xs text-muted-foreground/70"> | |
| 55 | Loading diff viewer... | |
| 56 | </div> | |
| 57 | ) : ( | |
| 58 | <aside className="flex h-full w-[560px] shrink-0 items-center justify-center border-l border-border bg-card px-4 text-center text-xs text-muted-foreground/70"> | |
| 59 | Loading diff viewer... | |
| 60 | </aside> | |
| 61 | ); | |
| 21 | return () => { | |
| 22 | unsubscribe?.(); | |
| 23 | }; | |
| 24 | }, [navigate]); | |
| 62 | 25 | |
| 63 | 26 | return ( |
| 64 | 27 | <div className="flex h-screen overflow-hidden bg-background text-foreground isolate"> |
| 65 | 28 | <Sidebar /> |
| 66 | <Outlet /> | |
| 67 | <Activity mode={state.diffOpen ? "visible" : "hidden"}> | |
| 68 | <DiffPanelWrapper sheet={shouldUseDiffSheet}> | |
| 69 | <Suspense fallback={diffLoadingFallback}> | |
| 70 | <DiffWorkerPoolProvider> | |
| 71 | <DiffPanel mode={shouldUseDiffSheet ? "sheet" : "inline"} /> | |
| 72 | </DiffWorkerPoolProvider> | |
| 73 | </Suspense> | |
| 74 | </DiffPanelWrapper> | |
| 75 | </Activity> | |
| 29 | <DiffWorkerPoolProvider> | |
| 30 | <Outlet /> | |
| 31 | </DiffWorkerPoolProvider> | |
| 76 | 32 | </div> |
| 77 | 33 | ); |
| 78 | 34 | } |
| ... | ... | @@ -80,3 +36,4 @@ function ChatRouteLayout() { |
| 80 | 36 | export const Route = createFileRoute("/_chat")({ |
| 81 | 37 | component: ChatRouteLayout, |
| 82 | 38 | }); |
| 39 |
apps/web/src/store.test.ts-39| ... | ... | @@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState { |
| 81 | 81 | threads: [thread], |
| 82 | 82 | threadsHydrated: true, |
| 83 | 83 | runtimeMode: "full-access", |
| 84 | diffOpen: false, | |
| 85 | diffThreadId: null, | |
| 86 | diffTurnId: null, | |
| 87 | diffFilePath: null, | |
| 88 | 84 | }; |
| 89 | 85 | } |
| 90 | 86 | |
| ... | ... | @@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => { |
| 149 | 145 | expect(next.threads[0]?.terminalOpen).toBe(true); |
| 150 | 146 | }); |
| 151 | 147 | |
| 152 | it("opens diff panel with an explicit turn/file target", () => { | |
| 153 | const state = makeState(makeThread()); | |
| 154 | const next = reducer(state, { | |
| 155 | type: "OPEN_DIFF", | |
| 156 | threadId: "thread-local-1", | |
| 157 | turnId: "turn-1", | |
| 158 | filePath: "src/app.ts", | |
| 159 | }); | |
| 160 | ||
| 161 | expect(next.diffOpen).toBe(true); | |
| 162 | expect(next.diffThreadId).toBe("thread-local-1"); | |
| 163 | expect(next.diffTurnId).toBe("turn-1"); | |
| 164 | expect(next.diffFilePath).toBe("src/app.ts"); | |
| 165 | }); | |
| 166 | ||
| 167 | 148 | it("sets terminal open state per thread", () => { |
| 168 | 149 | const state = makeState(makeThread({ terminalOpen: true })); |
| 169 | 150 | const next = reducer(state, { |
| ... | ... | @@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => { |
| 618 | 599 | ], |
| 619 | 600 | threadsHydrated: true, |
| 620 | 601 | runtimeMode: "full-access", |
| 621 | diffOpen: true, | |
| 622 | diffThreadId: "thread-b", | |
| 623 | diffTurnId: "turn-b", | |
| 624 | diffFilePath: "src/removed.ts", | |
| 625 | 602 | }; |
| 626 | 603 | |
| 627 | 604 | const next = reducer(state, { |
| ... | ... | @@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => { |
| 645 | 622 | expect(next.threads).toHaveLength(1); |
| 646 | 623 | expect(next.threads[0]?.id).toBe("thread-a"); |
| 647 | 624 | expect(next.threads[0]?.projectId).toBe("project-new-a"); |
| 648 | expect(next.diffOpen).toBe(false); | |
| 649 | expect(next.diffThreadId).toBeNull(); | |
| 650 | expect(next.diffTurnId).toBeNull(); | |
| 651 | expect(next.diffFilePath).toBeNull(); | |
| 652 | 625 | }); |
| 653 | 626 | |
| 654 | 627 | it("treats empty scripts from sync as authoritative", () => { |
| ... | ... | @@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => { |
| 674 | 647 | threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], |
| 675 | 648 | threadsHydrated: true, |
| 676 | 649 | runtimeMode: "full-access", |
| 677 | diffOpen: false, | |
| 678 | diffThreadId: null, | |
| 679 | diffTurnId: null, | |
| 680 | diffFilePath: null, | |
| 681 | 650 | }; |
| 682 | 651 | |
| 683 | 652 | const next = reducer(state, { |
| ... | ... | @@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => { |
| 766 | 735 | ], |
| 767 | 736 | threadsHydrated: true, |
| 768 | 737 | runtimeMode: "full-access", |
| 769 | diffOpen: true, | |
| 770 | diffThreadId: "thread-a", | |
| 771 | diffTurnId: "turn-a", | |
| 772 | diffFilePath: "src/a.ts", | |
| 773 | 738 | }; |
| 774 | 739 | |
| 775 | 740 | const next = reducer(state, { |
| ... | ... | @@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => { |
| 781 | 746 | expect(next.projects[0]?.id).toBe("project-2"); |
| 782 | 747 | expect(next.threads).toHaveLength(1); |
| 783 | 748 | expect(next.threads[0]?.id).toBe("thread-b"); |
| 784 | expect(next.diffOpen).toBe(false); | |
| 785 | expect(next.diffThreadId).toBeNull(); | |
| 786 | expect(next.diffTurnId).toBeNull(); | |
| 787 | expect(next.diffFilePath).toBeNull(); | |
| 788 | 749 | }); |
| 789 | 750 | |
| 790 | 751 | it("marks completion as seen immediately for the active thread", () => { |
apps/web/src/store.ts+2-85| ... | ... | @@ -48,20 +48,6 @@ type Action = |
| 48 | 48 | | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 49 | 49 | | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } |
| 50 | 50 | | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } |
| 51 | | { type: "TOGGLE_DIFF" } | |
| 52 | | { | |
| 53 | type: "OPEN_DIFF"; | |
| 54 | threadId: string; | |
| 55 | turnId?: string; | |
| 56 | filePath?: string; | |
| 57 | } | |
| 58 | | { | |
| 59 | type: "SET_DIFF_TARGET"; | |
| 60 | threadId: string; | |
| 61 | turnId?: string; | |
| 62 | filePath?: string; | |
| 63 | } | |
| 64 | | { type: "CLOSE_DIFF" } | |
| 65 | 51 | | { |
| 66 | 52 | type: "APPLY_EVENT"; |
| 67 | 53 | event: ProviderEvent; |
| ... | ... | @@ -110,10 +96,6 @@ export interface AppState { |
| 110 | 96 | threads: Thread[]; |
| 111 | 97 | threadsHydrated: boolean; |
| 112 | 98 | runtimeMode: RuntimeMode; |
| 113 | diffOpen: boolean; | |
| 114 | diffThreadId: string | null; | |
| 115 | diffTurnId: string | null; | |
| 116 | diffFilePath: string | null; | |
| 117 | 99 | } |
| 118 | 100 | |
| 119 | 101 | const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; |
| ... | ... | @@ -133,10 +115,6 @@ const initialState: AppState = { |
| 133 | 115 | threads: [], |
| 134 | 116 | threadsHydrated: false, |
| 135 | 117 | runtimeMode: DEFAULT_RUNTIME_MODE, |
| 136 | diffOpen: false, | |
| 137 | diffThreadId: null, | |
| 138 | diffTurnId: null, | |
| 139 | diffFilePath: null, | |
| 140 | 118 | }; |
| 141 | 119 | |
| 142 | 120 | // ── Helpers ────────────────────────────────────────────────────────── |
| ... | ... | @@ -160,10 +138,6 @@ function readPersistedState(): AppState { |
| 160 | 138 | ...hydrated, |
| 161 | 139 | threads, |
| 162 | 140 | threadsHydrated: threads.length > 0, |
| 163 | diffOpen: false, | |
| 164 | diffThreadId: null, | |
| 165 | diffTurnId: null, | |
| 166 | diffFilePath: null, | |
| 167 | 141 | }; |
| 168 | 142 | } catch { |
| 169 | 143 | return initialState; |
| ... | ... | @@ -191,32 +165,6 @@ function updateThread( |
| 191 | 165 | return threads.map((t) => (t.id === threadId ? updater(t) : t)); |
| 192 | 166 | } |
| 193 | 167 | |
| 194 | function resetDiffTargetIfMissing(state: AppState, threads: Thread[]) { | |
| 195 | if (!state.diffThreadId) { | |
| 196 | return { | |
| 197 | diffOpen: state.diffOpen, | |
| 198 | diffThreadId: state.diffThreadId, | |
| 199 | diffTurnId: state.diffTurnId, | |
| 200 | diffFilePath: state.diffFilePath, | |
| 201 | }; | |
| 202 | } | |
| 203 | const hasThread = threads.some((thread) => thread.id === state.diffThreadId); | |
| 204 | if (hasThread) { | |
| 205 | return { | |
| 206 | diffOpen: state.diffOpen, | |
| 207 | diffThreadId: state.diffThreadId, | |
| 208 | diffTurnId: state.diffTurnId, | |
| 209 | diffFilePath: state.diffFilePath, | |
| 210 | }; | |
| 211 | } | |
| 212 | return { | |
| 213 | diffOpen: false, | |
| 214 | diffThreadId: null, | |
| 215 | diffTurnId: null, | |
| 216 | diffFilePath: null, | |
| 217 | }; | |
| 218 | } | |
| 219 | ||
| 220 | 168 | function mergeTurnDiffSummaries( |
| 221 | 169 | existing: Thread["turnDiffSummaries"], |
| 222 | 170 | next: Thread["turnDiffSummaries"], |
| ... | ... | @@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 615 | 563 | }); |
| 616 | 564 | }) |
| 617 | 565 | .filter((thread): thread is Thread => thread !== null); |
| 618 | const diffState = resetDiffTargetIfMissing(state, nextThreads); | |
| 619 | 566 | |
| 620 | 567 | return { |
| 621 | 568 | ...state, |
| 622 | 569 | projects: nextProjects, |
| 623 | 570 | threads: nextThreads, |
| 624 | ...diffState, | |
| 625 | 571 | }; |
| 626 | 572 | } |
| 627 | 573 | |
| ... | ... | @@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 640 | 586 | } |
| 641 | 587 | |
| 642 | 588 | const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); |
| 643 | const diffState = resetDiffTargetIfMissing(state, threads); | |
| 644 | 589 | |
| 645 | 590 | return { |
| 646 | 591 | ...state, |
| 647 | 592 | projects, |
| 648 | 593 | threads, |
| 649 | ...diffState, | |
| 650 | 594 | }; |
| 651 | 595 | } |
| 652 | 596 | |
| ... | ... | @@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState { |
| 830 | 774 | ), |
| 831 | 775 | }; |
| 832 | 776 | |
| 833 | case "TOGGLE_DIFF": | |
| 834 | return { ...state, diffOpen: !state.diffOpen }; | |
| 835 | ||
| 836 | case "OPEN_DIFF": | |
| 837 | return { | |
| 838 | ...state, | |
| 839 | diffOpen: true, | |
| 840 | diffThreadId: action.threadId, | |
| 841 | diffTurnId: action.turnId ?? null, | |
| 842 | diffFilePath: action.filePath ?? null, | |
| 843 | }; | |
| 844 | ||
| 845 | case "SET_DIFF_TARGET": | |
| 846 | return { | |
| 847 | ...state, | |
| 848 | diffThreadId: action.threadId, | |
| 849 | diffTurnId: action.turnId ?? null, | |
| 850 | diffFilePath: action.filePath ?? null, | |
| 851 | }; | |
| 852 | ||
| 853 | case "CLOSE_DIFF": | |
| 854 | return { ...state, diffOpen: false }; | |
| 855 | ||
| 856 | 777 | case "APPLY_TERMINAL_EVENT": |
| 857 | 778 | if (!state.threads.some((thread) => thread.id === action.event.threadId)) { |
| 858 | 779 | return state; |
| ... | ... | @@ -1093,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState { |
| 1093 | 1014 | runtimeMode: action.mode, |
| 1094 | 1015 | }; |
| 1095 | 1016 | |
| 1096 | case "DELETE_THREAD": { | |
| 1097 | const threads = state.threads.filter((t) => t.id !== action.threadId); | |
| 1098 | const diffState = resetDiffTargetIfMissing(state, threads); | |
| 1017 | case "DELETE_THREAD": | |
| 1099 | 1018 | return { |
| 1100 | 1019 | ...state, |
| 1101 | threads, | |
| 1102 | ...diffState, | |
| 1020 | threads: state.threads.filter((t) => t.id !== action.threadId), | |
| 1103 | 1021 | }; |
| 1104 | } | |
| 1105 | 1022 | |
| 1106 | 1023 | default: |
| 1107 | 1024 | return state; |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -8,6 +8,7 @@ interface DesktopBridge { |
| 8 | 8 | confirm: (message: string) => Promise<boolean>; |
| 9 | 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; |
| 10 | 10 | openExternal: (url: string) => Promise<boolean>; |
| 11 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | declare global { |
assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png created| Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ |
assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png created| Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ |
assets/T3 Code logo.svg	 created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
assets/T3 Code logo@16x.png created| Binary files /dev/null and b/assets/T3 Code logo@16x.png differ |
assets/T3 Code logo@32x.png created| Binary files /dev/null and b/assets/T3 Code logo@32x.png differ |
assets/T3 Code logo@8x.png created| Binary files /dev/null and b/assets/T3 Code logo@8x.png differ |
assets/T3 Code-macOS-Default-1024x1024@1x.png created| Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ |
assets/T3 Code-macOS-Default-1024x1024@4x.png created| Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ |
assets/logo-1024.png created| Binary files /dev/null and b/assets/logo-1024.png differ |
assets/logo.svg created+4| ... | ... | @@ -0,0 +1,4 @@ |
| 1 | <svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
| 2 | <path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/> | |
| 3 | <path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/> | |
| 4 | </svg> |
assets/macos-icon-1024.png created| Binary files /dev/null and b/assets/macos-icon-1024.png differ |
package.json+3| ... | ... | @@ -20,6 +20,9 @@ |
| 20 | 20 | "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", |
| 21 | 21 | "fmt": "oxfmt", |
| 22 | 22 | "build:contracts": "turbo run build --filter=@t3tools/contracts", |
| 23 | "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", | |
| 24 | "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", | |
| 25 | "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", | |
| 23 | 26 | "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", |
| 24 | 27 | "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" |
| 25 | 28 | }, |
packages/contracts/src/provider.test.ts+22| ... | ... | @@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => { |
| 37 | 37 | }), |
| 38 | 38 | ).toThrow(); |
| 39 | 39 | }); |
| 40 | ||
| 41 | it("accepts optional codex binary and home path overrides", () => { | |
| 42 | const parsed = providerSessionStartInputSchema.parse({ | |
| 43 | codexBinaryPath: "/opt/codex/bin/codex", | |
| 44 | codexHomePath: "/Users/theo/.codex", | |
| 45 | }); | |
| 46 | expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); | |
| 47 | expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); | |
| 48 | }); | |
| 49 | ||
| 50 | it("rejects blank codex overrides", () => { | |
| 51 | expect(() => | |
| 52 | providerSessionStartInputSchema.parse({ | |
| 53 | codexBinaryPath: " ", | |
| 54 | }), | |
| 55 | ).toThrow(); | |
| 56 | expect(() => | |
| 57 | providerSessionStartInputSchema.parse({ | |
| 58 | codexHomePath: " ", | |
| 59 | }), | |
| 60 | ).toThrow(); | |
| 61 | }); | |
| 40 | 62 | }); |
| 41 | 63 | |
| 42 | 64 | describe("providerSendTurnInputSchema", () => { |
packages/contracts/src/provider.ts+2| ... | ... | @@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({ |
| 50 | 50 | cwd: z.string().min(1).optional(), |
| 51 | 51 | model: z.string().trim().min(1).optional(), |
| 52 | 52 | resumeThreadId: z.string().trim().min(1).optional(), |
| 53 | codexBinaryPath: z.string().trim().min(1).optional(), | |
| 54 | codexHomePath: z.string().trim().min(1).optional(), | |
| 53 | 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), |
| 54 | 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), |
| 55 | 57 | }); |
scripts/build-desktop-dmg.mjs created+343| ... | ... | @@ -0,0 +1,343 @@ |
| 1 | #!/usr/bin/env node | |
| 2 | ||
| 3 | import { spawn } from "node:child_process"; | |
| 4 | import fs from "node:fs/promises"; | |
| 5 | import os from "node:os"; | |
| 6 | import path from "node:path"; | |
| 7 | import { fileURLToPath } from "node:url"; | |
| 8 | ||
| 9 | const __dirname = path.dirname(fileURLToPath(import.meta.url)); | |
| 10 | const repoRoot = path.resolve(__dirname, ".."); | |
| 11 | const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); | |
| 12 | ||
| 13 | const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); | |
| 14 | ||
| 15 | function printUsage() { | |
| 16 | console.log(`Build a shareable macOS .dmg for T3 Code desktop. | |
| 17 | ||
| 18 | Usage: | |
| 19 | node scripts/build-desktop-dmg.mjs [options] | |
| 20 | ||
| 21 | Options: | |
| 22 | --arch <arm64|x64|universal> Target arch (default: host arch if supported) | |
| 23 | --version <semver> App version in DMG metadata (default: apps/server version) | |
| 24 | --output-dir <path> Output folder for final artifacts (default: release) | |
| 25 | --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) | |
| 26 | --keep-stage Keep temporary staging directory | |
| 27 | --signed Allow automatic code signing discovery | |
| 28 | --help Show this message | |
| 29 | `); | |
| 30 | } | |
| 31 | ||
| 32 | function fail(message) { | |
| 33 | console.error(`[desktop-dmg] ${message}`); | |
| 34 | process.exit(1); | |
| 35 | } | |
| 36 | ||
| 37 | function parseArgs(argv) { | |
| 38 | const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; | |
| 39 | const options = { | |
| 40 | arch: defaultArch, | |
| 41 | version: null, | |
| 42 | skipBuild: false, | |
| 43 | keepStage: false, | |
| 44 | signed: false, | |
| 45 | outputDir: path.join(repoRoot, "release"), | |
| 46 | }; | |
| 47 | ||
| 48 | for (let i = 0; i < argv.length; i += 1) { | |
| 49 | const arg = argv[i]; | |
| 50 | if (arg === "--help") { | |
| 51 | options.help = true; | |
| 52 | continue; | |
| 53 | } | |
| 54 | if (arg === "--skip-build") { | |
| 55 | options.skipBuild = true; | |
| 56 | continue; | |
| 57 | } | |
| 58 | if (arg === "--keep-stage") { | |
| 59 | options.keepStage = true; | |
| 60 | continue; | |
| 61 | } | |
| 62 | if (arg === "--signed") { | |
| 63 | options.signed = true; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { | |
| 67 | const value = argv[i + 1]; | |
| 68 | if (!value || value.startsWith("--")) { | |
| 69 | fail(`Missing value for ${arg}`); | |
| 70 | } | |
| 71 | i += 1; | |
| 72 | if (arg === "--arch") options.arch = value; | |
| 73 | if (arg === "--version") options.version = value; | |
| 74 | if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); | |
| 75 | continue; | |
| 76 | } | |
| 77 | fail(`Unknown argument: ${arg}`); | |
| 78 | } | |
| 79 | ||
| 80 | if (!ARCH_CHOICES.has(options.arch)) { | |
| 81 | fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); | |
| 82 | } | |
| 83 | ||
| 84 | return options; | |
| 85 | } | |
| 86 | ||
| 87 | async function readJson(filePath) { | |
| 88 | const raw = await fs.readFile(filePath, "utf8"); | |
| 89 | return JSON.parse(raw); | |
| 90 | } | |
| 91 | ||
| 92 | async function exists(filePath) { | |
| 93 | try { | |
| 94 | await fs.access(filePath); | |
| 95 | return true; | |
| 96 | } catch { | |
| 97 | return false; | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { | |
| 102 | return new Promise((resolve, reject) => { | |
| 103 | const child = spawn(command, args, { | |
| 104 | cwd, | |
| 105 | env, | |
| 106 | stdio, | |
| 107 | }); | |
| 108 | ||
| 109 | child.on("error", reject); | |
| 110 | child.on("exit", (code, signal) => { | |
| 111 | if (signal) { | |
| 112 | reject(new Error(`${command} exited via signal ${signal}`)); | |
| 113 | return; | |
| 114 | } | |
| 115 | if (code !== 0) { | |
| 116 | reject(new Error(`${command} exited with code ${code}`)); | |
| 117 | return; | |
| 118 | } | |
| 119 | resolve(); | |
| 120 | }); | |
| 121 | }); | |
| 122 | } | |
| 123 | ||
| 124 | async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { | |
| 125 | const iconsetDir = path.join(tmpRoot, "icon.iconset"); | |
| 126 | await fs.mkdir(iconsetDir, { recursive: true }); | |
| 127 | ||
| 128 | const iconSizes = [16, 32, 128, 256, 512]; | |
| 129 | for (const size of iconSizes) { | |
| 130 | await run( | |
| 131 | "sips", | |
| 132 | ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)], | |
| 133 | { stdio: "ignore" }, | |
| 134 | ); | |
| 135 | const retinaSize = size * 2; | |
| 136 | await run( | |
| 137 | "sips", | |
| 138 | [ | |
| 139 | "-z", | |
| 140 | String(retinaSize), | |
| 141 | String(retinaSize), | |
| 142 | sourcePng, | |
| 143 | "--out", | |
| 144 | path.join(iconsetDir, `icon_${size}x${size}@2x.png`), | |
| 145 | ], | |
| 146 | { stdio: "ignore" }, | |
| 147 | ); | |
| 148 | } | |
| 149 | ||
| 150 | await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); | |
| 151 | } | |
| 152 | ||
| 153 | async function stageProductionIcons(stageResourcesDir) { | |
| 154 | if (!(await exists(PRODUCTION_ICON_SOURCE))) { | |
| 155 | fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); | |
| 156 | } | |
| 157 | ||
| 158 | const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); | |
| 159 | try { | |
| 160 | const iconPngPath = path.join(stageResourcesDir, "icon.png"); | |
| 161 | const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); | |
| 162 | await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { | |
| 163 | stdio: "ignore", | |
| 164 | }); | |
| 165 | await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); | |
| 166 | } finally { | |
| 167 | await fs.rm(tmpRoot, { recursive: true, force: true }); | |
| 168 | } | |
| 169 | } | |
| 170 | ||
| 171 | async function validateBundledClientAssets(clientDir) { | |
| 172 | const indexPath = path.join(clientDir, "index.html"); | |
| 173 | const indexHtml = await fs.readFile(indexPath, "utf8"); | |
| 174 | const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); | |
| 175 | const missing = []; | |
| 176 | ||
| 177 | for (const ref of refs) { | |
| 178 | const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; | |
| 179 | if (!normalizedRef) continue; | |
| 180 | if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; | |
| 181 | if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; | |
| 182 | ||
| 183 | const ext = path.extname(normalizedRef); | |
| 184 | if (!ext) continue; | |
| 185 | ||
| 186 | const relativePath = normalizedRef.replace(/^\/+/, ""); | |
| 187 | const assetPath = path.join(clientDir, relativePath); | |
| 188 | if (!(await exists(assetPath))) { | |
| 189 | missing.push(normalizedRef); | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | if (missing.length > 0) { | |
| 194 | const preview = missing.slice(0, 6).join(", "); | |
| 195 | const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; | |
| 196 | fail( | |
| 197 | `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, | |
| 198 | ); | |
| 199 | } | |
| 200 | } | |
| 201 | ||
| 202 | async function main() { | |
| 203 | const options = parseArgs(process.argv.slice(2)); | |
| 204 | if (options.help) { | |
| 205 | printUsage(); | |
| 206 | return; | |
| 207 | } | |
| 208 | ||
| 209 | if (process.platform !== "darwin") { | |
| 210 | fail("This script only builds macOS .dmg artifacts and must run on macOS."); | |
| 211 | } | |
| 212 | ||
| 213 | const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); | |
| 214 | const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); | |
| 215 | ||
| 216 | const electronVersion = desktopPkg.dependencies?.electron; | |
| 217 | if (typeof electronVersion !== "string" || electronVersion.length === 0) { | |
| 218 | fail("Could not resolve apps/desktop electron dependency version."); | |
| 219 | } | |
| 220 | ||
| 221 | const dependencies = serverPkg.dependencies; | |
| 222 | if (!dependencies || Object.keys(dependencies).length === 0) { | |
| 223 | fail("Could not resolve production dependencies from apps/server/package.json."); | |
| 224 | } | |
| 225 | ||
| 226 | const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; | |
| 227 | const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); | |
| 228 | const stageAppDir = path.join(stageRoot, "app"); | |
| 229 | const distDirs = { | |
| 230 | desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), | |
| 231 | desktopResources: path.join(repoRoot, "apps/desktop/resources"), | |
| 232 | serverDist: path.join(repoRoot, "apps/server/dist"), | |
| 233 | }; | |
| 234 | const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); | |
| 235 | ||
| 236 | if (!options.skipBuild) { | |
| 237 | console.log("[desktop-dmg] Building desktop/server/web artifacts..."); | |
| 238 | await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); | |
| 239 | } | |
| 240 | ||
| 241 | for (const [label, dir] of Object.entries(distDirs)) { | |
| 242 | if (!(await exists(dir))) { | |
| 243 | fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); | |
| 244 | } | |
| 245 | } | |
| 246 | if (!(await exists(bundledClientEntry))) { | |
| 247 | fail( | |
| 248 | `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, | |
| 249 | ); | |
| 250 | } | |
| 251 | await validateBundledClientAssets(path.dirname(bundledClientEntry)); | |
| 252 | ||
| 253 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 254 | await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); | |
| 255 | await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); | |
| 256 | ||
| 257 | console.log("[desktop-dmg] Staging release app..."); | |
| 258 | await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { | |
| 259 | recursive: true, | |
| 260 | }); | |
| 261 | await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { | |
| 262 | recursive: true, | |
| 263 | }); | |
| 264 | await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); | |
| 265 | await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); | |
| 266 | ||
| 267 | const stagePackageJson = { | |
| 268 | name: "t3-code-desktop", | |
| 269 | version: appVersion, | |
| 270 | private: true, | |
| 271 | description: "T3 Code desktop build", | |
| 272 | author: "T3 Tools", | |
| 273 | main: "apps/desktop/dist-electron/main.js", | |
| 274 | build: { | |
| 275 | appId: "com.t3tools.t3code", | |
| 276 | productName: desktopPkg.productName ?? "T3 Code", | |
| 277 | artifactName: "T3-Code-${version}-${arch}.${ext}", | |
| 278 | directories: { | |
| 279 | buildResources: "apps/desktop/resources", | |
| 280 | }, | |
| 281 | mac: { | |
| 282 | target: ["dmg"], | |
| 283 | icon: "icon.icns", | |
| 284 | category: "public.app-category.developer-tools", | |
| 285 | }, | |
| 286 | }, | |
| 287 | dependencies, | |
| 288 | devDependencies: { | |
| 289 | electron: electronVersion, | |
| 290 | }, | |
| 291 | }; | |
| 292 | ||
| 293 | await fs.writeFile( | |
| 294 | path.join(stageAppDir, "package.json"), | |
| 295 | `${JSON.stringify(stagePackageJson, null, 2)}\n`, | |
| 296 | "utf8", | |
| 297 | ); | |
| 298 | ||
| 299 | console.log("[desktop-dmg] Installing staged production dependencies..."); | |
| 300 | await run("bun", ["install", "--production"], { cwd: stageAppDir }); | |
| 301 | ||
| 302 | const buildEnv = { | |
| 303 | ...process.env, | |
| 304 | }; | |
| 305 | if (!options.signed) { | |
| 306 | buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; | |
| 307 | } | |
| 308 | ||
| 309 | const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; | |
| 310 | console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); | |
| 311 | await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); | |
| 312 | ||
| 313 | const stageDistDir = path.join(stageAppDir, "dist"); | |
| 314 | const stageFiles = await fs.readdir(stageDistDir); | |
| 315 | const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); | |
| 316 | if (dmgFiles.length === 0) { | |
| 317 | fail(`Build completed but no .dmg found in ${stageDistDir}`); | |
| 318 | } | |
| 319 | ||
| 320 | await fs.mkdir(options.outputDir, { recursive: true }); | |
| 321 | const copiedArtifacts = []; | |
| 322 | for (const file of stageFiles) { | |
| 323 | if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; | |
| 324 | const from = path.join(stageDistDir, file); | |
| 325 | const to = path.join(options.outputDir, file); | |
| 326 | await fs.copyFile(from, to); | |
| 327 | copiedArtifacts.push(to); | |
| 328 | } | |
| 329 | ||
| 330 | if (!options.keepStage) { | |
| 331 | await fs.rm(stageRoot, { recursive: true, force: true }); | |
| 332 | } | |
| 333 | ||
| 334 | console.log("[desktop-dmg] Done. Artifacts:"); | |
| 335 | for (const artifact of copiedArtifacts) { | |
| 336 | console.log(` - ${artifact}`); | |
| 337 | } | |
| 338 | } | |
| 339 | ||
| 340 | main().catch((error) => { | |
| 341 | const message = error instanceof Error ? error.message : String(error); | |
| 342 | fail(message); | |
| 343 | }); |