authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-19 02:42:24-08:00
committergravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-02-19 02:42:24-08:00
logf8883d0495aa6fb3162077667827b187220c7a7e
tree1ea336d1bdc64c153b4bb66ede756955d49ea576
parent8cb2b3fcfe451932681e73e38adb25321fdcb4cb
signature Commit is signed but in an unrecognized format.

settings page


12 files changed, 570 insertions(+), 29 deletions(-)

apps/desktop/src/main.ts+85-1
...@@ -5,7 +5,15 @@ import net from "node:net";...@@ -5,7 +5,15 @@ 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 shell,
16} from "electron";
917
10import { showDesktopConfirmDialog } from "./confirmDialog";18import { showDesktopConfirmDialog } from "./confirmDialog";
11import { fixPath } from "./fixPath";19import { fixPath } from "./fixPath";
...@@ -16,6 +24,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";...@@ -16,6 +24,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
16const CONFIRM_CHANNEL = "desktop:confirm";24const CONFIRM_CHANNEL = "desktop:confirm";
17const CONTEXT_MENU_CHANNEL = "desktop:context-menu";25const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
18const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";26const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
27const MENU_ACTION_CHANNEL = "desktop:menu-action";
19const ROOT_DIR = path.resolve(__dirname, "../../..");28const ROOT_DIR = path.resolve(__dirname, "../../..");
20const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");29const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
21const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");30const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
...@@ -33,6 +42,80 @@ let restartAttempt = 0;...@@ -33,6 +42,80 @@ let restartAttempt = 0;
33let restartTimer: ReturnType<typeof setTimeout> | null = null;42let restartTimer: ReturnType<typeof setTimeout> | null = null;
34let isQuitting = false;43let isQuitting = false;
3544
45function dispatchMenuAction(action: string): void {
46 const existingWindow = BrowserWindow.getFocusedWindow() ?? mainWindow ?? BrowserWindow.getAllWindows()[0];
47 const targetWindow = existingWindow ?? createWindow();
48 if (!existingWindow) {
49 mainWindow = targetWindow;
50 }
51
52 const send = () => {
53 if (targetWindow.isDestroyed()) return;
54 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
55 if (!targetWindow.isVisible()) {
56 targetWindow.show();
57 }
58 targetWindow.focus();
59 };
60
61 if (targetWindow.webContents.isLoadingMainFrame()) {
62 targetWindow.webContents.once("did-finish-load", send);
63 return;
64 }
65
66 send();
67}
68
69function configureApplicationMenu(): void {
70 const template: MenuItemConstructorOptions[] = [];
71
72 if (process.platform === "darwin") {
73 template.push({
74 label: app.name,
75 submenu: [
76 { role: "about" },
77 {
78 label: "Settings...",
79 accelerator: "CmdOrCtrl+,",
80 click: () => dispatchMenuAction("open-settings"),
81 },
82 { type: "separator" },
83 { role: "services" },
84 { type: "separator" },
85 { role: "hide" },
86 { role: "hideOthers" },
87 { role: "unhide" },
88 { type: "separator" },
89 { role: "quit" },
90 ],
91 });
92 }
93
94 template.push(
95 {
96 label: "File",
97 submenu: [
98 ...(process.platform === "darwin"
99 ? []
100 : [
101 {
102 label: "Settings...",
103 accelerator: "CmdOrCtrl+,",
104 click: () => dispatchMenuAction("open-settings"),
105 },
106 { type: "separator" as const },
107 ]),
108 { role: process.platform === "darwin" ? "close" : "quit" },
109 ],
110 },
111 { role: "editMenu" },
112 { role: "viewMenu" },
113 { role: "windowMenu" },
114 );
115
116 Menu.setApplicationMenu(Menu.buildFromTemplate(template));
117}
118
36function resolveResourcePath(fileName: string): string | null {119function resolveResourcePath(fileName: string): string | null {
37 const candidates = [120 const candidates = [
38 path.join(__dirname, "../resources", fileName),121 path.join(__dirname, "../resources", fileName),
...@@ -322,6 +405,7 @@ app.on("before-quit", () => {...@@ -322,6 +405,7 @@ app.on("before-quit", () => {
322405
323app.whenReady().then(() => {406app.whenReady().then(() => {
324 configureAppIdentity();407 configureAppIdentity();
408 configureApplicationMenu();
325 void bootstrap();409 void bootstrap();
326410
327 app.on("activate", () => {411 app.on("activate", () => {
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/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/components/ChatView.tsx+8
...@@ -35,6 +35,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";...@@ -35,6 +35,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";35import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3636
37import { isElectron } from "../env";37import { isElectron } from "../env";
38import { useAppSettings } from "../appSettings";
38import { buildBootstrapInput } from "../historyBootstrap";39import { buildBootstrapInput } from "../historyBootstrap";
39import {40import {
40 type ComposerTriggerKind,41 type ComposerTriggerKind,
...@@ -376,6 +377,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -376,6 +377,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
376 const { state, dispatch } = useStore();377 const { state, dispatch } = useStore();
377 const api = useNativeApi();378 const api = useNativeApi();
378 const { resolvedTheme } = useTheme();379 const { resolvedTheme } = useTheme();
380 const { settings: appSettings } = useAppSettings();
379 const queryClient = useQueryClient();381 const queryClient = useQueryClient();
380 const createWorktreeMutation = useMutation(382 const createWorktreeMutation = useMutation(
381 gitCreateWorktreeMutationOptions({ api, queryClient }),383 gitCreateWorktreeMutationOptions({ api, queryClient }),
...@@ -642,6 +644,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -642,6 +644,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
642 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";644 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
643 const runtimeSandboxMode =645 const runtimeSandboxMode =
644 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";646 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
647 const codexBinaryPath = appSettings.codexBinaryPath.trim();
648 const codexHomePath = appSettings.codexHomePath.trim();
645 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;649 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
646 const composerTrigger = useMemo(650 const composerTrigger = useMemo(
647 () => detectComposerTrigger(prompt, composerCursor),651 () => detectComposerTrigger(prompt, composerCursor),
...@@ -1515,6 +1519,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1515,6 +1519,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1515 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,1519 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
1516 model: selectedModel || undefined,1520 model: selectedModel || undefined,
1517 resumeThreadId: priorCodexThreadId ?? undefined,1521 resumeThreadId: priorCodexThreadId ?? undefined,
1522 ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}),
1523 ...(codexHomePath.length > 0 ? { codexHomePath } : {}),
1518 approvalPolicy: runtimeApprovalPolicy,1524 approvalPolicy: runtimeApprovalPolicy,
1519 sandboxMode: runtimeSandboxMode,1525 sandboxMode: runtimeSandboxMode,
1520 });1526 });
...@@ -1555,6 +1561,8 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1555,6 +1561,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
1555 activeProject,1561 activeProject,
1556 activeThread,1562 activeThread,
1557 api,1563 api,
1564 codexBinaryPath,
1565 codexHomePath,
1558 dispatch,1566 dispatch,
1559 runtimeApprovalPolicy,1567 runtimeApprovalPolicy,
1560 runtimeSandboxMode,1568 runtimeSandboxMode,
apps/web/src/components/Sidebar.tsx+25-21
...@@ -1,11 +1,11 @@...@@ -1,11 +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 { APP_STAGE_LABEL } from "../branding";8import { APP_STAGE_LABEL } from "../branding";
8import { useTheme } from "../hooks/useTheme";
9import { DEFAULT_MODEL } from "../model-logic";9import { DEFAULT_MODEL } from "../model-logic";
10import { derivePendingApprovals } from "../session-logic";10import { derivePendingApprovals } from "../session-logic";
11import { useStore } from "../store";11import { useStore } from "../store";
...@@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast";...@@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast";
18import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";18import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
19import { createThread } from "../threadFactory";19import { createThread } from "../threadFactory";
2020
21const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
22const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];21const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2322
24function formatRelativeTime(iso: string): string {23function formatRelativeTime(iso: string): string {
...@@ -117,6 +116,7 @@ export default function Sidebar() {...@@ -117,6 +116,7 @@ export default function Sidebar() {
117 const { state, dispatch } = useStore();116 const { state, dispatch } = useStore();
118 const api = useNativeApi();117 const api = useNativeApi();
119 const navigate = useNavigate();118 const navigate = useNavigate();
119 const { settings: appSettings } = useAppSettings();
120 const params = useParams({ strict: false });120 const params = useParams({ strict: false });
121 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;121 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
122 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({122 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
...@@ -127,7 +127,6 @@ export default function Sidebar() {...@@ -127,7 +127,6 @@ export default function Sidebar() {
127 const removeWorktreeMutation = useMutation(127 const removeWorktreeMutation = useMutation(
128 gitRemoveWorktreeMutationOptions({ api, queryClient }),128 gitRemoveWorktreeMutationOptions({ api, queryClient }),
129 );129 );
130 const { theme, setTheme } = useTheme();
131 const [addingProject, setAddingProject] = useState(false);130 const [addingProject, setAddingProject] = useState(false);
132 const [newCwd, setNewCwd] = useState("");131 const [newCwd, setNewCwd] = useState("");
133 const [isPickingFolder, setIsPickingFolder] = useState(false);132 const [isPickingFolder, setIsPickingFolder] = useState(false);
...@@ -272,6 +271,17 @@ export default function Sidebar() {...@@ -272,6 +271,17 @@ export default function Sidebar() {
272271
273 const thread = state.threads.find((t) => t.id === threadId);272 const thread = state.threads.find((t) => t.id === threadId);
274 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 }
275 const threadProject = state.projects.find((project) => project.id === thread.projectId);285 const threadProject = state.projects.find((project) => project.id === thread.projectId);
276 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);286 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
277 const displayWorktreePath = orphanedWorktreePath287 const displayWorktreePath = orphanedWorktreePath
...@@ -349,7 +359,16 @@ export default function Sidebar() {...@@ -349,7 +359,16 @@ export default function Sidebar() {
349 });359 });
350 }360 }
351 },361 },
352 [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 ],
353 );372 );
354373
355 const handleProjectContextMenu = useCallback(374 const handleProjectContextMenu = useCallback(
...@@ -436,25 +455,10 @@ export default function Sidebar() {...@@ -436,25 +455,10 @@ export default function Sidebar() {
436 <span className="truncate text-sm font-semibold tracking-tight text-foreground">455 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
437 T3 <span className="font-normal text-muted-foreground">Code</span>456 T3 <span className="font-normal text-muted-foreground">Code</span>
438 </span>457 </span>
439 <span className="rounded border border-border bg-secondary px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">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">
440 {APP_STAGE_LABEL}459 {APP_STAGE_LABEL}
441 </span>460 </span>
442 </div>461 </div>
443 <button
444 type="button"
445 className="rounded-md p-1.5 text-muted-foreground/80 transition-colors duration-150 hover:bg-accent hover:text-muted-foreground"
446 onClick={() => setTheme(THEME_CYCLE[theme])}
447 aria-label={`Theme: ${theme}`}
448 title={`Theme: ${theme}`}
449 >
450 {theme === "system" ? (
451 <MonitorIcon className="size-3.5" />
452 ) : theme === "light" ? (
453 <SunIcon className="size-3.5" />
454 ) : (
455 <MoonIcon className="size-3.5" />
456 )}
457 </button>
458 </div>462 </div>
459463
460 {/* New thread (global) */}464 {/* New thread (global) */}
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/_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+19-2
...@@ -1,5 +1,5 @@...@@ -1,5 +1,5 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode } from "react";2import { Activity, Suspense, lazy, type ReactNode, useEffect } from "react";
33
4import Sidebar from "../components/Sidebar";4import Sidebar from "../components/Sidebar";
5import { useMediaQuery } from "../hooks/useMediaQuery";5import { useMediaQuery } from "../hooks/useMediaQuery";
...@@ -48,6 +48,23 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {...@@ -48,6 +48,23 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
48function ChatRouteLayout() {48function ChatRouteLayout() {
49 const { state } = useStore();49 const { state } = useStore();
50 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);50 const shouldUseDiffSheet = useMediaQuery(DIFF_INLINE_LAYOUT_MEDIA_QUERY);
51 const navigate = useNavigate();
52
53 useEffect(() => {
54 const onMenuAction = window.desktopBridge?.onMenuAction;
55 if (typeof onMenuAction !== "function") {
56 return;
57 }
58
59 const unsubscribe = onMenuAction((action) => {
60 if (action !== "open-settings") return;
61 void navigate({ to: "/settings" });
62 });
63
64 return () => {
65 unsubscribe?.();
66 };
67 }, [navigate]);
5168
52 const diffLoadingFallback =69 const diffLoadingFallback =
53 !state.diffOpen || shouldUseDiffSheet ? (70 !state.diffOpen || shouldUseDiffSheet ? (
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 {
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});