authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 15:39:47-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-19 15:39:47-08:00
log8a72a2a91d52c16f3f761115d1c49b21fa6cdc4e
treeb1cf20836e92b09979dca45a58d916cc162b188a
parent842e327583f214714abbde82e7e19a6341c3d140
parent8cea075a1eb94b574434676122abc5f82e941f43

Merge origin/main into codething/53bdee92

Resolve PR conflicts while preserving route-scoped diff rendering and _chat desktop menu action handling. Co-authored-by: codex <codex@users.noreply.github.com>

45 files changed, 1415 insertions(+), 53 deletions(-)

.gitignore+1
...@@ -10,3 +10,4 @@ packages/*/dist...@@ -10,3 +10,4 @@ packages/*/dist
10.env.local10.env.local
11build/11build/
12.logs/12.logs/
13release/
README.md+14
...@@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop...@@ -56,6 +56,9 @@ T3CODE_DEV_INSTANCE=feature-xyz bun run dev:desktop
56bun run build56bun run build
57bun run start57bun run start
5858
59# Build a shareable macOS .dmg (arm64 by default)
60bun run dist:desktop:dmg
61
59# Or from any project directory after publishing:62# Or from any project directory after publishing:
60npx t363npx t3
61```64```
...@@ -69,6 +72,17 @@ npx t3...@@ -69,6 +72,17 @@ npx t3
69- `bun run build` — Builds contracts, web app, and server through Turbo.72- `bun run build` — Builds contracts, web app, and server through Turbo.
70- `bun run typecheck` — Strict TypeScript checks for all packages.73- `bun run typecheck` — Strict TypeScript checks for all packages.
71- `bun run test` — Runs workspace tests.74- `bun run test` — Runs workspace tests.
75- `bun run dist:desktop:dmg` — Builds a shareable macOS `.dmg` into `./release`.
76- `bun run dist:desktop:dmg:x64` — Builds an Intel macOS `.dmg`.
77
78### Desktop `.dmg` packaging notes
79
80- Default build is unsigned/not notarized for local sharing.
81- The DMG build uses `assets/macos-icon-1024.png` as the production app icon source.
82- Desktop production windows load the bundled UI from `t3://app/index.html` (not a `127.0.0.1` document URL).
83- Desktop packaging includes `apps/server/dist` (the `t3` backend) and starts it on loopback with an auth token for WebSocket/API traffic.
84- Your tester can still open it on macOS by right-clicking the app and choosing **Open** on first launch.
85- To keep staging files for debugging package contents, run: `bun run dist:desktop:dmg -- --keep-stage`
7286
73### Running multiple dev instances87### Running multiple dev instances
7488
TODO.md+3-1
...@@ -2,6 +2,8 @@...@@ -2,6 +2,8 @@
22
3## Small things3## Small things
44
5- [ ] Plan mode
6- [ ] Submitting new messages should scroll to bottom
5- [ ] 1 click copy in codeblocks7- [ ] 1 click copy in codeblocks
6- [x] Pasting/adding images to submit with prompts8- [x] Pasting/adding images to submit with prompts
7- [ ] Only show last 10 threads for a given project9- [ ] Only show last 10 threads for a given project
...@@ -12,7 +14,7 @@...@@ -12,7 +14,7 @@
12- [x] "Response" bar should ONLY appear on work completed14- [x] "Response" bar should ONLY appear on work completed
13- [x] "Response" bar should ONLY appear above the FINAL message in a series15- [x] "Response" bar should ONLY appear above the FINAL message in a series
14- [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)16- [x] Reduce noise of tool calls (combine "command run" and "command completed", make tool calls a "box" with limited # of calls shown at a time, etc)
15- [ ] Insta focus input on new thread creation17- [x] Insta focus input on new thread creation
16- [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it18- [ ] Disable "streaming" (wait until whole paragraph/step finishes before showing it
17- [x] Making new thread should auto-focus input box19- [x] Making new thread should auto-focus input box
18- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank)20- [x] Threads should be scrolled to bottom on open (they currently start near top and "scroll down" after rendering, it's jank)
apps/desktop/.gitignore+1
...@@ -1 +1,2 @@...@@ -1 +1,2 @@
1dist-electron/1dist-electron/
2.electron-runtime/
apps/desktop/package.json+2-1
...@@ -2,13 +2,14 @@...@@ -2,13 +2,14 @@
2 "name": "@t3tools/desktop",2 "name": "@t3tools/desktop",
3 "version": "0.0.0",3 "version": "0.0.0",
4 "private": true,4 "private": true,
5 "productName": "T3 Code (Alpha)",
5 "main": "dist-electron/main.js",6 "main": "dist-electron/main.js",
6 "scripts": {7 "scripts": {
7 "dev": "bun run --parallel dev:bundle dev:electron",8 "dev": "bun run --parallel dev:bundle dev:electron",
8 "dev:bundle": "tsdown --watch",9 "dev:bundle": "tsdown --watch",
9 "dev:electron": "bun run scripts/dev-electron.mjs",10 "dev:electron": "bun run scripts/dev-electron.mjs",
10 "build": "tsdown",11 "build": "tsdown",
11 "start": "electron dist-electron/main.js",12 "start": "bun run scripts/start-electron.mjs",
12 "typecheck": "tsc --noEmit",13 "typecheck": "tsc --noEmit",
13 "test": "vitest run --passWithNoTests",14 "test": "vitest run --passWithNoTests",
14 "smoke-test": "node scripts/smoke-test.mjs"15 "smoke-test": "node scripts/smoke-test.mjs"
apps/desktop/resources/icon.icns created
Binary files /dev/null and b/apps/desktop/resources/icon.icns differ
apps/desktop/resources/icon.ico created
Binary files /dev/null and b/apps/desktop/resources/icon.ico differ
apps/desktop/resources/icon.png created
Binary files /dev/null and b/apps/desktop/resources/icon.png differ
apps/desktop/scripts/dev-electron.mjs+48-6
...@@ -1,7 +1,10 @@...@@ -1,7 +1,10 @@
1import { spawn } from "node:child_process";1import { spawnSync } from "node:child_process";
22
3import electronmon from "electronmon";
3import waitOn from "wait-on";4import waitOn from "wait-on";
45
6import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
7
5const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5173);
6const devServerUrl = `http://localhost:${port}`;9const devServerUrl = `http://localhost:${port}`;
710
...@@ -14,18 +17,57 @@ await waitOn({...@@ -14,18 +17,57 @@ await waitOn({
14 ],17 ],
15});18});
1619
17const command = process.platform === "win32" ? "electronmon.cmd" : "electronmon";
18const childEnv = { ...process.env };20const childEnv = { ...process.env };
19delete childEnv.ELECTRON_RUN_AS_NODE;21delete childEnv.ELECTRON_RUN_AS_NODE;
2022
21const child = spawn(command, ["dist-electron/main.js"], {23const monitor = await electronmon({
22 stdio: "inherit",24 cwd: desktopDir,
25 args: ["dist-electron/main.js"],
23 env: {26 env: {
24 ...childEnv,27 ...childEnv,
25 VITE_DEV_SERVER_URL: devServerUrl,28 VITE_DEV_SERVER_URL: devServerUrl,
26 },29 },
30 electronPath: resolveElectronPath(),
27});31});
2832
29child.on("exit", (code) => {33let shuttingDown = false;
30 process.exit(code ?? 0);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);
31});73});
apps/desktop/scripts/electron-launcher.mjs created+173
...@@ -0,0 +1,173 @@
1// This file mostly exists because we want dev mode to say "T3 Code (Dev)" instead of "electron"
2
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";...@@ -5,7 +5,16 @@ import net from "node:net";
5import os from "node:os";5import os from "node:os";
6import path from "node:path";6import 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
10import { showDesktopConfirmDialog } from "./confirmDialog";19import { showDesktopConfirmDialog } from "./confirmDialog";
11import { fixPath } from "./fixPath";20import { fixPath } from "./fixPath";
...@@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";...@@ -16,11 +25,13 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
16const CONFIRM_CHANNEL = "desktop:confirm";25const CONFIRM_CHANNEL = "desktop:confirm";
17const CONTEXT_MENU_CHANNEL = "desktop:context-menu";26const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
18const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";27const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
19const ROOT_DIR = path.resolve(__dirname, "../../..");28const MENU_ACTION_CHANNEL = "desktop:menu-action";
20const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
21const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
22const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");29const STATE_DIR = path.join(os.homedir(), ".t3", "userdata");
30const DESKTOP_SCHEME = "t3";
31const ROOT_DIR = path.resolve(__dirname, "../../..");
23const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);32const 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
25let mainWindow: BrowserWindow | null = null;36let mainWindow: BrowserWindow | null = null;
26let backendProcess: ChildProcess | null = null;37let backendProcess: ChildProcess | null = null;
...@@ -30,6 +41,235 @@ let backendWsUrl = "";...@@ -30,6 +41,235 @@ let backendWsUrl = "";
30let restartAttempt = 0;41let restartAttempt = 0;
31let restartTimer: ReturnType<typeof setTimeout> | null = null;42let restartTimer: ReturnType<typeof setTimeout> | null = null;
32let isQuitting = false;43let 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
34async function reserveLoopbackPort(): Promise<number> {274async function reserveLoopbackPort(): Promise<number> {
35 return new Promise((resolve, reject) => {275 return new Promise((resolve, reject) => {
...@@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void {...@@ -76,13 +316,14 @@ function scheduleBackendRestart(reason: string): void {
76function startBackend(): void {316function startBackend(): void {
77 if (isQuitting || backendProcess) return;317 if (isQuitting || backendProcess) return;
78318
79 if (!fs.existsSync(BACKEND_ENTRY)) {319 const backendEntry = resolveBackendEntry();
80 scheduleBackendRestart(`missing server entry at ${BACKEND_ENTRY}`);320 if (!fs.existsSync(backendEntry)) {
321 scheduleBackendRestart(`missing server entry at ${backendEntry}`);
81 return;322 return;
82 }323 }
83324
84 const child = spawn(process.execPath, [BACKEND_ENTRY], {325 const child = spawn(process.execPath, [backendEntry], {
85 cwd: ROOT_DIR,326 cwd: resolveBackendCwd(),
86 // In Electron main, process.execPath points to the Electron binary.327 // In Electron main, process.execPath points to the Electron binary.
87 // Run the child in Node mode so this backend process does not become a GUI app instance.328 // Run the child in Node mode so this backend process does not become a GUI app instance.
88 env: {329 env: {
...@@ -124,10 +365,10 @@ function stopBackend(): void {...@@ -124,10 +365,10 @@ function stopBackend(): void {
124 backendProcess = null;365 backendProcess = null;
125 if (!child) return;366 if (!child) return;
126367
127 if (!child.killed) {368 if (child.exitCode === null && child.signalCode === null) {
128 child.kill("SIGTERM");369 child.kill("SIGTERM");
129 setTimeout(() => {370 setTimeout(() => {
130 if (!child.killed) {371 if (child.exitCode === null && child.signalCode === null) {
131 child.kill("SIGKILL");372 child.kill("SIGKILL");
132 }373 }
133 }, 2_000).unref();374 }, 2_000).unref();
...@@ -204,6 +445,13 @@ function registerIpcHandlers(): void {...@@ -204,6 +445,13 @@ function registerIpcHandlers(): void {
204 });445 });
205}446}
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
207function createWindow(): BrowserWindow {455function createWindow(): BrowserWindow {
208 const window = new BrowserWindow({456 const window = new BrowserWindow({
209 width: 1100,457 width: 1100,
...@@ -212,6 +460,8 @@ function createWindow(): BrowserWindow {...@@ -212,6 +460,8 @@ function createWindow(): BrowserWindow {
212 minHeight: 620,460 minHeight: 620,
213 show: false,461 show: false,
214 autoHideMenuBar: true,462 autoHideMenuBar: true,
463 ...getIconOption(),
464 title: APP_DISPLAY_NAME,
215 titleBarStyle: "hiddenInset",465 titleBarStyle: "hiddenInset",
216 trafficLightPosition: { x: 16, y: 18 },466 trafficLightPosition: { x: 16, y: 18 },
217 webPreferences: {467 webPreferences: {
...@@ -223,6 +473,13 @@ function createWindow(): BrowserWindow {...@@ -223,6 +473,13 @@ function createWindow(): BrowserWindow {
223 });473 });
224474
225 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));475 window.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
476 window.on("page-title-updated", (event) => {
477 event.preventDefault();
478 window.setTitle(APP_DISPLAY_NAME);
479 });
480 window.webContents.on("did-finish-load", () => {
481 window.setTitle(APP_DISPLAY_NAME);
482 });
226 window.once("ready-to-show", () => {483 window.once("ready-to-show", () => {
227 window.show();484 window.show();
228 });485 });
...@@ -231,10 +488,7 @@ function createWindow(): BrowserWindow {...@@ -231,10 +488,7 @@ function createWindow(): BrowserWindow {
231 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);488 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);
232 window.webContents.openDevTools({ mode: "detach" });489 window.webContents.openDevTools({ mode: "detach" });
233 } else {490 } else {
234 if (!fs.existsSync(WEB_ENTRY)) {491 void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);
235 throw new Error(`Web bundle missing at ${WEB_ENTRY}`);
236 }
237 void window.loadFile(WEB_ENTRY);
238 }492 }
239493
240 window.on("closed", () => {494 window.on("closed", () => {
...@@ -246,6 +500,8 @@ function createWindow(): BrowserWindow {...@@ -246,6 +500,8 @@ function createWindow(): BrowserWindow {
246 return window;500 return window;
247}501}
248502
503configureAppIdentity();
504
249async function bootstrap(): Promise<void> {505async function bootstrap(): Promise<void> {
250 backendPort = await reserveLoopbackPort();506 backendPort = await reserveLoopbackPort();
251 backendAuthToken = randomBytes(24).toString("hex");507 backendAuthToken = randomBytes(24).toString("hex");
...@@ -263,6 +519,9 @@ app.on("before-quit", () => {...@@ -263,6 +519,9 @@ app.on("before-quit", () => {
263});519});
264520
265app.whenReady().then(() => {521app.whenReady().then(() => {
522 configureAppIdentity();
523 configureApplicationMenu();
524 registerDesktopProtocol();
266 void bootstrap();525 void bootstrap();
267526
268 app.on("activate", () => {527 app.on("activate", () => {
...@@ -277,3 +536,19 @@ app.on("window-all-closed", () => {...@@ -277,3 +536,19 @@ app.on("window-all-closed", () => {
277 app.quit();536 app.quit();
278 }537 }
279});538});
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";...@@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
4const CONFIRM_CHANNEL = "desktop:confirm";4const CONFIRM_CHANNEL = "desktop:confirm";
5const CONTEXT_MENU_CHANNEL = "desktop:context-menu";5const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
6const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";6const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7const MENU_ACTION_CHANNEL = "desktop:menu-action";
7const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;8const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
89
9contextBridge.exposeInMainWorld("desktopBridge", {10contextBridge.exposeInMainWorld("desktopBridge", {
...@@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
13 showContextMenu: (items: { id: string; label: string }[]) =>14 showContextMenu: (items: { id: string; label: string }[]) =>
14 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,15 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
15 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,16 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
17 onMenuAction: (listener: (action: string) => void) => {
18 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
19 if (typeof action !== "string") return;
20 listener(action);
21 };
22
23 ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener);
24 return () => {
25 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
26 };
27 },
16});28});
apps/server/src/codexAppServerManager.ts+7-2
...@@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
163 updatedAt: now,163 updatedAt: now,
164 };164 };
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"], {
167 cwd: resolvedCwd,169 cwd: resolvedCwd,
168 env: process.env,170 env: {
171 ...process.env,
172 ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
173 },
169 stdio: ["pipe", "pipe", "pipe"],174 stdio: ["pipe", "pipe", "pipe"],
170 });175 });
171 const output = readline.createInterface({ input: child.stdout });176 const output = readline.createInterface({ input: child.stdout });
apps/web/index.html+3-1
...@@ -3,13 +3,15 @@...@@ -3,13 +3,15 @@
3 <head>3 <head>
4 <meta charset="UTF-8" />4 <meta charset="UTF-8" />
5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />5 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 <link rel="icon" href="/favicon.ico" sizes="48x48" />
7 <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
6 <link rel="preconnect" href="https://fonts.googleapis.com" />8 <link rel="preconnect" href="https://fonts.googleapis.com" />
7 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />9 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
8 <link10 <link
9 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap"11 href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300..800;1,9..40,300..800&display=swap"
10 rel="stylesheet"12 rel="stylesheet"
11 />13 />
12 <title>T3 Code</title>14 <title>T3 Code (Alpha)</title>
13 </head>15 </head>
14 <body>16 <body>
15 <div id="root"></div>17 <div id="root"></div>
apps/web/public/apple-touch-icon.png created
Binary files /dev/null and b/apps/web/public/apple-touch-icon.png differ
apps/web/public/favicon-16x16.png created
Binary files /dev/null and b/apps/web/public/favicon-16x16.png differ
apps/web/public/favicon-32x32.png created
Binary files /dev/null and b/apps/web/public/favicon-32x32.png differ
apps/web/public/favicon.ico created
Binary files /dev/null and b/apps/web/public/favicon.ico differ
apps/web/public/favicon.svg created+4
...@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
apps/web/src/appSettings.ts created+109
...@@ -0,0 +1,109 @@
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+8
...@@ -37,6 +37,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer...@@ -37,6 +37,7 @@ import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuer
3737
38import { isElectron } from "../env";38import { isElectron } from "../env";
39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";39import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
40import { useAppSettings } from "../appSettings";
40import { buildBootstrapInput } from "../historyBootstrap";41import { buildBootstrapInput } from "../historyBootstrap";
41import {42import {
42 type ComposerTriggerKind,43 type ComposerTriggerKind,
...@@ -380,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -380,6 +381,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
380 const rawSearch = useSearch({ strict: false });381 const rawSearch = useSearch({ strict: false });
381 const api = useNativeApi();382 const api = useNativeApi();
382 const { resolvedTheme } = useTheme();383 const { resolvedTheme } = useTheme();
384 const { settings: appSettings } = useAppSettings();
383 const queryClient = useQueryClient();385 const queryClient = useQueryClient();
384 const createWorktreeMutation = useMutation(386 const createWorktreeMutation = useMutation(
385 gitCreateWorktreeMutationOptions({ api, queryClient }),387 gitCreateWorktreeMutationOptions({ api, queryClient }),
...@@ -651,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -651,6 +653,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
651 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";653 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
652 const runtimeSandboxMode =654 const runtimeSandboxMode =
653 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";655 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
656 const codexBinaryPath = appSettings.codexBinaryPath.trim();
657 const codexHomePath = appSettings.codexHomePath.trim();
654 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;658 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
655 const composerTrigger = useMemo(659 const composerTrigger = useMemo(
656 () => detectComposerTrigger(prompt, composerCursor),660 () => detectComposerTrigger(prompt, composerCursor),
...@@ -1524,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1524,6 +1528,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1524 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,1528 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
1525 model: selectedModel || undefined,1529 model: selectedModel || undefined,
1526 resumeThreadId: priorCodexThreadId ?? undefined,1530 resumeThreadId: priorCodexThreadId ?? undefined,
1531 ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}),
1532 ...(codexHomePath.length > 0 ? { codexHomePath } : {}),
1527 approvalPolicy: runtimeApprovalPolicy,1533 approvalPolicy: runtimeApprovalPolicy,
1528 sandboxMode: runtimeSandboxMode,1534 sandboxMode: runtimeSandboxMode,
1529 });1535 });
...@@ -1564,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1564,6 +1570,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1564 activeProject,1570 activeProject,
1565 activeThread,1571 activeThread,
1566 api,1572 api,
1573 codexBinaryPath,
1574 codexHomePath,
1567 dispatch,1575 dispatch,
1568 runtimeApprovalPolicy,1576 runtimeApprovalPolicy,
1569 runtimeSandboxMode,1577 runtimeSandboxMode,
apps/web/src/components/Sidebar.tsx+33-23
...@@ -1,10 +1,11 @@...@@ -1,10 +1,11 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";1import { TerminalIcon } from "lucide-react";
2import { useCallback, useEffect, useMemo, useState } from "react";2import { useCallback, useEffect, useMemo, useState } from "react";
3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";3import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";4import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5import { useNavigate, useParams } from "@tanstack/react-router";5import { useNavigate, useParams } from "@tanstack/react-router";
6import { useAppSettings } from "../appSettings";
6import { isElectron } from "../env";7import { isElectron } from "../env";
7import { useTheme } from "../hooks/useTheme";8import { APP_STAGE_LABEL } from "../branding";
8import { DEFAULT_MODEL } from "../model-logic";9import { DEFAULT_MODEL } from "../model-logic";
9import { derivePendingApprovals } from "../session-logic";10import { derivePendingApprovals } from "../session-logic";
10import { useStore } from "../store";11import { useStore } from "../store";
...@@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast";...@@ -17,7 +18,6 @@ import { toastManager } from "./ui/toast";
17import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";18import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
18import { createThread } from "../threadFactory";19import { createThread } from "../threadFactory";
1920
20const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
21const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];21const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2222
23function formatRelativeTime(iso: string): string {23function formatRelativeTime(iso: string): string {
...@@ -116,6 +116,7 @@ export default function Sidebar() {...@@ -116,6 +116,7 @@ export default function Sidebar() {
116 const { state, dispatch } = useStore();116 const { state, dispatch } = useStore();
117 const api = useNativeApi();117 const api = useNativeApi();
118 const navigate = useNavigate();118 const navigate = useNavigate();
119 const { settings: appSettings } = useAppSettings();
119 const params = useParams({ strict: false });120 const params = useParams({ strict: false });
120 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;121 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
121 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({122 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
...@@ -126,7 +127,6 @@ export default function Sidebar() {...@@ -126,7 +127,6 @@ export default function Sidebar() {
126 const removeWorktreeMutation = useMutation(127 const removeWorktreeMutation = useMutation(
127 gitRemoveWorktreeMutationOptions({ api, queryClient }),128 gitRemoveWorktreeMutationOptions({ api, queryClient }),
128 );129 );
129 const { theme, setTheme } = useTheme();
130 const [addingProject, setAddingProject] = useState(false);130 const [addingProject, setAddingProject] = useState(false);
131 const [newCwd, setNewCwd] = useState("");131 const [newCwd, setNewCwd] = useState("");
132 const [isPickingFolder, setIsPickingFolder] = useState(false);132 const [isPickingFolder, setIsPickingFolder] = useState(false);
...@@ -271,6 +271,17 @@ export default function Sidebar() {...@@ -271,6 +271,17 @@ export default function Sidebar() {
271271
272 const thread = state.threads.find((t) => t.id === threadId);272 const thread = state.threads.find((t) => t.id === threadId);
273 if (!thread) return;273 if (!thread) return;
274 if (appSettings.confirmThreadDelete) {
275 const confirmed = await api.dialogs.confirm(
276 [
277 `Delete thread \"${thread.title}\"?`,
278 "This permanently clears conversation history for this thread.",
279 ].join("\n"),
280 );
281 if (!confirmed) {
282 return;
283 }
284 }
274 const threadProject = state.projects.find((project) => project.id === thread.projectId);285 const threadProject = state.projects.find((project) => project.id === thread.projectId);
275 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);286 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
276 const displayWorktreePath = orphanedWorktreePath287 const displayWorktreePath = orphanedWorktreePath
...@@ -348,7 +359,16 @@ export default function Sidebar() {...@@ -348,7 +359,16 @@ export default function Sidebar() {
348 });359 });
349 }360 }
350 },361 },
351 [api, dispatch, navigate, removeWorktreeMutation, routeThreadId, state.projects, state.threads],362 [
363 api,
364 appSettings.confirmThreadDelete,
365 dispatch,
366 navigate,
367 removeWorktreeMutation,
368 routeThreadId,
369 state.projects,
370 state.threads,
371 ],
352 );372 );
353373
354 const handleProjectContextMenu = useCallback(374 const handleProjectContextMenu = useCallback(
...@@ -431,24 +451,14 @@ export default function Sidebar() {...@@ -431,24 +451,14 @@ export default function Sidebar() {
431 <div451 <div
432 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}452 className={`flex items-center gap-2.5 px-4 ${isElectron ? "drag-region h-[52px] pl-[76px]" : "py-4"}`}
433 >453 >
434 <span className="flex-1 text-sm font-semibold tracking-tight text-foreground">454 <div className="flex min-w-0 flex-1 items-center gap-2">
435 T3 <span className="font-normal text-muted-foreground">Code</span>455 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
436 </span>456 T3 <span className="font-normal text-muted-foreground">Code</span>
437 <button457 </span>
438 type="button"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">
439 className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground"459 {APP_STAGE_LABEL}
440 onClick={() => setTheme(THEME_CYCLE[theme])}460 </span>
441 aria-label={`Theme: ${theme}`}461 </div>
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>
452 </div>462 </div>
453463
454 {/* New thread (global) */}464 {/* New thread (global) */}
apps/web/src/main.tsx+2
...@@ -9,12 +9,14 @@ import "@xterm/xterm/css/xterm.css";...@@ -9,12 +9,14 @@ import "@xterm/xterm/css/xterm.css";
9import "highlight.js/styles/github-dark.css";9import "highlight.js/styles/github-dark.css";
10import "./index.css";10import "./index.css";
1111
12import { APP_DISPLAY_NAME } from "./branding";
12import { isElectron } from "./env";13import { isElectron } from "./env";
13import { routeTree } from "./routeTree.gen";14import { routeTree } from "./routeTree.gen";
1415
15const history = isElectron ? createHashHistory() : createBrowserHistory();16const history = isElectron ? createHashHistory() : createBrowserHistory();
1617
17const queryClient = new QueryClient();18const queryClient = new QueryClient();
19document.title = APP_DISPLAY_NAME;
1820
19const router = createRouter({21const router = createRouter({
20 routeTree,22 routeTree,
apps/web/src/routeTree.gen.ts+21-3
...@@ -11,6 +11,7 @@...@@ -11,6 +11,7 @@
11import { Route as rootRouteImport } from './routes/__root'11import { Route as rootRouteImport } from './routes/__root'
12import { Route as ChatRouteImport } from './routes/_chat'12import { Route as ChatRouteImport } from './routes/_chat'
13import { Route as ChatIndexRouteImport } from './routes/_chat.index'13import { Route as ChatIndexRouteImport } from './routes/_chat.index'
14import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
14import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'15import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
1516
16const ChatRoute = ChatRouteImport.update({17const ChatRoute = ChatRouteImport.update({
...@@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({...@@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
22 path: '/',23 path: '/',
23 getParentRoute: () => ChatRoute,24 getParentRoute: () => ChatRoute,
24} as any)25} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({
27 id: '/settings',
28 path: '/settings',
29 getParentRoute: () => ChatRoute,
30} as any)
25const ChatThreadIdRoute = ChatThreadIdRouteImport.update({31const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
26 id: '/$threadId',32 id: '/$threadId',
27 path: '/$threadId',33 path: '/$threadId',
...@@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({...@@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
31export interface FileRoutesByFullPath {37export interface FileRoutesByFullPath {
32 '/': typeof ChatIndexRoute38 '/': typeof ChatIndexRoute
33 '/$threadId': typeof ChatThreadIdRoute39 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute
34}41}
35export interface FileRoutesByTo {42export interface FileRoutesByTo {
36 '/$threadId': typeof ChatThreadIdRoute43 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute
37 '/': typeof ChatIndexRoute45 '/': typeof ChatIndexRoute
38}46}
39export interface FileRoutesById {47export interface FileRoutesById {
40 __root__: typeof rootRouteImport48 __root__: typeof rootRouteImport
41 '/_chat': typeof ChatRouteWithChildren49 '/_chat': typeof ChatRouteWithChildren
42 '/_chat/$threadId': typeof ChatThreadIdRoute50 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute
43 '/_chat/': typeof ChatIndexRoute52 '/_chat/': typeof ChatIndexRoute
44}53}
45export interface FileRouteTypes {54export interface FileRouteTypes {
46 fileRoutesByFullPath: FileRoutesByFullPath55 fileRoutesByFullPath: FileRoutesByFullPath
47 fullPaths: '/' | '/$threadId'56 fullPaths: '/' | '/$threadId' | '/settings'
48 fileRoutesByTo: FileRoutesByTo57 fileRoutesByTo: FileRoutesByTo
49 to: '/$threadId' | '/'58 to: '/$threadId' | '/settings' | '/'
50 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/'59 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'
51 fileRoutesById: FileRoutesById60 fileRoutesById: FileRoutesById
52}61}
53export interface RootRouteChildren {62export interface RootRouteChildren {
...@@ -70,6 +79,13 @@ declare module '@tanstack/react-router' {...@@ -70,6 +79,13 @@ declare module '@tanstack/react-router' {
70 preLoaderRoute: typeof ChatIndexRouteImport79 preLoaderRoute: typeof ChatIndexRouteImport
71 parentRoute: typeof ChatRoute80 parentRoute: typeof ChatRoute
72 }81 }
82 '/_chat/settings': {
83 id: '/_chat/settings'
84 path: '/settings'
85 fullPath: '/settings'
86 preLoaderRoute: typeof ChatSettingsRouteImport
87 parentRoute: typeof ChatRoute
88 }
73 '/_chat/$threadId': {89 '/_chat/$threadId': {
74 id: '/_chat/$threadId'90 id: '/_chat/$threadId'
75 path: '/$threadId'91 path: '/$threadId'
...@@ -82,11 +98,13 @@ declare module '@tanstack/react-router' {...@@ -82,11 +98,13 @@ declare module '@tanstack/react-router' {
8298
83interface ChatRouteChildren {99interface ChatRouteChildren {
84 ChatThreadIdRoute: typeof ChatThreadIdRoute100 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
85 ChatIndexRoute: typeof ChatIndexRoute102 ChatIndexRoute: typeof ChatIndexRoute
86}103}
87104
88const ChatRouteChildren: ChatRouteChildren = {105const ChatRouteChildren: ChatRouteChildren = {
89 ChatThreadIdRoute: ChatThreadIdRoute,106 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
90 ChatIndexRoute: ChatIndexRoute,108 ChatIndexRoute: ChatIndexRoute,
91}109}
92110
apps/web/src/routes/__root.tsx+4-1
...@@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery";...@@ -9,6 +9,7 @@ import { invalidateGitQueries } from "../lib/gitReactQuery";
9import { DEFAULT_MODEL } from "../model-logic";9import { DEFAULT_MODEL } from "../model-logic";
10import { useStore } from "../store";10import { useStore } from "../store";
11import { onServerWelcome } from "../wsNativeApi";11import { onServerWelcome } from "../wsNativeApi";
12import { APP_DISPLAY_NAME } from "../branding";
1213
13export const Route = createRootRouteWithContext<{14export const Route = createRootRouteWithContext<{
14 queryClient: QueryClient;15 queryClient: QueryClient;
...@@ -23,7 +24,9 @@ function RootRouteView() {...@@ -23,7 +24,9 @@ function RootRouteView() {
23 return (24 return (
24 <div className="flex h-screen flex-col bg-background text-foreground">25 <div className="flex h-screen flex-col bg-background text-foreground">
25 <div className="flex flex-1 items-center justify-center">26 <div className="flex flex-1 items-center justify-center">
26 <p className="text-sm text-muted-foreground">Connecting to T3 Code server...</p>27 <p className="text-sm text-muted-foreground">
28 Connecting to {APP_DISPLAY_NAME} server...
29 </p>
27 </div>30 </div>
28 </div>31 </div>
29 );32 );
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+20-1
...@@ -1,9 +1,28 @@...@@ -1,9 +1,28 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { useEffect } from "react";
23
3import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";4import { DiffWorkerPoolProvider } from "../components/DiffWorkerPoolProvider";
4import Sidebar from "../components/Sidebar";5import Sidebar from "../components/Sidebar";
56
6function ChatRouteLayout() {7function ChatRouteLayout() {
8 const navigate = useNavigate();
9
10 useEffect(() => {
11 const onMenuAction = window.desktopBridge?.onMenuAction;
12 if (typeof onMenuAction !== "function") {
13 return;
14 }
15
16 const unsubscribe = onMenuAction((action) => {
17 if (action !== "open-settings") return;
18 void navigate({ to: "/settings" });
19 });
20
21 return () => {
22 unsubscribe?.();
23 };
24 }, [navigate]);
25
7 return (26 return (
8 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">27 <div className="flex h-screen overflow-hidden bg-background text-foreground isolate">
9 <Sidebar />28 <Sidebar />
apps/web/src/vite-env.d.ts+1
...@@ -8,6 +8,7 @@ interface DesktopBridge {...@@ -8,6 +8,7 @@ interface DesktopBridge {
8 confirm: (message: string) => Promise<boolean>;8 confirm: (message: string) => Promise<boolean>;
9 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;9 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
10 openExternal: (url: string) => Promise<boolean>;10 openExternal: (url: string) => Promise<boolean>;
11 onMenuAction: (listener: (action: string) => void) => () => void;
11}12}
1213
13declare global {14declare global {
assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png created
Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@1x.png differ
assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png created
Binary files /dev/null and b/assets/T3 Code blueprint-macOS-Default-1024x1024@2x.png differ
assets/T3 Code logo.svg created+4
...@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
assets/T3 Code logo@16x.png created
Binary files /dev/null and b/assets/T3 Code logo@16x.png differ
assets/T3 Code logo@32x.png created
Binary files /dev/null and b/assets/T3 Code logo@32x.png differ
assets/T3 Code logo@8x.png created
Binary files /dev/null and b/assets/T3 Code logo@8x.png differ
assets/T3 Code-macOS-Default-1024x1024@1x.png created
Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@1x.png differ
assets/T3 Code-macOS-Default-1024x1024@4x.png created
Binary files /dev/null and b/assets/T3 Code-macOS-Default-1024x1024@4x.png differ
assets/logo-1024.png created
Binary files /dev/null and b/assets/logo-1024.png differ
assets/logo.svg created+4
...@@ -0,0 +1,4 @@
1<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
2<path d="M0 10C0 4.47715 4.47715 0 10 0H118C123.523 0 128 4.47715 128 10V118C128 123.523 123.523 128 118 128H10C4.47715 128 0 123.523 0 118V10Z" fill="black"/>
3<path d="M33.4509 93V47.56H15.5309V37H64.3309V47.56H46.4109V93H33.4509ZM86.7253 93.96C82.832 93.96 78.9653 93.4533 75.1253 92.44C71.2853 91.3733 68.032 89.88 65.3653 87.96L70.4053 78.04C72.5386 79.5867 75.0186 80.8133 77.8453 81.72C80.672 82.6267 83.5253 83.08 86.4053 83.08C89.6586 83.08 92.2186 82.44 94.0853 81.16C95.952 79.88 96.8853 78.12 96.8853 75.88C96.8853 73.7467 96.0586 72.0667 94.4053 70.84C92.752 69.6133 90.0853 69 86.4053 69H80.4853V60.44L96.0853 42.76L97.5253 47.4H68.1653V37H107.365V45.4L91.8453 63.08L85.2853 59.32H89.0453C95.9253 59.32 101.125 60.8667 104.645 63.96C108.165 67.0533 109.925 71.0267 109.925 75.88C109.925 79.0267 109.099 81.9867 107.445 84.76C105.792 87.48 103.259 89.6933 99.8453 91.4C96.432 93.1067 92.0586 93.96 86.7253 93.96Z" fill="white"/>
4</svg>
assets/macos-icon-1024.png created
Binary files /dev/null and b/assets/macos-icon-1024.png differ
package.json+3
...@@ -20,6 +20,9 @@...@@ -20,6 +20,9 @@
20 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",20 "test:desktop-smoke": "turbo run smoke-test --filter=@t3tools/desktop",
21 "fmt": "oxfmt",21 "fmt": "oxfmt",
22 "build:contracts": "turbo run build --filter=@t3tools/contracts",22 "build:contracts": "turbo run build --filter=@t3tools/contracts",
23 "dist:desktop:dmg": "node scripts/build-desktop-dmg.mjs",
24 "dist:desktop:dmg:arm64": "node scripts/build-desktop-dmg.mjs --arch arm64",
25 "dist:desktop:dmg:x64": "node scripts/build-desktop-dmg.mjs --arch x64",
23 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",26 "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo",
24 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"27 "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs"
25 },28 },
packages/contracts/src/provider.test.ts+22
...@@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => {...@@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => {
37 }),37 }),
38 ).toThrow();38 ).toThrow();
39 });39 });
40
41 it("accepts optional codex binary and home path overrides", () => {
42 const parsed = providerSessionStartInputSchema.parse({
43 codexBinaryPath: "/opt/codex/bin/codex",
44 codexHomePath: "/Users/theo/.codex",
45 });
46 expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex");
47 expect(parsed.codexHomePath).toBe("/Users/theo/.codex");
48 });
49
50 it("rejects blank codex overrides", () => {
51 expect(() =>
52 providerSessionStartInputSchema.parse({
53 codexBinaryPath: " ",
54 }),
55 ).toThrow();
56 expect(() =>
57 providerSessionStartInputSchema.parse({
58 codexHomePath: " ",
59 }),
60 ).toThrow();
61 });
40});62});
4163
42describe("providerSendTurnInputSchema", () => {64describe("providerSendTurnInputSchema", () => {
packages/contracts/src/provider.ts+2
...@@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({...@@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({
50 cwd: z.string().min(1).optional(),50 cwd: z.string().min(1).optional(),
51 model: z.string().trim().min(1).optional(),51 model: z.string().trim().min(1).optional(),
52 resumeThreadId: z.string().trim().min(1).optional(),52 resumeThreadId: z.string().trim().min(1).optional(),
53 codexBinaryPath: z.string().trim().min(1).optional(),
54 codexHomePath: z.string().trim().min(1).optional(),
53 approvalPolicy: providerApprovalPolicySchema.default("never"),55 approvalPolicy: providerApprovalPolicySchema.default("never"),
54 sandboxMode: providerSandboxModeSchema.default("workspace-write"),56 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
55});57});
scripts/build-desktop-dmg.mjs created+343
...@@ -0,0 +1,343 @@
1#!/usr/bin/env node
2
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});