diff --git a/.gitignore b/.gitignore index 3ac5a88fdfab3c0e19a87842855744a7b7640c3f..08ba9a8a1e32370fc37f9fbfd03607d43e812f78 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ packages/*/dist .env.local build/ .logs/ +release/ diff --git a/README.md b/README.md index ac73a427082d88fe755a14b4e66c3605fb64d783..425a70e4041018b3793af34d548c358597bad03d 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop bun run build bun run start +# Build a shareable macOS .dmg (arm64 by default) +bun run dist:desktop:dmg + # Or from any project directory after publishing: npx t3 ``` @@ -69,6 +72,17 @@ npx t3 - `bun run build` — Builds contracts, web app, and server through Turbo. - `bun run typecheck` — Strict TypeScript checks for all packages. - `bun run test` — Runs workspace tests. +- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`. +- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`. + +### Desktop `.dmg` packaging notes + +- Default build is unsigned/not notarized for local sharing. +- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source. +- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL). +- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic. +- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch. +- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage` ### Running multiple dev instances diff --git a/TODO.md b/TODO.md index e6ff5a6b7e4a04433c7bd84abe54fca6d56bb127..cf8656655e8eea82dc195d411a51a85cf2cc2150 100644 --- a/TODO.md +++ b/TODO.md @@ -2,6 +2,8 @@ ## Small things +- [ ] Plan mode +- [ ] Submitting new messages should scroll to bottom - [ ] 1 click copy in codeblocks - [x] Pasting/adding images to submit with prompts - [ ] Only show last 10 threads for a given project @@ -12,7 +14,7 @@ - [x] "Response" bar should ONLY appear on work completed - [x] "Response" bar should ONLY appear above the FINAL message in a series - [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc) -- [ ] Insta focus input on new thread creation +- [x] Insta focus input on new thread creation - [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it - [x] Making new thread should auto-focus input box - [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank) diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index acb4535b60e5107bc2fac12705d43e40b23c2d18..45b848af6524e510fc183d1ac21891d95444e769 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -1 +1,2 @@ dist-electron/ +.electron-runtime/ diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d6afd21ce940cbbd337504fb545c19c59ed95e2c..65a23524587ac523734734ec9214e235c534d433 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,13 +2,14 @@ "name": "@t3tools/desktop", "version": "0.0.0", "private": true, + "productName": "T3 Code (Alpha)", "main": "dist-electron/main.js", "scripts": { "dev": "bun run --parallel dev:bundle dev:electron", "dev:bundle": "tsdown --watch", "dev:electron": "bun run scripts/dev-electron.mjs", "build": "tsdown", - "start": "electron dist-electron/main.js", + "start": "bun run scripts/start-electron.mjs", "typecheck": "tsc --noEmit", "test": "vitest run --passWithNoTests", "smoke-test": "node scripts/smoke-test.mjs" diff --git a/apps/desktop/resources/icon.icns b/apps/desktop/resources/icon.icns new file mode 100644 index 0000000000000000000000000000000000000000..da16d12a0c7cb97f6a77c382f1a07c52a9457b16 Binary files /dev/null and b/apps/desktop/resources/icon.icns differ diff --git a/apps/desktop/resources/icon.ico b/apps/desktop/resources/icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/desktop/resources/icon.ico differ diff --git a/apps/desktop/resources/icon.png b/apps/desktop/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..37f3f756a553db1520e7bde5f928bc7a68cd1bdf Binary files /dev/null and b/apps/desktop/resources/icon.png differ diff --git a/apps/desktop/scripts/dev-electron.mjs b/apps/desktop/scripts/dev-electron.mjs index be7d8380fb7172b0d35436d3e8b4a714f3d13810..03ac96b5a070e5e3a12acb02693ef27bc678c672 100644 --- a/apps/desktop/scripts/dev-electron.mjs +++ b/apps/desktop/scripts/dev-electron.mjs @@ -1,7 +1,10 @@ -import { spawn } from "node:child_process"; +import { spawnSync } from "node:child_process"; +import electronmon from "electronmon"; import waitOn from "wait-on"; +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173); const devServerUrl = `http://localhost:${port}`; @@ -14,18 +17,57 @@ await waitOn({ ], }); -const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon"; const childEnv = { ...process.env }; delete childEnv.ELECTRON_RUN_AS_NODE; -const child = spawn(command, ["dist-electron/main.js"], { - stdio: "inherit", +const monitor = await electronmon({ + cwd: desktopDir, + args: ["dist-electron/main.js"], env: { ...childEnv, VITE_DEV_SERVER_URL: devServerUrl, }, + electronPath: resolveElectronPath(), }); -child.on("exit", (code) => { - process.exit(code ?? 0); +let shuttingDown = false; + +function killChildTree(signal) { + if (process.platform === "win32") { + return; + } + + // electronmon may leave Electron orphaned when parent shutdown races; kill direct children as fallback. + spawnSync("pkill", [`-${signal}`, "-P", String(process.pid)], { stdio: "ignore" }); +} + +async function shutdown(exitCode) { + if (shuttingDown) return; + shuttingDown = true; + + try { + await Promise.race([ + monitor.destroy(), + new Promise((resolve) => setTimeout(resolve, 1_500)), + ]); + } catch { + // Best effort only; fallback process kill below handles stubborn children. + } + + killChildTree("TERM"); + setTimeout(() => { + killChildTree("KILL"); + }, 1_200).unref(); + + process.exit(exitCode); +} + +process.once("SIGINT", () => { + void shutdown(130); +}); +process.once("SIGTERM", () => { + void shutdown(143); +}); +process.once("SIGHUP", () => { + void shutdown(129); }); diff --git a/apps/desktop/scripts/electron-launcher.mjs b/apps/desktop/scripts/electron-launcher.mjs new file mode 100644 index 0000000000000000000000000000000000000000..2a6d685dea2032cc4de322aa03ba9bb46fa5110a --- /dev/null +++ b/apps/desktop/scripts/electron-launcher.mjs @@ -0,0 +1,173 @@ +// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron" + +import { spawnSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_BUNDLE_ID = "com.t3tools.t3code"; +const LAUNCHER_VERSION = 1; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const desktopDir = resolve(__dirname, ".."); + +function setPlistString(plistPath, key, value) { + const replaceResult = spawnSync( + "plutil", + ["-replace", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (replaceResult.status === 0) { + return; + } + + const insertResult = spawnSync( + "plutil", + ["-insert", key, "-string", value, plistPath], + { + encoding: "utf8", + }, + ); + if (insertResult.status === 0) { + return; + } + + const details = [replaceResult.stderr, insertResult.stderr] + .filter(Boolean) + .join("\n"); + throw new Error( + `Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim(), + ); +} + +function patchMainBundleInfoPlist(appBundlePath, iconPath) { + const infoPlistPath = join(appBundlePath, "Contents", "Info.plist"); + setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleName", APP_DISPLAY_NAME); + setPlistString(infoPlistPath, "CFBundleIdentifier", APP_BUNDLE_ID); + setPlistString(infoPlistPath, "CFBundleIconFile", "icon.icns"); + + const resourcesDir = join(appBundlePath, "Contents", "Resources"); + copyFileSync(iconPath, join(resourcesDir, "icon.icns")); + copyFileSync(iconPath, join(resourcesDir, "electron.icns")); +} + +function patchHelperBundleInfoPlists(appBundlePath) { + const frameworksDir = join(appBundlePath, "Contents", "Frameworks"); + if (!existsSync(frameworksDir)) { + return; + } + + for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) { + if (!entry.isDirectory() || !entry.name.endsWith(".app")) { + continue; + } + if (!entry.name.startsWith("Electron Helper")) { + continue; + } + + const helperPlistPath = join( + frameworksDir, + entry.name, + "Contents", + "Info.plist", + ); + if (!existsSync(helperPlistPath)) { + continue; + } + + const suffix = entry.name + .replace("Electron Helper", "") + .replace(".app", "") + .trim(); + const helperName = suffix + ? `${APP_DISPLAY_NAME} Helper ${suffix}` + : `${APP_DISPLAY_NAME} Helper`; + const helperIdSuffix = suffix + .replace(/[()]/g, "") + .trim() + .toLowerCase() + .replace(/\s+/g, "-"); + const helperBundleId = helperIdSuffix + ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}` + : `${APP_BUNDLE_ID}.helper`; + + setPlistString(helperPlistPath, "CFBundleDisplayName", helperName); + setPlistString(helperPlistPath, "CFBundleName", helperName); + setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId); + } +} + +function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +} + +function buildMacLauncher(electronBinaryPath) { + const sourceAppBundlePath = resolve(electronBinaryPath, "../../.."); + const runtimeDir = join(desktopDir, ".electron-runtime"); + const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`); + const targetBinaryPath = join( + targetAppBundlePath, + "Contents", + "MacOS", + "Electron", + ); + const iconPath = join(desktopDir, "resources", "icon.icns"); + const metadataPath = join(runtimeDir, "metadata.json"); + + mkdirSync(runtimeDir, { recursive: true }); + + const expectedMetadata = { + launcherVersion: LAUNCHER_VERSION, + sourceAppBundlePath, + sourceAppMtimeMs: statSync(sourceAppBundlePath).mtimeMs, + iconMtimeMs: statSync(iconPath).mtimeMs, + }; + + const currentMetadata = readJson(metadataPath); + if ( + existsSync(targetBinaryPath) && + currentMetadata && + JSON.stringify(currentMetadata) === JSON.stringify(expectedMetadata) + ) { + return targetBinaryPath; + } + + rmSync(targetAppBundlePath, { recursive: true, force: true }); + cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true }); + patchMainBundleInfoPlist(targetAppBundlePath, iconPath); + patchHelperBundleInfoPlists(targetAppBundlePath); + writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`); + + return targetBinaryPath; +} + +export function resolveElectronPath() { + const require = createRequire(import.meta.url); + const electronBinaryPath = require("electron"); + + if (process.platform !== "darwin") { + return electronBinaryPath; + } + + return buildMacLauncher(electronBinaryPath); +} diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs new file mode 100644 index 0000000000000000000000000000000000000000..bf93adb6b0db0c2b5658faa08d5fd9ec3f6ee9e7 --- /dev/null +++ b/apps/desktop/scripts/start-electron.mjs @@ -0,0 +1,20 @@ +import { spawn } from "node:child_process"; + +import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; + +const childEnv = { ...process.env }; +delete childEnv.ELECTRON_RUN_AS_NODE; + +const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], { + stdio: "inherit", + cwd: desktopDir, + env: childEnv, +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index a1f7a9bf21349271a570135693c45bae2b90d7e2..792e78811ecf87fb1c5915145ac75ea6d9b7aa73 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -5,7 +5,16 @@ import net from "node:net"; import os from "node:os"; import path from "node:path"; -import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; +import { + app, + BrowserWindow, + dialog, + ipcMain, + Menu, + type MenuItemConstructorOptions, + protocol, + shell, +} from "electron"; import { showDesktopConfirmDialog } from "./confirmDialog"; import { fixPath } from "./fixPath"; @@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; -const ROOT_DIR = path.resolve(__dirname, "../../.."); -const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); -const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); +const MENU_ACTION_CHANNEL = "desktop:menu-action"; const STATE_DIR = path.join(os.homedir(), ".t3", "userdata"); +const DESKTOP_SCHEME = "t3"; +const ROOT_DIR = path.resolve(__dirname, "../../.."); const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL); +const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)"; +const APP_USER_MODEL_ID = "com.t3tools.t3code"; let mainWindow: BrowserWindow | null = null; let backendProcess: ChildProcess | null = null; @@ -30,6 +41,235 @@ let backendWsUrl = ""; let restartAttempt = 0; let restartTimer: ReturnType | null = null; let isQuitting = false; +let desktopProtocolRegistered = false; + +protocol.registerSchemesAsPrivileged([ + { + scheme: DESKTOP_SCHEME, + privileges: { + standard: true, + secure: true, + supportFetchAPI: true, + corsEnabled: true, + }, + }, +]); + +function resolveAppRoot(): string { + if (!app.isPackaged) { + return ROOT_DIR; + } + return app.getAppPath(); +} + +function resolveBackendEntry(): string { + return path.join(resolveAppRoot(), "apps/server/dist/index.mjs"); +} + +function resolveBackendCwd(): string { + if (!app.isPackaged) { + return resolveAppRoot(); + } + return os.homedir(); +} + +function resolveDesktopStaticDir(): string | null { + const appRoot = resolveAppRoot(); + const candidates = [ + path.join(appRoot, "apps/server/dist/client"), + path.join(appRoot, "apps/web/dist"), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(path.join(candidate, "index.html"))) { + return candidate; + } + } + + return null; +} + +function resolveDesktopStaticPath(staticRoot: string, requestUrl: string): string { + const url = new URL(requestUrl); + const rawPath = decodeURIComponent(url.pathname); + const normalizedPath = path.posix.normalize(rawPath).replace(/^\/+/, ""); + if (normalizedPath.includes("..")) { + return path.join(staticRoot, "index.html"); + } + + const requestedPath = normalizedPath.length > 0 ? normalizedPath : "index.html"; + const resolvedPath = path.join(staticRoot, requestedPath); + + if (path.extname(resolvedPath)) { + return resolvedPath; + } + + const nestedIndex = path.join(resolvedPath, "index.html"); + if (fs.existsSync(nestedIndex)) { + return nestedIndex; + } + + return path.join(staticRoot, "index.html"); +} + +function isStaticAssetRequest(requestUrl: string): boolean { + try { + const url = new URL(requestUrl); + return path.extname(url.pathname).length > 0; + } catch { + return false; + } +} + +function registerDesktopProtocol(): void { + if (isDevelopment || desktopProtocolRegistered) return; + + const staticRoot = resolveDesktopStaticDir(); + if (!staticRoot) { + throw new Error("Desktop static bundle missing. Build apps/server (with bundled client) first."); + } + + const staticRootResolved = path.resolve(staticRoot); + const staticRootPrefix = `${staticRootResolved}${path.sep}`; + const fallbackIndex = path.join(staticRootResolved, "index.html"); + + protocol.registerFileProtocol(DESKTOP_SCHEME, (request, callback) => { + try { + const candidate = resolveDesktopStaticPath(staticRootResolved, request.url); + const resolvedCandidate = path.resolve(candidate); + const isInRoot = + resolvedCandidate === fallbackIndex || resolvedCandidate.startsWith(staticRootPrefix); + const isAssetRequest = isStaticAssetRequest(request.url); + + if (!isInRoot || !fs.existsSync(resolvedCandidate)) { + if (isAssetRequest) { + callback({ error: -6 }); + return; + } + callback({ path: fallbackIndex }); + return; + } + + callback({ path: resolvedCandidate }); + } catch { + callback({ path: fallbackIndex }); + } + }); + + desktopProtocolRegistered = true; +} + +function dispatchMenuAction(action: string): void { + const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0]; + const targetWindow = existingWindow ?? createWindow(); + if (!existingWindow) { + mainWindow = targetWindow; + } + + const send = () => { + if (targetWindow.isDestroyed()) return; + targetWindow.webContents.send(MENU_ACTION_CHANNEL, action); + if (!targetWindow.isVisible()) { + targetWindow.show(); + } + targetWindow.focus(); + }; + + if (targetWindow.webContents.isLoadingMainFrame()) { + targetWindow.webContents.once("did-finish-load", send); + return; + } + + send(); +} + +function configureApplicationMenu(): void { + const template: MenuItemConstructorOptions[] = []; + + if (process.platform === "darwin") { + template.push({ + label: app.name, + submenu: [ + { role: "about" }, + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" }, + { role: "services" }, + { type: "separator" }, + { role: "hide" }, + { role: "hideOthers" }, + { role: "unhide" }, + { type: "separator" }, + { role: "quit" }, + ], + }); + } + + template.push( + { + label: "File", + submenu: [ + ...(process.platform === "darwin" + ? [] + : [ + { + label: "Settings...", + accelerator: "CmdOrCtrl+,", + click: () => dispatchMenuAction("open-settings"), + }, + { type: "separator" as const }, + ]), + { role: process.platform === "darwin" ? "close" : "quit" }, + ], + }, + { role: "editMenu" }, + { role: "viewMenu" }, + { role: "windowMenu" }, + ); + + Menu.setApplicationMenu(Menu.buildFromTemplate(template)); +} + +function resolveResourcePath(fileName: string): string | null { + const candidates = [ + path.join(__dirname, "../resources", fileName), + path.join(process.resourcesPath, "resources", fileName), + path.join(process.resourcesPath, fileName), + ]; + + for (const candidate of candidates) { + if (fs.existsSync(candidate)) { + return candidate; + } + } + + return null; +} + +function resolveIconPath(ext: "ico" | "icns" | "png"): string | null { + return resolveResourcePath(`icon.${ext}`); +} + +function configureAppIdentity(): void { + app.setName(APP_DISPLAY_NAME); + app.setAboutPanelOptions({ + applicationName: APP_DISPLAY_NAME, + }); + + if (process.platform === "win32") { + app.setAppUserModelId(APP_USER_MODEL_ID); + } + + if (process.platform === "darwin" && app.dock) { + const iconPath = resolveIconPath("png"); + if (iconPath) { + app.dock.setIcon(iconPath); + } + } +} async function reserveLoopbackPort(): Promise { return new Promise((resolve, reject) => { @@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void { function startBackend(): void { if (isQuitting || backendProcess) return; - if (!fs.existsSync(BACKEND_ENTRY)) { - scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`); + const backendEntry = resolveBackendEntry(); + if (!fs.existsSync(backendEntry)) { + scheduleBackendRestart(`missing server entry at ${backendEntry}`); return; } - const child = spawn(process.execPath, [BACKEND_ENTRY], { - cwd: ROOT_DIR, + const child = spawn(process.execPath, [backendEntry], { + cwd: resolveBackendCwd(), // In Electron main, process.execPath points to the Electron binary. // Run the child in Node mode so this backend process does not become a GUI app instance. env: { @@ -124,10 +365,10 @@ function stopBackend(): void { backendProcess = null; if (!child) return; - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGTERM"); setTimeout(() => { - if (!child.killed) { + if (child.exitCode === null && child.signalCode === null) { child.kill("SIGKILL"); } }, 2_000).unref(); @@ -204,6 +445,13 @@ function registerIpcHandlers(): void { }); } +function getIconOption(): { icon: string } | Record { + if (process.platform === "darwin") return {}; // macOS uses .icns from app bundle + const ext = process.platform === "win32" ? "ico" : "png"; + const iconPath = resolveIconPath(ext); + return iconPath ? { icon: iconPath } : {}; +} + function createWindow(): BrowserWindow { const window = new BrowserWindow({ width: 1100, @@ -212,6 +460,8 @@ function createWindow(): BrowserWindow { minHeight: 620, show: false, autoHideMenuBar: true, + ...getIconOption(), + title: APP_DISPLAY_NAME, titleBarStyle: "hiddenInset", trafficLightPosition: { x: 16, y: 18 }, webPreferences: { @@ -223,6 +473,13 @@ function createWindow(): BrowserWindow { }); window.webContents.setWindowOpenHandler(() => ({ action: "deny" })); + window.on("page-title-updated", (event) => { + event.preventDefault(); + window.setTitle(APP_DISPLAY_NAME); + }); + window.webContents.on("did-finish-load", () => { + window.setTitle(APP_DISPLAY_NAME); + }); window.once("ready-to-show", () => { window.show(); }); @@ -231,10 +488,7 @@ function createWindow(): BrowserWindow { void window.loadURL(process.env.VITE_DEV_SERVER_URL as string); window.webContents.openDevTools({ mode: "detach" }); } else { - if (!fs.existsSync(WEB_ENTRY)) { - throw new Error(`Web bundle missing at ${WEB_ENTRY}`); - } - void window.loadFile(WEB_ENTRY); + void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`); } window.on("closed", () => { @@ -246,6 +500,8 @@ function createWindow(): BrowserWindow { return window; } +configureAppIdentity(); + async function bootstrap(): Promise { backendPort = await reserveLoopbackPort(); backendAuthToken = randomBytes(24).toString("hex"); @@ -263,6 +519,9 @@ app.on("before-quit", () => { }); app.whenReady().then(() => { + configureAppIdentity(); + configureApplicationMenu(); + registerDesktopProtocol(); void bootstrap(); app.on("activate", () => { @@ -277,3 +536,19 @@ app.on("window-all-closed", () => { app.quit(); } }); + +if (process.platform !== "win32") { + process.on("SIGINT", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); + + process.on("SIGTERM", () => { + if (isQuitting) return; + isQuitting = true; + stopBackend(); + app.quit(); + }); +} diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index fb90831fc0ded445d5ba9dcd85ef58c9b9c7a4c0..464832b33d307420860bfd9d90a6977d9b9d7247 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; const CONFIRM_CHANNEL = "desktop:confirm"; const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; +const MENU_ACTION_CHANNEL = "desktop:menu-action"; const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; contextBridge.exposeInMainWorld("desktopBridge", { @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { showContextMenu: (items: { id: string; label: string }[]) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise, openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise, + onMenuAction: (listener: (action: string) => void) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => { + if (typeof action !== "string") return; + listener(action); + }; + + ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener); + }; + }, }); diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index 6122ec601f61e1415abcba35662c7a831c1ffebd..912f9b2db867aabd380a17fa2e83ab348dc97dd7 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter + + - T3 Code + T3 Code (Alpha)
diff --git a/apps/web/public/apple-touch-icon.png b/apps/web/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..43dc5d21602cc217500121410827b603a6a6fe5e Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ diff --git a/apps/web/public/favicon-16x16.png b/apps/web/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..1636b52162945be4ea63d866a9cb22ae68bb4ed0 Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ diff --git a/apps/web/public/favicon-32x32.png b/apps/web/public/favicon-32x32.png new file mode 100644 index 0000000000000000000000000000000000000000..b84a6ff27a093136a3511018cd15b6bb6bb9fff5 Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..8298f70d8b3690d1103d712c37d13a1d17fcec8d Binary files /dev/null and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd3da7224c7855b1c2cd446925bb5bcbe663e479 --- /dev/null +++ b/apps/web/src/appSettings.ts @@ -0,0 +1,109 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { z } from "zod"; + +const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; + +const appSettingsSchema = z.object({ + codexBinaryPath: z.string().trim().max(4096).default(""), + codexHomePath: z.string().trim().max(4096).default(""), + confirmThreadDelete: z.boolean().default(true), +}); + +export type AppSettings = z.infer; + +const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); + +let listeners: Array<() => void> = []; +let cachedRawSettings: string | null | undefined; +let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; + +function emitChange(): void { + for (const listener of listeners) { + listener(); + } +} + +function parsePersistedSettings(value: string | null): AppSettings { + if (!value) { + return DEFAULT_APP_SETTINGS; + } + + try { + const parsed = JSON.parse(value) as unknown; + return appSettingsSchema.parse(parsed); + } catch { + return DEFAULT_APP_SETTINGS; + } +} + +export function getAppSettingsSnapshot(): AppSettings { + if (typeof window === "undefined") { + return DEFAULT_APP_SETTINGS; + } + + const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); + if (raw === cachedRawSettings) { + return cachedSnapshot; + } + + cachedRawSettings = raw; + cachedSnapshot = parsePersistedSettings(raw); + return cachedSnapshot; +} + +function persistSettings(next: AppSettings): void { + if (typeof window === "undefined") return; + + const raw = JSON.stringify(next); + try { + if (raw !== cachedRawSettings) { + window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); + } + } catch { + // Best-effort persistence only. + } + + cachedRawSettings = raw; + cachedSnapshot = next; +} + +function subscribe(listener: () => void): () => void { + listeners.push(listener); + + const onStorage = (event: StorageEvent) => { + if (event.key === APP_SETTINGS_STORAGE_KEY) { + emitChange(); + } + }; + + window.addEventListener("storage", onStorage); + return () => { + listeners = listeners.filter((entry) => entry !== listener); + window.removeEventListener("storage", onStorage); + }; +} + +export function useAppSettings() { + const settings = useSyncExternalStore(subscribe, getAppSettingsSnapshot, () => DEFAULT_APP_SETTINGS); + + const updateSettings = useCallback((patch: Partial) => { + const next = appSettingsSchema.parse({ + ...getAppSettingsSnapshot(), + ...patch, + }); + persistSettings(next); + emitChange(); + }, []); + + const resetSettings = useCallback(() => { + persistSettings(DEFAULT_APP_SETTINGS); + emitChange(); + }, []); + + return { + settings, + updateSettings, + resetSettings, + defaults: DEFAULT_APP_SETTINGS, + } as const; +} diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts new file mode 100644 index 0000000000000000000000000000000000000000..4adc50bc1932e991c7c6d730c982305f5036fdc2 --- /dev/null +++ b/apps/web/src/branding.ts @@ -0,0 +1,3 @@ +export const APP_BASE_NAME = "T3 Code"; +export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha"; +export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 9b5288a4fdfd08a45d380c5cad659688a955159d..92a6a7392854bf12d7a1a033bb5dc2b57c3ccc11 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -28,6 +28,7 @@ import { } from "react"; import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; +import { useNavigate, useSearch } from "@tanstack/react-router"; import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual"; import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; @@ -35,6 +36,8 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { isElectron } from "../env"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useAppSettings } from "../appSettings"; import { buildBootstrapInput } from "../historyBootstrap"; import { type ComposerTriggerKind, @@ -374,8 +377,11 @@ interface ChatViewProps { export default function ChatView({ threadId }: ChatViewProps) { const { state, dispatch } = useStore(); + const navigate = useNavigate(); + const rawSearch = useSearch({ strict: false }); const api = useNativeApi(); const { resolvedTheme } = useTheme(); + const { settings: appSettings } = useAppSettings(); const queryClient = useQueryClient(); const createWorktreeMutation = useMutation( gitCreateWorktreeMutationOptions({ api, queryClient }), @@ -411,6 +417,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const checkpointTurnCountSyncFingerprintRef = useRef(new Map()); const activeThread = state.threads.find((t) => t.id === threadId); + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const diffOpen = diffSearch.diff === "1"; const activeThreadId = activeThread?.id ?? null; const activeSessionId = activeThread?.session?.sessionId ?? null; const activeThreadRuntimeId = @@ -642,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const runtimeSandboxMode = state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; + const codexBinaryPath = appSettings.codexBinaryPath.trim(); + const codexHomePath = appSettings.codexHomePath.trim(); const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; const composerTrigger = useMemo( () => detectComposerTrigger(prompt, composerCursor), @@ -1515,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) { cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, model: selectedModel || undefined, resumeThreadId: priorCodexThreadId ?? undefined, + ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), + ...(codexHomePath.length > 0 ? { codexHomePath } : {}), approvalPolicy: runtimeApprovalPolicy, sandboxMode: runtimeSandboxMode, }); @@ -1555,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProject, activeThread, api, + codexBinaryPath, + codexHomePath, dispatch, runtimeApprovalPolicy, runtimeSandboxMode, @@ -1954,32 +1971,29 @@ export default function ChatView({ threadId }: ChatViewProps) { setExpandedImage(image); }, []); const onToggleDiff = useCallback(() => { - if (state.diffOpen) { - dispatch({ type: "CLOSE_DIFF" }); - return; - } - - if (!activeThread) { - dispatch({ type: "TOGGLE_DIFF" }); - return; - } - - dispatch({ - type: "OPEN_DIFF", - threadId: activeThread.id, + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return diffOpen ? rest : { ...rest, diff: "1" }; + }, }); - }, [activeThread, dispatch, state.diffOpen]); + }, [diffOpen, navigate, threadId]); const onOpenTurnDiff = useCallback( (turnId: string, filePath?: string) => { - if (!activeThread) return; - dispatch({ - type: "OPEN_DIFF", - threadId: activeThread.id, - turnId, - ...(filePath ? { filePath } : {}), + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return filePath + ? { ...rest, diff: "1", diffTurnId: turnId, diffFilePath: filePath } + : { ...rest, diff: "1", diffTurnId: turnId }; + }, }); }, - [activeThread, dispatch], + [navigate, threadId], ); const onRevertUserMessage = useCallback( (messageId: string) => { @@ -2027,7 +2041,7 @@ export default function ChatView({ threadId }: ChatViewProps) { keybindings={keybindings} api={api} gitCwd={gitCwd} - diffOpen={state.diffOpen} + diffOpen={diffOpen} onRunProjectScript={(script) => { void runProjectScript(script); }} @@ -2812,11 +2826,16 @@ const MessagesTimeline = memo(function MessagesTimeline({ row.message.id, ); if (!turnSummary) return null; - const checkpointFilesState = turnCheckpointFilesByTurnId.get(turnSummary.turnId); + const checkpointFilesState = turnCheckpointFilesByTurnId.get( + turnSummary.turnId, + ); const checkpointFiles = checkpointFilesState?.files ?? []; const summaryStat = checkpointFiles.reduce( (acc, file) => { - if (typeof file.additions !== "number" || typeof file.deletions !== "number") { + if ( + typeof file.additions !== "number" || + typeof file.deletions !== "number" + ) { return acc; } return { @@ -2827,7 +2846,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ { additions: 0, deletions: 0 }, ); const changedFileCountLabel = - checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "..."; + checkpointFilesState?.status === "ready" + ? String(checkpointFiles.length) + : "..."; return (
@@ -2838,7 +2859,9 @@ const MessagesTimeline = memo(function MessagesTimeline({ • +{summaryStat.additions} / - -{summaryStat.deletions} + + -{summaryStat.deletions} + )}

@@ -2846,12 +2869,15 @@ const MessagesTimeline = memo(function MessagesTimeline({ type="button" size="xs" variant="outline" - onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)} + onClick={() => + onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path) + } > View diff
- {!checkpointFilesState || checkpointFilesState.status === "pending" ? ( + {!checkpointFilesState || + checkpointFilesState.status === "pending" ? (

Waiting for checkpoint metadata...

diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 394631c103cba88add74319ad4d586e0a36b6148..660b13de88d1e0a3a13653c967a23a614f3be5c5 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,15 +1,9 @@ import { parsePatchFiles } from "@pierre/diffs"; -import { - FileDiff, - type FileDiffMetadata, - Virtualizer, - WorkerPoolContextProvider, -} from "@pierre/diffs/react"; +import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react"; import { useQuery } from "@tanstack/react-query"; -import { useParams } from "@tanstack/react-router"; +import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; import { - type ReactNode, type WheelEvent as ReactWheelEvent, useCallback, useEffect, @@ -19,6 +13,7 @@ import { } from "react"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { isElectron } from "../env"; import { useNativeApi } from "../hooks/useNativeApi"; import { useTheme } from "../hooks/useTheme"; @@ -90,41 +85,26 @@ interface DiffPanelProps { mode?: "inline" | "sheet"; } -export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { - const workerPoolSize = useMemo(() => { - const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); - return Math.max(2, Math.min(6, Math.floor(cores / 2))); - }, []); - - return ( - - new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), - poolSize: workerPoolSize, - totalASTLRUCacheSize: 240, - }} - highlighterOptions={{ - tokenizeMaxLineLength: 1_000, - }} - > - {children} - - ); -} +export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = useNativeApi(); + const navigate = useNavigate(); const { resolvedTheme } = useTheme(); - const { state, dispatch } = useStore(); + const { state } = useStore(); const [diffRenderMode, setDiffRenderMode] = useState("stacked"); const patchViewportRef = useRef(null); const turnStripRef = useRef(null); const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false); const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false); const params = useParams({ strict: false }); + const rawSearch = useSearch({ strict: false }); const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; - const activeThreadId = state.diffThreadId ?? routeThreadId; + const diffSearch = useMemo( + () => parseDiffRouteSearch(rawSearch as Record), + [rawSearch], + ); + const activeThreadId = routeThreadId; const activeThread = state.threads.find((thread) => thread.id === activeThreadId); const activeThreadRuntimeId = activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null; @@ -132,10 +112,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = useTurnDiffSummaries(activeThread); - const canApplyStoredTarget = Boolean(activeThread && state.diffThreadId === activeThread.id); - const selectedTurnId = canApplyStoredTarget ? state.diffTurnId : null; - const selectedFilePath = - canApplyStoredTarget && selectedTurnId !== null ? state.diffFilePath : null; + const selectedTurnId = diffSearch.diffTurnId ?? null; + const selectedFilePath = selectedTurnId !== null ? (diffSearch.diffFilePath ?? null) : null; const selectedTurn = selectedTurnId === null ? undefined @@ -235,17 +213,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const selectTurn = (turnId: string) => { if (!activeThread) return; - dispatch({ - type: "SET_DIFF_TARGET", - threadId: activeThread.id, - turnId, + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1", diffTurnId: turnId }; + }, }); }; const selectWholeConversation = () => { if (!activeThread) return; - dispatch({ - type: "SET_DIFF_TARGET", - threadId: activeThread.id, + void navigate({ + to: "/$threadId", + params: { threadId: activeThread.id }, + search: (previous) => { + const rest = stripDiffSearchParams(previous); + return { ...rest, diff: "1" }; + }, }); }; const updateTurnStripScrollState = useCallback(() => { @@ -440,13 +425,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { ) : ( <>
- {!canApplyStoredTarget && state.diffThreadId && ( -
-

- Showing diffs for the active thread. -

-
- )} {checkpointDiffError && !renderablePatch && (

{checkpointDiffError}

diff --git a/apps/web/src/components/DiffWorkerPoolProvider.tsx b/apps/web/src/components/DiffWorkerPoolProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..53939cf81cfefa1371776945a059c4e6df247ba8 --- /dev/null +++ b/apps/web/src/components/DiffWorkerPoolProvider.tsx @@ -0,0 +1,26 @@ +import { WorkerPoolContextProvider } from "@pierre/diffs/react"; +import { useMemo, type ReactNode } from "react"; + +export function DiffWorkerPoolProvider({ children }: { children?: ReactNode }) { + const workerPoolSize = useMemo(() => { + const cores = typeof navigator === "undefined" ? 4 : Math.max(1, navigator.hardwareConcurrency || 4); + return Math.max(2, Math.min(6, Math.floor(cores / 2))); + }, []); + + return ( + + new Worker(new URL("../workers/diffs.worker.ts", import.meta.url), { type: "module" }), + poolSize: workerPoolSize, + totalASTLRUCacheSize: 240, + }} + highlighterOptions={{ + tokenizeMaxLineLength: 1_000, + }} + > + {children} + + ); +} + diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 71a6e02ce87164ecbdfb4a91eaf977ce7455268a..9291843ea9d07e92cbfa44fc8782afe5a55e69dd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,10 +1,11 @@ -import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; +import { TerminalIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useParams } from "@tanstack/react-router"; +import { useAppSettings } from "../appSettings"; import { isElectron } from "../env"; -import { useTheme } from "../hooks/useTheme"; +import { APP_STAGE_LABEL } from "../branding"; import { DEFAULT_MODEL } from "../model-logic"; import { derivePendingApprovals } from "../session-logic"; import { useStore } from "../store"; @@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast"; import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; import { createThread } from "../threadFactory"; -const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; function formatRelativeTime(iso: string): string { @@ -116,6 +116,7 @@ export default function Sidebar() { const { state, dispatch } = useStore(); const api = useNativeApi(); const navigate = useNavigate(); + const { settings: appSettings } = useAppSettings(); const params = useParams({ strict: false }); const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ @@ -126,7 +127,6 @@ export default function Sidebar() { const removeWorktreeMutation = useMutation( gitRemoveWorktreeMutationOptions({ api, queryClient }), ); - const { theme, setTheme } = useTheme(); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [isPickingFolder, setIsPickingFolder] = useState(false); @@ -271,6 +271,17 @@ export default function Sidebar() { const thread = state.threads.find((t) => t.id === threadId); if (!thread) return; + if (appSettings.confirmThreadDelete) { + const confirmed = await api.dialogs.confirm( + [ + `Delete thread \"${thread.title}\"?`, + "This permanently clears conversation history for this thread.", + ].join("\n"), + ); + if (!confirmed) { + return; + } + } const threadProject = state.projects.find((project) => project.id === thread.projectId); const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); const displayWorktreePath = orphanedWorktreePath @@ -348,7 +359,16 @@ export default function Sidebar() { }); } }, - [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads], + [ + api, + appSettings.confirmThreadDelete, + dispatch, + navigate, + removeWorktreeMutation, + routeThreadId, + state.projects, + state.threads, + ], ); const handleProjectContextMenu = useCallback( @@ -431,24 +451,14 @@ export default function Sidebar() {
- - T3 Code - - +
+ + T3 Code + + + {APP_STAGE_LABEL} + +
{/* New thread (global) */} diff --git a/apps/web/src/diffRouteSearch.test.ts b/apps/web/src/diffRouteSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..becf9e335f0ff4111e8d5725f9cb0db56adf9f29 --- /dev/null +++ b/apps/web/src/diffRouteSearch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; + +import { parseDiffRouteSearch } from "./diffRouteSearch"; + +describe("parseDiffRouteSearch", () => { + it("parses valid diff search values", () => { + const parsed = parseDiffRouteSearch({ + diff: "1", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }); + + expect(parsed).toEqual({ + diff: "1", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }); + }); + + it("drops turn and file values when diff is closed", () => { + const parsed = parseDiffRouteSearch({ + diff: "0", + diffTurnId: "turn-1", + diffFilePath: "src/app.ts", + }); + + expect(parsed).toEqual({}); + }); + + it("drops file value when turn is not selected", () => { + const parsed = parseDiffRouteSearch({ + diff: "1", + diffFilePath: "src/app.ts", + }); + + expect(parsed).toEqual({ + diff: "1", + }); + }); + + it("normalizes whitespace-only values", () => { + const parsed = parseDiffRouteSearch({ + diff: "1", + diffTurnId: " ", + diffFilePath: " ", + }); + + expect(parsed).toEqual({ + diff: "1", + }); + }); +}); + diff --git a/apps/web/src/diffRouteSearch.ts b/apps/web/src/diffRouteSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd3d6cae7087bb84286b5dfa75009a330c4541cd --- /dev/null +++ b/apps/web/src/diffRouteSearch.ts @@ -0,0 +1,32 @@ +export interface DiffRouteSearch { + diff?: "1"; + diffTurnId?: string; + diffFilePath?: string; +} + +function normalizeSearchString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const normalized = value.trim(); + return normalized.length > 0 ? normalized : undefined; +} + +export function stripDiffSearchParams>( + params: T, +): Omit { + const { diff: _diff, diffTurnId: _diffTurnId, diffFilePath: _diffFilePath, ...rest } = params; + return rest as Omit; +} + +export function parseDiffRouteSearch(search: Record): DiffRouteSearch { + const diff = search.diff === "1" ? "1" : undefined; + const diffTurnId = diff ? normalizeSearchString(search.diffTurnId) : undefined; + const diffFilePath = diff && diffTurnId ? normalizeSearchString(search.diffFilePath) : undefined; + + return { + ...(diff ? { diff } : {}), + ...(diffTurnId ? { diffTurnId } : {}), + ...(diffFilePath ? { diffFilePath } : {}), + }; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index baa3fea4f4a3866c9dd5e156980d89a357964e65..0de3ae3c4e7eaa62816048e3cf971aa30e84cea5 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -9,12 +9,14 @@ import "@xterm/xterm/css/xterm.css"; import "highlight.js/styles/github-dark.css"; import "./index.css"; +import { APP_DISPLAY_NAME } from "./branding"; import { isElectron } from "./env"; import { routeTree } from "./routeTree.gen"; const history = isElectron ? createHashHistory() : createBrowserHistory(); const queryClient = new QueryClient(); +document.title = APP_DISPLAY_NAME; const router = createRouter({ routeTree, diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a005a05e5ac23fb035c653563cd788382b8edc1f..880d5ef64ba441142945fdfe91d85a9e897d7dbe 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -11,6 +11,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' const ChatRoute = ChatRouteImport.update({ @@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const ChatSettingsRoute = ChatSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => ChatRoute, +} as any) const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ id: '/$threadId', path: '/$threadId', @@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof ChatIndexRoute '/$threadId': typeof ChatThreadIdRoute + '/settings': typeof ChatSettingsRoute } export interface FileRoutesByTo { '/$threadId': typeof ChatThreadIdRoute + '/settings': typeof ChatSettingsRoute '/': typeof ChatIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/_chat': typeof ChatRouteWithChildren '/_chat/$threadId': typeof ChatThreadIdRoute + '/_chat/settings': typeof ChatSettingsRoute '/_chat/': typeof ChatIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/$threadId' + fullPaths: '/' | '/$threadId' | '/settings' fileRoutesByTo: FileRoutesByTo - to: '/$threadId' | '/' - id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/' + to: '/$threadId' | '/settings' | '/' + id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/_chat/settings': { + id: '/_chat/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof ChatSettingsRouteImport + parentRoute: typeof ChatRoute + } '/_chat/$threadId': { id: '/_chat/$threadId' path: '/$threadId' @@ -82,11 +98,13 @@ declare module '@tanstack/react-router' { interface ChatRouteChildren { ChatThreadIdRoute: typeof ChatThreadIdRoute + ChatSettingsRoute: typeof ChatSettingsRoute ChatIndexRoute: typeof ChatIndexRoute } const ChatRouteChildren: ChatRouteChildren = { ChatThreadIdRoute: ChatThreadIdRoute, + ChatSettingsRoute: ChatSettingsRoute, ChatIndexRoute: ChatIndexRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index bfac06dff13a5ce399d898691c917c9d5f68d3db..78b887c6fec38cb6983a6fefa94925f80d1fc81b 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery"; import { DEFAULT_MODEL } from "../model-logic"; import { useStore } from "../store"; import { onServerWelcome } from "../wsNativeApi"; +import { APP_DISPLAY_NAME } from "../branding"; export const Route = createRootRouteWithContext<{ queryClient: QueryClient; @@ -23,7 +24,9 @@ function RootRouteView() { return (
-

Connecting to T3 Code server...

+

+ Connecting to {APP_DISPLAY_NAME} server... +

); diff --git a/apps/web/src/routes/_chat.$threadId.tsx b/apps/web/src/routes/_chat.$threadId.tsx index 066f657ea332294601206c030cfa1f4cc06edccc..8bb8aa389cfdcc7483ce6286eb1c28a335a4cfd0 100644 --- a/apps/web/src/routes/_chat.$threadId.tsx +++ b/apps/web/src/routes/_chat.$threadId.tsx @@ -1,14 +1,67 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react"; import ChatView from "../components/ChatView"; +import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { useStore } from "../store"; +import { Sheet, SheetPopup } from "../components/ui/sheet"; + +const DiffPanel = lazy(() => import("../components/DiffPanel")); +const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; + +const DiffPanelWrapper = (props: { + children: ReactNode; + sheet: boolean; + diffOpen: boolean; + onCloseDiff: () => void; +}) => { + if (props.sheet) { + return ( + { + if (!open) { + props.onCloseDiff(); + } + }} + > + + {props.children} + + + ); + } + + return ( + + ); +}; function ChatThreadRouteView() { const { state } = useStore(); const navigate = useNavigate(); const { threadId } = Route.useParams(); + const search = Route.useSearch(); const threadExists = threadId ? state.threads.some((thread) => thread.id === threadId) : false; + const diffOpen = search.diff === "1"; + const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const closeDiff = useCallback(() => { + void navigate({ + to: "/$threadId", + params: { threadId }, + search: (previous) => { + return stripDiffSearchParams(previous); + }, + }); + }, [navigate, threadId]); useEffect(() => { if (!threadId) { @@ -30,9 +83,32 @@ function ChatThreadRouteView() { return null; } - return ; + const diffLoadingFallback = + !diffOpen || shouldUseDiffSheet ? ( +
+ Loading diff viewer... +
+ ) : ( + + ); + + return ( + <> + + + + + + + + + + ); } export const Route = createFileRoute("/_chat/$threadId")({ + validateSearch: (search) => parseDiffRouteSearch(search), component: ChatThreadRouteView, }); diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..740707addd9e71f622f499025f2ce1e8e680736c --- /dev/null +++ b/apps/web/src/routes/_chat.settings.tsx @@ -0,0 +1,259 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { useAppSettings } from "../appSettings"; +import { isElectron } from "../env"; +import { useTheme } from "../hooks/useTheme"; +import { useStore } from "../store"; +import { Button } from "../components/ui/button"; +import { Input } from "../components/ui/input"; +import { Switch } from "../components/ui/switch"; + +const THEME_OPTIONS = [ + { + value: "system", + label: "System", + description: "Match your OS appearance setting.", + }, + { + value: "light", + label: "Light", + description: "Always use the light theme.", + }, + { + value: "dark", + label: "Dark", + description: "Always use the dark theme.", + }, +] as const; + +const RUNTIME_MODE_OPTIONS = [ + { + value: "full-access", + label: "Full access", + description: "Allow commands to run without confirmation prompts.", + }, + { + value: "approval-required", + label: "Supervised", + description: "Require approval prompts before command execution.", + }, +] as const; + +function SettingsRouteView() { + const { theme, setTheme, resolvedTheme } = useTheme(); + const { state, dispatch } = useStore(); + const { settings, defaults, updateSettings } = useAppSettings(); + + const codexBinaryPath = settings.codexBinaryPath; + const codexHomePath = settings.codexHomePath; + + return ( +
+ {isElectron && ( +
+ Settings +
+ )} + +
+
+
+

Settings

+

+ Configure app-level preferences for this device. +

+
+ +
+
+

Appearance

+

+ Choose how T3 Code handles light and dark mode. +

+
+ +
+ {THEME_OPTIONS.map((option) => { + const selected = theme === option.value; + return ( + + ); + })} +
+ +

+ Active theme: {resolvedTheme} +

+
+ +
+
+

Codex App Server

+

+ These overrides apply to new sessions and let you use a non-default Codex install. +

+
+ +
+ + + + +
+

+ Binary source: {codexBinaryPath || "PATH"} +

+ +
+
+
+ +
+
+

Runtime Mode

+

+ Select the default execution policy for this client. +

+
+ +
+ {RUNTIME_MODE_OPTIONS.map((option) => { + const selected = state.runtimeMode === option.value; + return ( + + ); + })} +
+
+ +
+
+

Safety

+

+ Additional guardrails for destructive local actions. +

+
+ +
+
+

Confirm thread deletion

+

+ Ask for confirmation before deleting a thread and its chat history. +

+
+ + updateSettings({ + confirmThreadDelete: Boolean(checked), + }) + } + aria-label="Confirm thread deletion" + /> +
+ + {settings.confirmThreadDelete !== defaults.confirmThreadDelete ? ( +
+ +
+ ) : null} +
+
+
+
+ ); +} + +export const Route = createFileRoute("/_chat/settings")({ + component: SettingsRouteView, +}); diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 702e873b07fdf761c80121b9de3378e86257da4e..36c14455986a5adbbb5309100b92eeb1e85d03a2 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,78 +1,34 @@ -import { Outlet, createFileRoute } from "@tanstack/react-router"; -import { Activity, Suspense, lazy, type ReactNode } from "react"; +import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider"; import Sidebar from "../components/Sidebar"; -import { useMediaQuery } from "../hooks/useMediaQuery"; -import { useStore } from "../store"; -import { Sheet, SheetPopup } from "../components/ui/sheet"; - -const DiffPanel = lazy(() => import("../components/DiffPanel")); -const DiffWorkerPoolProvider = lazy(() => - import("../components/DiffPanel").then((module) => ({ - default: module.DiffWorkerPoolProvider, - })), -); -const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)"; - -const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => { - const { state, dispatch } = useStore(); - if (props.sheet) { - return ( - { - if (!open) { - dispatch({ type: "CLOSE_DIFF" }); - } - }} - > - - {props.children} - - - ); - } - - return ( - - ); -}; function ChatRouteLayout() { - const { state } = useStore(); - const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY); + const navigate = useNavigate(); - const diffLoadingFallback = - !state.diffOpen || shouldUseDiffSheet ? ( -
- Loading diff viewer... -
- ) : ( - - ); + useEffect(() => { + const onMenuAction = window.desktopBridge?.onMenuAction; + if (typeof onMenuAction !== "function") { + return; + } + + const unsubscribe = onMenuAction((action) => { + if (action !== "open-settings") return; + void navigate({ to: "/settings" }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); return (
- - - - - - - - - - + + +
); } @@ -80,3 +36,4 @@ function ChatRouteLayout() { export const Route = createFileRoute("/_chat")({ component: ChatRouteLayout, }); + diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 5b3c7a81117c465e86caa5a4ad7579b87c0886aa..6acd1e2859ec7cc23ea4849ae71cfe99fc167258 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState { threads: [thread], threadsHydrated: true, runtimeMode: "full-access", - diffOpen: false, - diffThreadId: null, - diffTurnId: null, - diffFilePath: null, }; } @@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => { expect(next.threads[0]?.terminalOpen).toBe(true); }); - it("opens diff panel with an explicit turn/file target", () => { - const state = makeState(makeThread()); - const next = reducer(state, { - type: "OPEN_DIFF", - threadId: "thread-local-1", - turnId: "turn-1", - filePath: "src/app.ts", - }); - - expect(next.diffOpen).toBe(true); - expect(next.diffThreadId).toBe("thread-local-1"); - expect(next.diffTurnId).toBe("turn-1"); - expect(next.diffFilePath).toBe("src/app.ts"); - }); - it("sets terminal open state per thread", () => { const state = makeState(makeThread({ terminalOpen: true })); const next = reducer(state, { @@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => { ], threadsHydrated: true, runtimeMode: "full-access", - diffOpen: true, - diffThreadId: "thread-b", - diffTurnId: "turn-b", - diffFilePath: "src/removed.ts", }; const next = reducer(state, { @@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => { expect(next.threads).toHaveLength(1); expect(next.threads[0]?.id).toBe("thread-a"); expect(next.threads[0]?.projectId).toBe("project-new-a"); - expect(next.diffOpen).toBe(false); - expect(next.diffThreadId).toBeNull(); - expect(next.diffTurnId).toBeNull(); - expect(next.diffFilePath).toBeNull(); }); it("treats empty scripts from sync as authoritative", () => { @@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => { threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })], threadsHydrated: true, runtimeMode: "full-access", - diffOpen: false, - diffThreadId: null, - diffTurnId: null, - diffFilePath: null, }; const next = reducer(state, { @@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => { ], threadsHydrated: true, runtimeMode: "full-access", - diffOpen: true, - diffThreadId: "thread-a", - diffTurnId: "turn-a", - diffFilePath: "src/a.ts", }; const next = reducer(state, { @@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => { expect(next.projects[0]?.id).toBe("project-2"); expect(next.threads).toHaveLength(1); expect(next.threads[0]?.id).toBe("thread-b"); - expect(next.diffOpen).toBe(false); - expect(next.diffThreadId).toBeNull(); - expect(next.diffTurnId).toBeNull(); - expect(next.diffFilePath).toBeNull(); }); it("marks completion as seen immediately for the active thread", () => { diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f470a8c83bd5dba8c8eaa3a850e3e6e7c8bc60ab..cb45b5506cdcde65f101eb4f1ac0a5dcf331e3ae 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -48,20 +48,6 @@ type Action = | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string } | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string } | { type: "CLOSE_THREAD_TERMINAL"; threadId: string; terminalId: string } - | { type: "TOGGLE_DIFF" } - | { - type: "OPEN_DIFF"; - threadId: string; - turnId?: string; - filePath?: string; - } - | { - type: "SET_DIFF_TARGET"; - threadId: string; - turnId?: string; - filePath?: string; - } - | { type: "CLOSE_DIFF" } | { type: "APPLY_EVENT"; event: ProviderEvent; @@ -110,10 +96,6 @@ export interface AppState { threads: Thread[]; threadsHydrated: boolean; runtimeMode: RuntimeMode; - diffOpen: boolean; - diffThreadId: string | null; - diffTurnId: string | null; - diffFilePath: string | null; } const PERSISTED_STATE_KEY = "t3code:renderer-state:v7"; @@ -133,10 +115,6 @@ const initialState: AppState = { threads: [], threadsHydrated: false, runtimeMode: DEFAULT_RUNTIME_MODE, - diffOpen: false, - diffThreadId: null, - diffTurnId: null, - diffFilePath: null, }; // ── Helpers ────────────────────────────────────────────────────────── @@ -160,10 +138,6 @@ function readPersistedState(): AppState { ...hydrated, threads, threadsHydrated: threads.length > 0, - diffOpen: false, - diffThreadId: null, - diffTurnId: null, - diffFilePath: null, }; } catch { return initialState; @@ -191,32 +165,6 @@ function updateThread( return threads.map((t) => (t.id === threadId ? updater(t) : t)); } -function resetDiffTargetIfMissing(state: AppState, threads: Thread[]) { - if (!state.diffThreadId) { - return { - diffOpen: state.diffOpen, - diffThreadId: state.diffThreadId, - diffTurnId: state.diffTurnId, - diffFilePath: state.diffFilePath, - }; - } - const hasThread = threads.some((thread) => thread.id === state.diffThreadId); - if (hasThread) { - return { - diffOpen: state.diffOpen, - diffThreadId: state.diffThreadId, - diffTurnId: state.diffTurnId, - diffFilePath: state.diffFilePath, - }; - } - return { - diffOpen: false, - diffThreadId: null, - diffTurnId: null, - diffFilePath: null, - }; -} - function mergeTurnDiffSummaries( existing: Thread["turnDiffSummaries"], next: Thread["turnDiffSummaries"], @@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState { }); }) .filter((thread): thread is Thread => thread !== null); - const diffState = resetDiffTargetIfMissing(state, nextThreads); return { ...state, projects: nextProjects, threads: nextThreads, - ...diffState, }; } @@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState { } const threads = state.threads.filter((thread) => thread.projectId !== action.projectId); - const diffState = resetDiffTargetIfMissing(state, threads); return { ...state, projects, threads, - ...diffState, }; } @@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState { ), }; - case "TOGGLE_DIFF": - return { ...state, diffOpen: !state.diffOpen }; - - case "OPEN_DIFF": - return { - ...state, - diffOpen: true, - diffThreadId: action.threadId, - diffTurnId: action.turnId ?? null, - diffFilePath: action.filePath ?? null, - }; - - case "SET_DIFF_TARGET": - return { - ...state, - diffThreadId: action.threadId, - diffTurnId: action.turnId ?? null, - diffFilePath: action.filePath ?? null, - }; - - case "CLOSE_DIFF": - return { ...state, diffOpen: false }; - case "APPLY_TERMINAL_EVENT": if (!state.threads.some((thread) => thread.id === action.event.threadId)) { return state; @@ -1093,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState { runtimeMode: action.mode, }; - case "DELETE_THREAD": { - const threads = state.threads.filter((t) => t.id !== action.threadId); - const diffState = resetDiffTargetIfMissing(state, threads); + case "DELETE_THREAD": return { ...state, - threads, - ...diffState, + threads: state.threads.filter((t) => t.id !== action.threadId), }; - } default: return state; diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts index 3438d5d7b236ef604eb8111f1a531ddaefd8ea47..93076c718b369f48f230c54b31ed5d9ff033f994 100644 --- a/apps/web/src/vite-env.d.ts +++ b/apps/web/src/vite-env.d.ts @@ -8,6 +8,7 @@ interface DesktopBridge { confirm: (message: string) => Promise; showContextMenu: (items: readonly { id: string; label: string }[]) => Promise; openExternal: (url: string) => Promise; + onMenuAction: (listener: (action: string) => void) => () => void; } declare global { diff --git a/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..f01a7b8813dfa98e6c7eed39fe8e5d0eb923b97e Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ diff --git a/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..8dba03e01fe1bb2c33d82836236899cd47016ca4 Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ diff --git a/assets/T3 Code logo.svg b/assets/T3 Code logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/assets/T3 Code logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/T3 Code logo@16x.png b/assets/T3 Code logo@16x.png new file mode 100644 index 0000000000000000000000000000000000000000..87d9f008c687041b72ccaab2833e0d17915b828c Binary files /dev/null and b/assets/T3 Code logo@16x.png differ diff --git a/assets/T3 Code logo@32x.png b/assets/T3 Code logo@32x.png new file mode 100644 index 0000000000000000000000000000000000000000..d75373862e46463a3990e86ff110e8b65dc1fa7d Binary files /dev/null and b/assets/T3 Code logo@32x.png differ diff --git a/assets/T3 Code logo@8x.png b/assets/T3 Code logo@8x.png new file mode 100644 index 0000000000000000000000000000000000000000..88317dd41fbabe19ea69d8ba0308ed08bca12887 Binary files /dev/null and b/assets/T3 Code logo@8x.png differ diff --git a/assets/T3 Code-macOS-Default-1024x1024@1x.png b/assets/T3 Code-macOS-Default-1024x1024@1x.png new file mode 100644 index 0000000000000000000000000000000000000000..0a6e1cbfcff3433a3a96d97f3b7431292e1a96c1 Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ diff --git a/assets/T3 Code-macOS-Default-1024x1024@4x.png b/assets/T3 Code-macOS-Default-1024x1024@4x.png new file mode 100644 index 0000000000000000000000000000000000000000..d426376b135c9ffaf9c3042d7b7392c8408438a0 Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ diff --git a/assets/logo-1024.png b/assets/logo-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..88317dd41fbabe19ea69d8ba0308ed08bca12887 Binary files /dev/null and b/assets/logo-1024.png differ diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..61b48db5af1a537cabab28a692c702b39a046e79 --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/macos-icon-1024.png b/assets/macos-icon-1024.png new file mode 100644 index 0000000000000000000000000000000000000000..0a6e1cbfcff3433a3a96d97f3b7431292e1a96c1 Binary files /dev/null and b/assets/macos-icon-1024.png differ diff --git a/package.json b/package.json index de8469e2c903b885521f05ddd0b55071f9a2cbc5..23e401648c212b9763e1aab2a821fad8951f10cb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,9 @@ "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop", "fmt": "oxfmt", "build:contracts": "turbo run build --filter=@t3tools/contracts", + "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs", + "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64", + "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64", "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs" }, diff --git a/packages/contracts/src/provider.test.ts b/packages/contracts/src/provider.test.ts index df9c8e9b3d83f41dab157301e068cd6be519f516..15910a432b3bc51f29082749f2b51500f3a0f852 100644 --- a/packages/contracts/src/provider.test.ts +++ b/packages/contracts/src/provider.test.ts @@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => { }), ).toThrow(); }); + + it("accepts optional codex binary and home path overrides", () => { + const parsed = providerSessionStartInputSchema.parse({ + codexBinaryPath: "/opt/codex/bin/codex", + codexHomePath: "/Users/theo/.codex", + }); + expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex"); + expect(parsed.codexHomePath).toBe("/Users/theo/.codex"); + }); + + it("rejects blank codex overrides", () => { + expect(() => + providerSessionStartInputSchema.parse({ + codexBinaryPath: " ", + }), + ).toThrow(); + expect(() => + providerSessionStartInputSchema.parse({ + codexHomePath: " ", + }), + ).toThrow(); + }); }); describe("providerSendTurnInputSchema", () => { diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 65877a6a11fda30af49afad943263dcbe321bc23..06a7582a8b2de5d8ef3afde0e780b0130ccd56a1 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({ cwd: z.string().min(1).optional(), model: z.string().trim().min(1).optional(), resumeThreadId: z.string().trim().min(1).optional(), + codexBinaryPath: z.string().trim().min(1).optional(), + codexHomePath: z.string().trim().min(1).optional(), approvalPolicy: providerApprovalPolicySchema.default("never"), sandboxMode: providerSandboxModeSchema.default("workspace-write"), }); diff --git a/scripts/build-desktop-dmg.mjs b/scripts/build-desktop-dmg.mjs new file mode 100644 index 0000000000000000000000000000000000000000..ffbd44dc44d17bc9fc48dd08decae699eec77d38 --- /dev/null +++ b/scripts/build-desktop-dmg.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(__dirname, ".."); +const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png"); + +const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]); + +function printUsage() { + console.log(`Build a shareable macOS .dmg for T3 Code desktop. + +Usage: + node scripts/build-desktop-dmg.mjs [options] + +Options: + --arch Target arch (default: host arch if supported) + --version App version in DMG metadata (default: apps/server version) + --output-dir Output folder for final artifacts (default: release) + --skip-build Skip 'bun run build:desktop' (requires existing dist artifacts) + --keep-stage Keep temporary staging directory + --signed Allow automatic code signing discovery + --help Show this message +`); +} + +function fail(message) { + console.error(`[desktop-dmg] ${message}`); + process.exit(1); +} + +function parseArgs(argv) { + const defaultArch = process.arch === "arm64" || process.arch === "x64" ? process.arch : "arm64"; + const options = { + arch: defaultArch, + version: null, + skipBuild: false, + keepStage: false, + signed: false, + outputDir: path.join(repoRoot, "release"), + }; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (arg === "--help") { + options.help = true; + continue; + } + if (arg === "--skip-build") { + options.skipBuild = true; + continue; + } + if (arg === "--keep-stage") { + options.keepStage = true; + continue; + } + if (arg === "--signed") { + options.signed = true; + continue; + } + if (arg === "--arch" || arg === "--version" || arg === "--output-dir") { + const value = argv[i + 1]; + if (!value || value.startsWith("--")) { + fail(`Missing value for ${arg}`); + } + i += 1; + if (arg === "--arch") options.arch = value; + if (arg === "--version") options.version = value; + if (arg === "--output-dir") options.outputDir = path.resolve(repoRoot, value); + continue; + } + fail(`Unknown argument: ${arg}`); + } + + if (!ARCH_CHOICES.has(options.arch)) { + fail(`Invalid arch '${options.arch}'. Expected one of: ${Array.from(ARCH_CHOICES).join(", ")}`); + } + + return options; +} + +async function readJson(filePath) { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw); +} + +async function exists(filePath) { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +function run(command, args, { cwd, env = process.env, stdio = "inherit" } = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd, + env, + stdio, + }); + + child.on("error", reject); + child.on("exit", (code, signal) => { + if (signal) { + reject(new Error(`${command} exited via signal ${signal}`)); + return; + } + if (code !== 0) { + reject(new Error(`${command} exited with code ${code}`)); + return; + } + resolve(); + }); + }); +} + +async function generateMacIconSet(sourcePng, targetIcns, tmpRoot) { + const iconsetDir = path.join(tmpRoot, "icon.iconset"); + await fs.mkdir(iconsetDir, { recursive: true }); + + const iconSizes = [16, 32, 128, 256, 512]; + for (const size of iconSizes) { + await run( + "sips", + ["-z", String(size), String(size), sourcePng, "--out", path.join(iconsetDir, `icon_${size}x${size}.png`)], + { stdio: "ignore" }, + ); + const retinaSize = size * 2; + await run( + "sips", + [ + "-z", + String(retinaSize), + String(retinaSize), + sourcePng, + "--out", + path.join(iconsetDir, `icon_${size}x${size}@2x.png`), + ], + { stdio: "ignore" }, + ); + } + + await run("iconutil", ["-c", "icns", iconsetDir, "-o", targetIcns], { stdio: "ignore" }); +} + +async function stageProductionIcons(stageResourcesDir) { + if (!(await exists(PRODUCTION_ICON_SOURCE))) { + fail(`Production icon source is missing at ${PRODUCTION_ICON_SOURCE}`); + } + + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "t3code-icon-build-")); + try { + const iconPngPath = path.join(stageResourcesDir, "icon.png"); + const iconIcnsPath = path.join(stageResourcesDir, "icon.icns"); + await run("sips", ["-z", "512", "512", PRODUCTION_ICON_SOURCE, "--out", iconPngPath], { + stdio: "ignore", + }); + await generateMacIconSet(PRODUCTION_ICON_SOURCE, iconIcnsPath, tmpRoot); + } finally { + await fs.rm(tmpRoot, { recursive: true, force: true }); + } +} + +async function validateBundledClientAssets(clientDir) { + const indexPath = path.join(clientDir, "index.html"); + const indexHtml = await fs.readFile(indexPath, "utf8"); + const refs = [...indexHtml.matchAll(/\b(?:src|href)=["']([^"']+)["']/g)].map((match) => match[1]); + const missing = []; + + for (const ref of refs) { + const normalizedRef = ref.split("#")[0]?.split("?")[0] ?? ""; + if (!normalizedRef) continue; + if (normalizedRef.startsWith("http://") || normalizedRef.startsWith("https://")) continue; + if (normalizedRef.startsWith("data:") || normalizedRef.startsWith("mailto:")) continue; + + const ext = path.extname(normalizedRef); + if (!ext) continue; + + const relativePath = normalizedRef.replace(/^\/+/, ""); + const assetPath = path.join(clientDir, relativePath); + if (!(await exists(assetPath))) { + missing.push(normalizedRef); + } + } + + if (missing.length > 0) { + const preview = missing.slice(0, 6).join(", "); + const suffix = missing.length > 6 ? ` (+${missing.length - 6} more)` : ""; + fail( + `Bundled client references missing files in ${indexPath}: ${preview}${suffix}. Rebuild web/server artifacts.`, + ); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printUsage(); + return; + } + + if (process.platform !== "darwin") { + fail("This script only builds macOS .dmg artifacts and must run on macOS."); + } + + const desktopPkg = await readJson(path.join(repoRoot, "apps/desktop/package.json")); + const serverPkg = await readJson(path.join(repoRoot, "apps/server/package.json")); + + const electronVersion = desktopPkg.dependencies?.electron; + if (typeof electronVersion !== "string" || electronVersion.length === 0) { + fail("Could not resolve apps/desktop electron dependency version."); + } + + const dependencies = serverPkg.dependencies; + if (!dependencies || Object.keys(dependencies).length === 0) { + fail("Could not resolve production dependencies from apps/server/package.json."); + } + + const appVersion = options.version ?? serverPkg.version ?? "0.1.0"; + const stageRoot = path.join(os.tmpdir(), "t3code-desktop-dmg-stage"); + const stageAppDir = path.join(stageRoot, "app"); + const distDirs = { + desktopDist: path.join(repoRoot, "apps/desktop/dist-electron"), + desktopResources: path.join(repoRoot, "apps/desktop/resources"), + serverDist: path.join(repoRoot, "apps/server/dist"), + }; + const bundledClientEntry = path.join(distDirs.serverDist, "client/index.html"); + + if (!options.skipBuild) { + console.log("[desktop-dmg] Building desktop/server/web artifacts..."); + await run("bun", ["run", "build:desktop"], { cwd: repoRoot }); + } + + for (const [label, dir] of Object.entries(distDirs)) { + if (!(await exists(dir))) { + fail(`Missing ${label} at ${dir}. Run 'bun run build:desktop' first.`); + } + } + if (!(await exists(bundledClientEntry))) { + fail( + `Missing bundled server client at ${bundledClientEntry}. Run 'bun run build:desktop' first.`, + ); + } + await validateBundledClientAssets(path.dirname(bundledClientEntry)); + + await fs.rm(stageRoot, { recursive: true, force: true }); + await fs.mkdir(path.join(stageAppDir, "apps/desktop"), { recursive: true }); + await fs.mkdir(path.join(stageAppDir, "apps/server"), { recursive: true }); + + console.log("[desktop-dmg] Staging release app..."); + await fs.cp(distDirs.desktopDist, path.join(stageAppDir, "apps/desktop/dist-electron"), { + recursive: true, + }); + await fs.cp(distDirs.desktopResources, path.join(stageAppDir, "apps/desktop/resources"), { + recursive: true, + }); + await fs.cp(distDirs.serverDist, path.join(stageAppDir, "apps/server/dist"), { recursive: true }); + await stageProductionIcons(path.join(stageAppDir, "apps/desktop/resources")); + + const stagePackageJson = { + name: "t3-code-desktop", + version: appVersion, + private: true, + description: "T3 Code desktop build", + author: "T3 Tools", + main: "apps/desktop/dist-electron/main.js", + build: { + appId: "com.t3tools.t3code", + productName: desktopPkg.productName ?? "T3 Code", + artifactName: "T3-Code-${version}-${arch}.${ext}", + directories: { + buildResources: "apps/desktop/resources", + }, + mac: { + target: ["dmg"], + icon: "icon.icns", + category: "public.app-category.developer-tools", + }, + }, + dependencies, + devDependencies: { + electron: electronVersion, + }, + }; + + await fs.writeFile( + path.join(stageAppDir, "package.json"), + `${JSON.stringify(stagePackageJson, null, 2)}\n`, + "utf8", + ); + + console.log("[desktop-dmg] Installing staged production dependencies..."); + await run("bun", ["install", "--production"], { cwd: stageAppDir }); + + const buildEnv = { + ...process.env, + }; + if (!options.signed) { + buildEnv.CSC_IDENTITY_AUTO_DISCOVERY = "false"; + } + + const buildArgs = ["--bun", "electron-builder", "--mac", "dmg", `--${options.arch}`]; + console.log(`[desktop-dmg] Building .dmg (arch=${options.arch}, version=${appVersion})...`); + await run("bunx", buildArgs, { cwd: stageAppDir, env: buildEnv }); + + const stageDistDir = path.join(stageAppDir, "dist"); + const stageFiles = await fs.readdir(stageDistDir); + const dmgFiles = stageFiles.filter((file) => file.endsWith(".dmg")); + if (dmgFiles.length === 0) { + fail(`Build completed but no .dmg found in ${stageDistDir}`); + } + + await fs.mkdir(options.outputDir, { recursive: true }); + const copiedArtifacts = []; + for (const file of stageFiles) { + if (!file.endsWith(".dmg") && !file.endsWith(".dmg.blockmap")) continue; + const from = path.join(stageDistDir, file); + const to = path.join(options.outputDir, file); + await fs.copyFile(from, to); + copiedArtifacts.push(to); + } + + if (!options.keepStage) { + await fs.rm(stageRoot, { recursive: true, force: true }); + } + + console.log("[desktop-dmg] Done. Artifacts:"); + for (const artifact of copiedArtifacts) { + console.log(` - ${artifact}`); + } +} + +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + fail(message); +});