authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 15:48:48-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 15:48:48-08:00
loga002239fc40750ca04fe1c890cd5b9395cceb89b
tree81d2d4d6623a037ccb97cf048f933bf2f747cf4e
parentf3b773f1f0eca53ff6e5a70df4bcb5ae3eb1ffd4
parent70a13c298f50be8c620020708d8b1db6ae80557e

Merge origin/main into codething/c71d8ea1

Co-authored-by: codex <codex@users.noreply.github.com>

52 files changed, 1682 insertions(+), 321 deletions(-)

.gitignore+1
......@@ -10,3 +10,4 @@ packages/*/dist
1010.env.local
1111build/
1212.logs/
13release/
README.md+14
......@@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop
5656bun run build
5757bun run start
5858
59# Build a shareable macOS .dmg (arm64 by default)
60bun run dist:desktop:dmg
61
5962# Or from any project directory after publishing:
6063npx t3
6164```
......@@ -69,6 +72,17 @@ npx t3
6972- `bun run build` — Builds contracts, web app, and server through Turbo.
7073- `bun run typecheck` — Strict TypeScript checks for all packages.
7174- `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`
7286
7387### Running multiple dev instances
7488
TODO.md+3-1
......@@ -2,6 +2,8 @@
22
33## Small things
44
5- [ ] Plan mode
6- [ ] Submitting new messages should scroll to bottom
57- [ ] 1 click copy in codeblocks
68- [x] Pasting/adding images to submit with prompts
79- [ ] Only show last 10 threads for a given project
......@@ -12,7 +14,7 @@
1214- [x] "Response" bar should ONLY appear on work completed
1315- [x] "Response" bar should ONLY appear above the FINAL message in a series
1416- [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
1618- [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it
1719- [x] Making new thread should auto-focus input box
1820- [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 @@
11dist-electron/
2.electron-runtime/
apps/desktop/package.json+2-1
......@@ -2,13 +2,14 @@
22 "name": "@t3tools/desktop",
33 "version": "0.0.0",
44 "private": true,
5 "productName": "T3 Code (Alpha)",
56 "main": "dist-electron/main.js",
67 "scripts": {
78 "dev": "bun run --parallel dev:bundle dev:electron",
89 "dev:bundle": "tsdown --watch",
910 "dev:electron": "bun run scripts/dev-electron.mjs",
1011 "build": "tsdown",
11 "start": "electron dist-electron/main.js",
12 "start": "bun run scripts/start-electron.mjs",
1213 "typecheck": "tsc --noEmit",
1314 "test": "vitest run --passWithNoTests",
1415 "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 @@
1import { spawn } from "node:child_process";
1import { spawnSync } from "node:child_process";
22
3import electronmon from "electronmon";
34import waitOn from "wait-on";
45
6import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
7
58const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
69const devServerUrl = `http://localhost:${port}`;
710
......@@ -14,18 +17,57 @@ await waitOn({
1417 ],
1518});
1619
17const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon";
1820const childEnv = { ...process.env };
1921delete childEnv.ELECTRON_RUN_AS_NODE;
2022
21const child = spawn(command, ["dist-electron/main.js"], {
22 stdio: "inherit",
23const monitor = await electronmon({
24 cwd: desktopDir,
25 args: ["dist-electron/main.js"],
2326 env: {
2427 ...childEnv,
2528 VITE_DEV_SERVER_URL: devServerUrl,
2629 },
30 electronPath: resolveElectronPath(),
2731});
2832
29child.on("exit", (code) => {
30 process.exit(code ?? 0);
33let shuttingDown = false;
34
35function 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
44async 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
65process.once("SIGINT", () => {
66 void shutdown(130);
67});
68process.once("SIGTERM", () => {
69 void shutdown(143);
70});
71process.once("SIGHUP", () => {
72 void shutdown(129);
3173});
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
3import { spawnSync } from "node:child_process";
4import {
5 copyFileSync,
6 cpSync,
7 existsSync,
8 mkdirSync,
9 readFileSync,
10 readdirSync,
11 rmSync,
12 statSync,
13 writeFileSync,
14} from "node:fs";
15import { createRequire } from "node:module";
16import { dirname, join, resolve } from "node:path";
17import { fileURLToPath } from "node:url";
18
19const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
20const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
21const APP_BUNDLE_ID = "com.t3tools.t3code";
22const LAUNCHER_VERSION = 1;
23
24const __dirname = dirname(fileURLToPath(import.meta.url));
25export const desktopDir = resolve(__dirname, "..");
26
27function 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
58function 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
70function 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
116function readJson(path) {
117 try {
118 return JSON.parse(readFileSync(path, "utf8"));
119 } catch {
120 return null;
121 }
122}
123
124function 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
164export 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 @@
1import { spawn } from "node:child_process";
2
3import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
4
5const childEnv = { ...process.env };
6delete childEnv.ELECTRON_RUN_AS_NODE;
7
8const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], {
9 stdio: "inherit",
10 cwd: desktopDir,
11 env: childEnv,
12});
13
14child.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";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
8import {
9 app,
10 BrowserWindow,
11 dialog,
12 ipcMain,
13 Menu,
14 type MenuItemConstructorOptions,
15 protocol,
16 shell,
17} from "electron";
918
1019import { showDesktopConfirmDialog } from "./confirmDialog";
1120import { fixPath } from "./fixPath";
......@@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1625const CONFIRM_CHANNEL = "desktop:confirm";
1726const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1827const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
19const ROOT_DIR = path.resolve(__dirname, "../../..");
20const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
21const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
28const MENU_ACTION_CHANNEL = "desktop:menu-action";
2229const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
30const DESKTOP_SCHEME = "t3";
31const ROOT_DIR = path.resolve(__dirname, "../../..");
2332const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
33const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
34const APP_USER_MODEL_ID = "com.t3tools.t3code";
2435
2536let mainWindow: BrowserWindow | null = null;
2637let backendProcess: ChildProcess | null = null;
......@@ -30,6 +41,235 @@ let backendWsUrl = "";
3041let restartAttempt = 0;
3142let restartTimer: ReturnType<typeof setTimeout> | null = null;
3243let isQuitting = false;
44let desktopProtocolRegistered = false;
45
46protocol.registerSchemesAsPrivileged([
47 {
48 scheme: DESKTOP_SCHEME,
49 privileges: {
50 standard: true,
51 secure: true,
52 supportFetchAPI: true,
53 corsEnabled: true,
54 },
55 },
56]);
57
58function resolveAppRoot(): string {
59 if (!app.isPackaged) {
60 return ROOT_DIR;
61 }
62 return app.getAppPath();
63}
64
65function resolveBackendEntry(): string {
66 return path.join(resolveAppRoot(), "apps/server/dist/index.mjs");
67}
68
69function resolveBackendCwd(): string {
70 if (!app.isPackaged) {
71 return resolveAppRoot();
72 }
73 return os.homedir();
74}
75
76function 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
92function 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
115function 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
124function 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
162function 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
186function 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
236function 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
252function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
253 return resolveResourcePath(`icon.${ext}`);
254}
255
256function 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}
33273
34274async function reserveLoopbackPort(): Promise<number> {
35275 return new Promise((resolve, reject) => {
......@@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void {
76316function startBackend(): void {
77317 if (isQuitting || backendProcess) return;
78318
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}`);
81322 return;
82323 }
83324
84 const child = spawn(process.execPath, [BACKEND_ENTRY], {
85 cwd: ROOT_DIR,
325 const child = spawn(process.execPath, [backendEntry], {
326 cwd: resolveBackendCwd(),
86327 // In Electron main, process.execPath points to the Electron binary.
87328 // Run the child in Node mode so this backend process does not become a GUI app instance.
88329 env: {
......@@ -124,10 +365,10 @@ function stopBackend(): void {
124365 backendProcess = null;
125366 if (!child) return;
126367
127 if (!child.killed) {
368 if (child.exitCode === null && child.signalCode === null) {
128369 child.kill("SIGTERM");
129370 setTimeout(() => {
130 if (!child.killed) {
371 if (child.exitCode === null && child.signalCode === null) {
131372 child.kill("SIGKILL");
132373 }
133374 }, 2_000).unref();
......@@ -204,6 +445,13 @@ function registerIpcHandlers(): void {
204445 });
205446}
206447
448function 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
207455function createWindow(): BrowserWindow {
208456 const window = new BrowserWindow({
209457 width: 1100,
......@@ -212,6 +460,8 @@ function createWindow(): BrowserWindow {
212460 minHeight: 620,
213461 show: false,
214462 autoHideMenuBar: true,
463 ...getIconOption(),
464 title: APP_DISPLAY_NAME,
215465 titleBarStyle: "hiddenInset",
216466 trafficLightPosition: { x: 16, y: 18 },
217467 webPreferences: {
......@@ -223,6 +473,13 @@ function createWindow(): BrowserWindow {
223473 });
224474
225475 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 });
226483 window.once("ready-to-show", () => {
227484 window.show();
228485 });
......@@ -231,10 +488,7 @@ function createWindow(): BrowserWindow {
231488 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
232489 window.webContents.openDevTools({ mode: "detach" });
233490 } 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`);
238492 }
239493
240494 window.on("closed", () => {
......@@ -246,6 +500,8 @@ function createWindow(): BrowserWindow {
246500 return window;
247501}
248502
503configureAppIdentity();
504
249505async function bootstrap(): Promise<void> {
250506 backendPort = await reserveLoopbackPort();
251507 backendAuthToken = randomBytes(24).toString("hex");
......@@ -263,6 +519,9 @@ app.on("before-quit", () => {
263519});
264520
265521app.whenReady().then(() => {
522 configureAppIdentity();
523 configureApplicationMenu();
524 registerDesktopProtocol();
266525 void bootstrap();
267526
268527 app.on("activate", () => {
......@@ -277,3 +536,19 @@ app.on("window-all-closed", () => {
277536 app.quit();
278537 }
279538});
539
540if (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";
44const CONFIRM_CHANNEL = "desktop:confirm";
55const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
66const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7const MENU_ACTION_CHANNEL = "desktop:menu-action";
78const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
89
910contextBridge.exposeInMainWorld("desktopBridge", {
......@@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
1314 showContextMenu: (items: { id: string; label: string }[]) =>
1415 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
1516 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 },
1628});
apps/server/src/codexAppServerManager.ts+7-2
......@@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
163163 updatedAt: now,
164164 };
165165
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"], {
167169 cwd: resolvedCwd,
168 env: process.env,
170 env: {
171 ...process.env,
172 ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
173 },
169174 stdio: ["pipe", "pipe", "pipe"],
170175 });
171176 const output = readline.createInterface({ input: child.stdout });
apps/web/index.html+3-1
......@@ -3,13 +3,15 @@
33 <head>
44 <meta charset="UTF-8" />
55 <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" />
68 <link rel="preconnect" href="https://fonts.googleapis.com" />
79 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
810 <link
911 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap"
1012 rel="stylesheet"
1113 />
12 <title>T3 Code</title>
14 <title>T3 Code (Alpha)</title>
1315 </head>
1416 <body>
1517 <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 @@
1import { useCallback, useSyncExternalStore } from "react";
2import { z } from "zod";
3
4const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
5
6const 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
12export type AppSettings = z.infer<typeof appSettingsSchema>;
13
14const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({});
15
16let listeners: Array<() => void> = [];
17let cachedRawSettings: string | null | undefined;
18let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
19
20function emitChange(): void {
21 for (const listener of listeners) {
22 listener();
23 }
24}
25
26function 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
39export 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
54function 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
70function 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
86export 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 @@
1export const APP_BASE_NAME = "T3 Code";
2export const APP_STAGE_LABEL = import.meta.env.DEV ? "Dev" : "Alpha";
3export const APP_DISPLAY_NAME = `${APP_BASE_NAME} (${APP_STAGE_LABEL})`;
apps/web/src/components/ChatView.tsx+54-28
......@@ -28,6 +28,7 @@ import {
2828} from "react";
2929import { useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
3030import { useDebouncedValue } from "@tanstack/react-pacer";
31import { useNavigate, useSearch } from "@tanstack/react-router";
3132import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual";
3233import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
3334import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
......@@ -35,6 +36,8 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
3536import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3637
3738import { isElectron } from "../env";
39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
40import { useAppSettings } from "../appSettings";
3841import { buildBootstrapInput } from "../historyBootstrap";
3942import {
4043 type ComposerTriggerKind,
......@@ -374,8 +377,11 @@ interface ChatViewProps {
374377
375378export default function ChatView({ threadId }: ChatViewProps) {
376379 const { state, dispatch } = useStore();
380 const navigate = useNavigate();
381 const rawSearch = useSearch({ strict: false });
377382 const api = useNativeApi();
378383 const { resolvedTheme } = useTheme();
384 const { settings: appSettings } = useAppSettings();
379385 const queryClient = useQueryClient();
380386 const createWorktreeMutation = useMutation(
381387 gitCreateWorktreeMutationOptions({ api, queryClient }),
......@@ -411,6 +417,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
411417 const checkpointTurnCountSyncFingerprintRef = useRef(new Map<string, string>());
412418
413419 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";
414425 const activeThreadId = activeThread?.id ?? null;
415426 const activeSessionId = activeThread?.session?.sessionId ?? null;
416427 const activeThreadRuntimeId =
......@@ -642,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
642653 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
643654 const runtimeSandboxMode =
644655 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
656 const codexBinaryPath = appSettings.codexBinaryPath.trim();
657 const codexHomePath = appSettings.codexHomePath.trim();
645658 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
646659 const composerTrigger = useMemo(
647660 () => detectComposerTrigger(prompt, composerCursor),
......@@ -1515,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
15151528 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
15161529 model: selectedModel || undefined,
15171530 resumeThreadId: priorCodexThreadId ?? undefined,
1531 ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}),
1532 ...(codexHomePath.length > 0 ? { codexHomePath } : {}),
15181533 approvalPolicy: runtimeApprovalPolicy,
15191534 sandboxMode: runtimeSandboxMode,
15201535 });
......@@ -1555,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
15551570 activeProject,
15561571 activeThread,
15571572 api,
1573 codexBinaryPath,
1574 codexHomePath,
15581575 dispatch,
15591576 runtimeApprovalPolicy,
15601577 runtimeSandboxMode,
......@@ -1954,32 +1971,29 @@ export default function ChatView({ threadId }: ChatViewProps) {
19541971 setExpandedImage(image);
19551972 }, []);
19561973 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 },
19701981 });
1971 }, [activeThread, dispatch, state.diffOpen]);
1982 }, [diffOpen, navigate, threadId]);
19721983 const onOpenTurnDiff = useCallback(
19731984 (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 },
19801994 });
19811995 },
1982 [activeThread, dispatch],
1996 [navigate, threadId],
19831997 );
19841998 const onRevertUserMessage = useCallback(
19851999 (messageId: string) => {
......@@ -2027,7 +2041,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
20272041 keybindings={keybindings}
20282042 api={api}
20292043 gitCwd={gitCwd}
2030 diffOpen={state.diffOpen}
2044 diffOpen={diffOpen}
20312045 onRunProjectScript={(script) => {
20322046 void runProjectScript(script);
20332047 }}
......@@ -2812,11 +2826,16 @@ const MessagesTimeline = memo(function MessagesTimeline({
28122826 row.message.id,
28132827 );
28142828 if (!turnSummary) return null;
2815 const checkpointFilesState = turnCheckpointFilesByTurnId.get(turnSummary.turnId);
2829 const checkpointFilesState = turnCheckpointFilesByTurnId.get(
2830 turnSummary.turnId,
2831 );
28162832 const checkpointFiles = checkpointFilesState?.files ?? [];
28172833 const summaryStat = checkpointFiles.reduce(
28182834 (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 ) {
28202839 return acc;
28212840 }
28222841 return {
......@@ -2827,7 +2846,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
28272846 { additions: 0, deletions: 0 },
28282847 );
28292848 const changedFileCountLabel =
2830 checkpointFilesState?.status === "ready" ? String(checkpointFiles.length) : "...";
2849 checkpointFilesState?.status === "ready"
2850 ? String(checkpointFiles.length)
2851 : "...";
28312852 return (
28322853 <div className="mt-2 rounded-lg border border-border/80 bg-card/45 p-2.5">
28332854 <div className="mb-1.5 flex items-center justify-between gap-2">
......@@ -2838,7 +2859,9 @@ const MessagesTimeline = memo(function MessagesTimeline({
28382859 <span className="mx-1">•</span>
28392860 <span className="text-success">+{summaryStat.additions}</span>
28402861 <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>
28422865 </>
28432866 )}
28442867 </p>
......@@ -2846,12 +2869,15 @@ const MessagesTimeline = memo(function MessagesTimeline({
28462869 type="button"
28472870 size="xs"
28482871 variant="outline"
2849 onClick={() => onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)}
2872 onClick={() =>
2873 onOpenTurnDiff(turnSummary.turnId, checkpointFiles[0]?.path)
2874 }
28502875 >
28512876 View diff
28522877 </Button>
28532878 </div>
2854 {!checkpointFilesState || checkpointFilesState.status === "pending" ? (
2879 {!checkpointFilesState ||
2880 checkpointFilesState.status === "pending" ? (
28552881 <p className="text-[11px] text-muted-foreground/70">
28562882 Waiting for checkpoint metadata...
28572883 </p>
apps/web/src/components/DiffPanel.tsx+28-50
......@@ -1,15 +1,9 @@
11import { parsePatchFiles } from "@pierre/diffs";
2import {
3 FileDiff,
4 type FileDiffMetadata,
5 Virtualizer,
6 WorkerPoolContextProvider,
7} from "@pierre/diffs/react";
2import { FileDiff, type FileDiffMetadata, Virtualizer } from "@pierre/diffs/react";
83import { useQuery } from "@tanstack/react-query";
9import { useParams } from "@tanstack/react-router";
4import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
105import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react";
116import {
12 type ReactNode,
137 type WheelEvent as ReactWheelEvent,
148 useCallback,
159 useEffect,
......@@ -19,6 +13,7 @@ import {
1913} from "react";
2014import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
2115import { cn } from "~/lib/utils";
16import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2217import { isElectron } from "../env";
2318import { useNativeApi } from "../hooks/useNativeApi";
2419import { useTheme } from "../hooks/useTheme";
......@@ -90,41 +85,26 @@ interface DiffPanelProps {
9085 mode?: "inline" | "sheet";
9186}
9287
93export 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}
88export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider";
11589
11690export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
11791 const api = useNativeApi();
92 const navigate = useNavigate();
11893 const { resolvedTheme } = useTheme();
119 const { state, dispatch } = useStore();
94 const { state } = useStore();
12095 const [diffRenderMode, setDiffRenderMode] = useState<DiffRenderMode>("stacked");
12196 const patchViewportRef = useRef<HTMLDivElement>(null);
12297 const turnStripRef = useRef<HTMLDivElement>(null);
12398 const [canScrollTurnStripLeft, setCanScrollTurnStripLeft] = useState(false);
12499 const [canScrollTurnStripRight, setCanScrollTurnStripRight] = useState(false);
125100 const params = useParams({ strict: false });
101 const rawSearch = useSearch({ strict: false });
126102 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;
128108 const activeThread = state.threads.find((thread) => thread.id === activeThreadId);
129109 const activeThreadRuntimeId =
130110 activeThread?.codexThreadId ?? activeThread?.session?.threadId ?? null;
......@@ -132,10 +112,8 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
132112 const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } =
133113 useTurnDiffSummaries(activeThread);
134114
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;
139117 const selectedTurn =
140118 selectedTurnId === null
141119 ? undefined
......@@ -235,17 +213,24 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
235213
236214 const selectTurn = (turnId: string) => {
237215 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 },
242223 });
243224 };
244225 const selectWholeConversation = () => {
245226 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 },
249234 });
250235 };
251236 const updateTurnStripScrollState = useCallback(() => {
......@@ -440,13 +425,6 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
440425 ) : (
441426 <>
442427 <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 )}
450428 {checkpointDiffError && !renderablePatch && (
451429 <div className="px-3">
452430 <p className="mb-2 text-[11px] text-red-500/80">{checkpointDiffError}</p>
apps/web/src/components/DiffWorkerPoolProvider.tsx created+26
......@@ -0,0 +1,26 @@
1import { WorkerPoolContextProvider } from "@pierre/diffs/react";
2import { useMemo, type ReactNode } from "react";
3
4export 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 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
1import { TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
44import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
55import { useNavigate, useParams } from "@tanstack/react-router";
6import { useAppSettings } from "../appSettings";
67import { isElectron } from "../env";
7import { useTheme } from "../hooks/useTheme";
8import { APP_STAGE_LABEL } from "../branding";
89import { DEFAULT_MODEL } from "../model-logic";
910import { derivePendingApprovals } from "../session-logic";
1011import { useStore } from "../store";
......@@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast";
1718import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1819import { createThread } from "../threadFactory";
1920
20const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
2121const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2222
2323function formatRelativeTime(iso: string): string {
......@@ -116,6 +116,7 @@ export default function Sidebar() {
116116 const { state, dispatch } = useStore();
117117 const api = useNativeApi();
118118 const navigate = useNavigate();
119 const { settings: appSettings } = useAppSettings();
119120 const params = useParams({ strict: false });
120121 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
121122 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
......@@ -126,7 +127,6 @@ export default function Sidebar() {
126127 const removeWorktreeMutation = useMutation(
127128 gitRemoveWorktreeMutationOptions({ api, queryClient }),
128129 );
129 const { theme, setTheme } = useTheme();
130130 const [addingProject, setAddingProject] = useState(false);
131131 const [newCwd, setNewCwd] = useState("");
132132 const [isPickingFolder, setIsPickingFolder] = useState(false);
......@@ -271,6 +271,17 @@ export default function Sidebar() {
271271
272272 const thread = state.threads.find((t) => t.id === threadId);
273273 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 }
274285 const threadProject = state.projects.find((project) => project.id === thread.projectId);
275286 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
276287 const displayWorktreePath = orphanedWorktreePath
......@@ -348,7 +359,16 @@ export default function Sidebar() {
348359 });
349360 }
350361 },
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 ],
352372 );
353373
354374 const handleProjectContextMenu = useCallback(
......@@ -431,24 +451,14 @@ export default function Sidebar() {
431451 <div
432452 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
433453 >
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>
452462 </div>
453463
454464 {/* New thread (global) */}
apps/web/src/diffRouteSearch.test.ts created+53
......@@ -0,0 +1,53 @@
1import { describe, expect, it } from "vitest";
2
3import { parseDiffRouteSearch } from "./diffRouteSearch";
4
5describe("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 @@
1export interface DiffRouteSearch {
2 diff?: "1";
3 diffTurnId?: string;
4 diffFilePath?: string;
5}
6
7function 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
15export 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
22export 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";
99import "highlight.js/styles/github-dark.css";
1010import "./index.css";
1111
12import { APP_DISPLAY_NAME } from "./branding";
1213import { isElectron } from "./env";
1314import { routeTree } from "./routeTree.gen";
1415
1516const history = isElectron ? createHashHistory() : createBrowserHistory();
1617
1718const queryClient = new QueryClient();
19document.title = APP_DISPLAY_NAME;
1820
1921const router = createRouter({
2022 routeTree,
apps/web/src/routeTree.gen.ts+21-3
......@@ -11,6 +11,7 @@
1111import { Route as rootRouteImport } from './routes/__root'
1212import { Route as ChatRouteImport } from './routes/_chat'
1313import { Route as ChatIndexRouteImport } from './routes/_chat.index'
14import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
1415import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
1516
1617const ChatRoute = ChatRouteImport.update({
......@@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
2223 path: '/',
2324 getParentRoute: () => ChatRoute,
2425} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({
27 id: '/settings',
28 path: '/settings',
29 getParentRoute: () => ChatRoute,
30} as any)
2531const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
2632 id: '/$threadId',
2733 path: '/$threadId',
......@@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
3137export interface FileRoutesByFullPath {
3238 '/': typeof ChatIndexRoute
3339 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute
3441}
3542export interface FileRoutesByTo {
3643 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute
3745 '/': typeof ChatIndexRoute
3846}
3947export interface FileRoutesById {
4048 __root__: typeof rootRouteImport
4149 '/_chat': typeof ChatRouteWithChildren
4250 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute
4352 '/_chat/': typeof ChatIndexRoute
4453}
4554export interface FileRouteTypes {
4655 fileRoutesByFullPath: FileRoutesByFullPath
47 fullPaths: '/' | '/$threadId'
56 fullPaths: '/' | '/$threadId' | '/settings'
4857 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/'
5160 fileRoutesById: FileRoutesById
5261}
5362export interface RootRouteChildren {
......@@ -70,6 +79,13 @@ declare module '@tanstack/react-router' {
7079 preLoaderRoute: typeof ChatIndexRouteImport
7180 parentRoute: typeof ChatRoute
7281 }
82 '/_chat/settings': {
83 id: '/_chat/settings'
84 path: '/settings'
85 fullPath: '/settings'
86 preLoaderRoute: typeof ChatSettingsRouteImport
87 parentRoute: typeof ChatRoute
88 }
7389 '/_chat/$threadId': {
7490 id: '/_chat/$threadId'
7591 path: '/$threadId'
......@@ -82,11 +98,13 @@ declare module '@tanstack/react-router' {
8298
8399interface ChatRouteChildren {
84100 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
85102 ChatIndexRoute: typeof ChatIndexRoute
86103}
87104
88105const ChatRouteChildren: ChatRouteChildren = {
89106 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
90108 ChatIndexRoute: ChatIndexRoute,
91109}
92110
apps/web/src/routes/__root.tsx+4-1
......@@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery";
99import { DEFAULT_MODEL } from "../model-logic";
1010import { useStore } from "../store";
1111import { onServerWelcome } from "../wsNativeApi";
12import { APP_DISPLAY_NAME } from "../branding";
1213
1314export const Route = createRootRouteWithContext<{
1415 queryClient: QueryClient;
......@@ -23,7 +24,9 @@ function RootRouteView() {
2324 return (
2425 <div className="flex h-screen flex-col bg-background text-foreground">
2526 <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>
2730 </div>
2831 </div>
2932 );
apps/web/src/routes/_chat.$threadId.tsx+78-2
......@@ -1,14 +1,67 @@
11import { createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";
2import { Activity, Suspense, lazy, type ReactNode, useCallback, useEffect } from "react";
33
44import ChatView from "../components/ChatView";
5import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
6import { useMediaQuery } from "../hooks/useMediaQuery";
57import { useStore } from "../store";
8import { Sheet, SheetPopup } from "../components/ui/sheet";
9
10const DiffPanel = lazy(() => import("../components/DiffPanel"));
11const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
12
13const 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};
647
748function ChatThreadRouteView() {
849 const { state } = useStore();
950 const navigate = useNavigate();
1051 const { threadId } = Route.useParams();
52 const search = Route.useSearch();
1153 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]);
1265
1366 useEffect(() => {
1467 if (!threadId) {
......@@ -30,9 +83,32 @@ function ChatThreadRouteView() {
3083 return null;
3184 }
3285
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 );
34109}
35110
36111export const Route = createFileRoute("/_chat/$threadId")({
112 validateSearch: (search) => parseDiffRouteSearch(search),
37113 component: ChatThreadRouteView,
38114});
apps/web/src/routes/_chat.settings.tsx created+259
......@@ -0,0 +1,259 @@
1import { createFileRoute } from "@tanstack/react-router";
2
3import { useAppSettings } from "../appSettings";
4import { isElectron } from "../env";
5import { useTheme } from "../hooks/useTheme";
6import { useStore } from "../store";
7import { Button } from "../components/ui/button";
8import { Input } from "../components/ui/input";
9import { Switch } from "../components/ui/switch";
10
11const 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
29const 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
42function 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
257export const Route = createFileRoute("/_chat/settings")({
258 component: SettingsRouteView,
259});
apps/web/src/routes/_chat.tsx+22-65
......@@ -1,78 +1,34 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode } from "react";
1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";
33
4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
45import Sidebar from "../components/Sidebar";
5import { useMediaQuery } from "../hooks/useMediaQuery";
6import { useStore } from "../store";
7import { Sheet, SheetPopup } from "../components/ui/sheet";
86
9const DiffPanel = lazy(() => import("../components/DiffPanel"));
10const DiffWorkerPoolProvider = lazy(() =>
11 import("../components/DiffPanel").then((module) => ({
12 default: module.DiffWorkerPoolProvider,
13 })),
14);
15const DIFF_INLINE_LAYOUT_MEDIA_QUERY = "(max-width: 1180px)";
16
17const 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 }
7function ChatRouteLayout() {
8 const navigate = useNavigate();
409
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 }
4715
48function 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 });
5120
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]);
6225
6326 return (
6427 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
6528 <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>
7632 </div>
7733 );
7834}
......@@ -80,3 +36,4 @@ function ChatRouteLayout() {
8036export const Route = createFileRoute("/_chat")({
8137 component: ChatRouteLayout,
8238});
39
apps/web/src/store.test.ts-39
......@@ -81,10 +81,6 @@ function makeState(thread: Thread): AppState {
8181 threads: [thread],
8282 threadsHydrated: true,
8383 runtimeMode: "full-access",
84 diffOpen: false,
85 diffThreadId: null,
86 diffTurnId: null,
87 diffFilePath: null,
8884 };
8985}
9086
......@@ -149,21 +145,6 @@ describe("store reducer thread continuity", () => {
149145 expect(next.threads[0]?.terminalOpen).toBe(true);
150146 });
151147
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
167148 it("sets terminal open state per thread", () => {
168149 const state = makeState(makeThread({ terminalOpen: true }));
169150 const next = reducer(state, {
......@@ -618,10 +599,6 @@ describe("store reducer thread continuity", () => {
618599 ],
619600 threadsHydrated: true,
620601 runtimeMode: "full-access",
621 diffOpen: true,
622 diffThreadId: "thread-b",
623 diffTurnId: "turn-b",
624 diffFilePath: "src/removed.ts",
625602 };
626603
627604 const next = reducer(state, {
......@@ -645,10 +622,6 @@ describe("store reducer thread continuity", () => {
645622 expect(next.threads).toHaveLength(1);
646623 expect(next.threads[0]?.id).toBe("thread-a");
647624 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();
652625 });
653626
654627 it("treats empty scripts from sync as authoritative", () => {
......@@ -674,10 +647,6 @@ describe("store reducer thread continuity", () => {
674647 threads: [makeThread({ id: "thread-a", projectId: "project-old-a" })],
675648 threadsHydrated: true,
676649 runtimeMode: "full-access",
677 diffOpen: false,
678 diffThreadId: null,
679 diffTurnId: null,
680 diffFilePath: null,
681650 };
682651
683652 const next = reducer(state, {
......@@ -766,10 +735,6 @@ describe("store reducer thread continuity", () => {
766735 ],
767736 threadsHydrated: true,
768737 runtimeMode: "full-access",
769 diffOpen: true,
770 diffThreadId: "thread-a",
771 diffTurnId: "turn-a",
772 diffFilePath: "src/a.ts",
773738 };
774739
775740 const next = reducer(state, {
......@@ -781,10 +746,6 @@ describe("store reducer thread continuity", () => {
781746 expect(next.projects[0]?.id).toBe("project-2");
782747 expect(next.threads).toHaveLength(1);
783748 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();
788749 });
789750
790751 it("marks completion as seen immediately for the active thread", () => {
apps/web/src/store.ts+2-85
......@@ -48,20 +48,6 @@ type Action =
4848 | { type: "NEW_THREAD_TERMINAL"; threadId: string; terminalId: string }
4949 | { type: "SET_THREAD_ACTIVE_TERMINAL"; threadId: string; terminalId: string }
5050 | { 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" }
6551 | {
6652 type: "APPLY_EVENT";
6753 event: ProviderEvent;
......@@ -110,10 +96,6 @@ export interface AppState {
11096 threads: Thread[];
11197 threadsHydrated: boolean;
11298 runtimeMode: RuntimeMode;
113 diffOpen: boolean;
114 diffThreadId: string | null;
115 diffTurnId: string | null;
116 diffFilePath: string | null;
11799}
118100
119101const PERSISTED_STATE_KEY = "t3code:renderer-state:v7";
......@@ -133,10 +115,6 @@ const initialState: AppState = {
133115 threads: [],
134116 threadsHydrated: false,
135117 runtimeMode: DEFAULT_RUNTIME_MODE,
136 diffOpen: false,
137 diffThreadId: null,
138 diffTurnId: null,
139 diffFilePath: null,
140118};
141119
142120// ── Helpers ──────────────────────────────────────────────────────────
......@@ -160,10 +138,6 @@ function readPersistedState(): AppState {
160138 ...hydrated,
161139 threads,
162140 threadsHydrated: threads.length > 0,
163 diffOpen: false,
164 diffThreadId: null,
165 diffTurnId: null,
166 diffFilePath: null,
167141 };
168142 } catch {
169143 return initialState;
......@@ -191,32 +165,6 @@ function updateThread(
191165 return threads.map((t) => (t.id === threadId ? updater(t) : t));
192166}
193167
194function 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
220168function mergeTurnDiffSummaries(
221169 existing: Thread["turnDiffSummaries"],
222170 next: Thread["turnDiffSummaries"],
......@@ -615,13 +563,11 @@ export function reducer(state: AppState, action: Action): AppState {
615563 });
616564 })
617565 .filter((thread): thread is Thread => thread !== null);
618 const diffState = resetDiffTargetIfMissing(state, nextThreads);
619566
620567 return {
621568 ...state,
622569 projects: nextProjects,
623570 threads: nextThreads,
624 ...diffState,
625571 };
626572 }
627573
......@@ -640,13 +586,11 @@ export function reducer(state: AppState, action: Action): AppState {
640586 }
641587
642588 const threads = state.threads.filter((thread) => thread.projectId !== action.projectId);
643 const diffState = resetDiffTargetIfMissing(state, threads);
644589
645590 return {
646591 ...state,
647592 projects,
648593 threads,
649 ...diffState,
650594 };
651595 }
652596
......@@ -830,29 +774,6 @@ export function reducer(state: AppState, action: Action): AppState {
830774 ),
831775 };
832776
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
856777 case "APPLY_TERMINAL_EVENT":
857778 if (!state.threads.some((thread) => thread.id === action.event.threadId)) {
858779 return state;
......@@ -1093,15 +1014,11 @@ export function reducer(state: AppState, action: Action): AppState {
10931014 runtimeMode: action.mode,
10941015 };
10951016
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":
10991018 return {
11001019 ...state,
1101 threads,
1102 ...diffState,
1020 threads: state.threads.filter((t) => t.id !== action.threadId),
11031021 };
1104 }
11051022
11061023 default:
11071024 return state;
apps/web/src/vite-env.d.ts+1
......@@ -8,6 +8,7 @@ interface DesktopBridge {
88 confirm: (message: string) => Promise<boolean>;
99 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
1010 openExternal: (url: string) => Promise<boolean>;
11 onMenuAction: (listener: (action: string) => void) => () => void;
1112}
1213
1314declare 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 @@
2020 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
2121 "fmt": "oxfmt",
2222 "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",
2326 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
2427 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
2528 },
packages/contracts/src/provider.test.ts+22
......@@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => {
3737 }),
3838 ).toThrow();
3939 });
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 });
4062});
4163
4264describe("providerSendTurnInputSchema", () => {
packages/contracts/src/provider.ts+2
......@@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({
5050 cwd: z.string().min(1).optional(),
5151 model: z.string().trim().min(1).optional(),
5252 resumeThreadId: z.string().trim().min(1).optional(),
53 codexBinaryPath: z.string().trim().min(1).optional(),
54 codexHomePath: z.string().trim().min(1).optional(),
5355 approvalPolicy: providerApprovalPolicySchema.default("never"),
5456 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
5557});
scripts/build-desktop-dmg.mjs created+343
......@@ -0,0 +1,343 @@
1#!/usr/bin/env node
2
3import { spawn } from "node:child_process";
4import fs from "node:fs/promises";
5import os from "node:os";
6import path from "node:path";
7import { fileURLToPath } from "node:url";
8
9const __dirname = path.dirname(fileURLToPath(import.meta.url));
10const repoRoot = path.resolve(__dirname, "..");
11const PRODUCTION_ICON_SOURCE = path.join(repoRoot, "assets/macos-icon-1024.png");
12
13const ARCH_CHOICES = new Set(["arm64", "x64", "universal"]);
14
15function printUsage() {
16 console.log(`Build a shareable macOS .dmg for T3 Code desktop.
17
18Usage:
19 node scripts/build-desktop-dmg.mjs [options]
20
21Options:
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
32function fail(message) {
33 console.error(`[desktop-dmg] ${message}`);
34 process.exit(1);
35}
36
37function 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
87async function readJson(filePath) {
88 const raw = await fs.readFile(filePath, "utf8");
89 return JSON.parse(raw);
90}
91
92async function exists(filePath) {
93 try {
94 await fs.access(filePath);
95 return true;
96 } catch {
97 return false;
98 }
99}
100
101function 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
124async 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
153async 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
171async 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
202async 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
340main().catch((error) => {
341 const message = error instanceof Error ? error.message : String(error);
342 fail(message);
343});