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";
55import os from "node:os";
66import path from "node:path";
77
8import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron";
8import {
9 app,
10 BrowserWindow,
11 dialog,
12 ipcMain,
13 Menu,
14 type MenuItemConstructorOptions,
15 shell,
16} from "electron";
917
1018import { showDesktopConfirmDialog } from "./confirmDialog";
1119import { fixPath } from "./fixPath";
......@@ -16,6 +24,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
1624const CONFIRM_CHANNEL = "desktop:confirm";
1725const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
1826const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
27const MENU_ACTION_CHANNEL = "desktop:menu-action";
1928const ROOT_DIR = path.resolve(__dirname, "../../..");
2029const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs");
2130const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html");
......@@ -33,6 +42,80 @@ let restartAttempt = 0;
3342let restartTimer: ReturnType<typeof setTimeout> | null = null;
3443let 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
36119function resolveResourcePath(fileName: string): string | null {
37120 const candidates = [
38121 path.join(__dirname, "../resources", fileName),
......@@ -322,6 +405,7 @@ app.on("before-quit", () => {
322405
323406app.whenReady().then(() => {
324407 configureAppIdentity();
408 configureApplicationMenu();
325409 void bootstrap();
326410
327411 app.on("activate", () => {
apps/desktop/src/preload.ts+12
......@@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
44const CONFIRM_CHANNEL = "desktop:confirm";
55const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
66const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
7const MENU_ACTION_CHANNEL = "desktop:menu-action";
78const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null;
89
910contextBridge.exposeInMainWorld("desktopBridge", {
......@@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", {
1314 showContextMenu: (items: { id: string; label: string }[]) =>
1415 ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>,
1516 openExternal: (url: string) => ipcRenderer.invoke(OPEN_EXTERNAL_CHANNEL, url) as Promise<boolean>,
17 onMenuAction: (listener: (action: string) => void) => {
18 const wrappedListener = (_event: Electron.IpcRendererEvent, action: unknown) => {
19 if (typeof action !== "string") return;
20 listener(action);
21 };
22
23 ipcRenderer.on(MENU_ACTION_CHANNEL, wrappedListener);
24 return () => {
25 ipcRenderer.removeListener(MENU_ACTION_CHANNEL, wrappedListener);
26 };
27 },
1628});
apps/server/src/codexAppServerManager.ts+7-2
......@@ -163,9 +163,14 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
163163 updatedAt: now,
164164 };
165165
166 const child = spawn("codex", ["app-server"], {
166 const codexBinaryPath = input.codexBinaryPath?.trim() || "codex";
167 const codexHomePath = input.codexHomePath?.trim();
168 const child = spawn(codexBinaryPath, ["app-server"], {
167169 cwd: resolvedCwd,
168 env: process.env,
170 env: {
171 ...process.env,
172 ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}),
173 },
169174 stdio: ["pipe", "pipe", "pipe"],
170175 });
171176 const output = readline.createInterface({ input: child.stdout });
apps/web/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";
3535import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
3636
3737import { isElectron } from "../env";
38import { useAppSettings } from "../appSettings";
3839import { buildBootstrapInput } from "../historyBootstrap";
3940import {
4041 type ComposerTriggerKind,
......@@ -376,6 +377,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
376377 const { state, dispatch } = useStore();
377378 const api = useNativeApi();
378379 const { resolvedTheme } = useTheme();
380 const { settings: appSettings } = useAppSettings();
379381 const queryClient = useQueryClient();
380382 const createWorktreeMutation = useMutation(
381383 gitCreateWorktreeMutationOptions({ api, queryClient }),
......@@ -642,6 +644,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
642644 const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
643645 const runtimeSandboxMode =
644646 state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write";
647 const codexBinaryPath = appSettings.codexBinaryPath.trim();
648 const codexHomePath = appSettings.codexHomePath.trim();
645649 const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null;
646650 const composerTrigger = useMemo(
647651 () => detectComposerTrigger(prompt, composerCursor),
......@@ -1515,6 +1519,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
15151519 cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd,
15161520 model: selectedModel || undefined,
15171521 resumeThreadId: priorCodexThreadId ?? undefined,
1522 ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}),
1523 ...(codexHomePath.length > 0 ? { codexHomePath } : {}),
15181524 approvalPolicy: runtimeApprovalPolicy,
15191525 sandboxMode: runtimeSandboxMode,
15201526 });
......@@ -1555,6 +1561,8 @@ export default function ChatView({ threadId }: ChatViewProps) {
15551561 activeProject,
15561562 activeThread,
15571563 api,
1564 codexBinaryPath,
1565 codexHomePath,
15581566 dispatch,
15591567 runtimeApprovalPolicy,
15601568 runtimeSandboxMode,
apps/web/src/components/Sidebar.tsx+25-21
......@@ -1,11 +1,11 @@
1import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react";
1import { TerminalIcon } from "lucide-react";
22import { useCallback, useEffect, useMemo, useState } from "react";
33import type { ResolvedKeybindingsConfig } from "@t3tools/contracts";
44import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
55import { useNavigate, useParams } from "@tanstack/react-router";
6import { useAppSettings } from "../appSettings";
67import { isElectron } from "../env";
78import { APP_STAGE_LABEL } from "../branding";
8import { useTheme } from "../hooks/useTheme";
99import { DEFAULT_MODEL } from "../model-logic";
1010import { derivePendingApprovals } from "../session-logic";
1111import { useStore } from "../store";
......@@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast";
1818import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup";
1919import { createThread } from "../threadFactory";
2020
21const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const;
2221const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = [];
2322
2423function formatRelativeTime(iso: string): string {
......@@ -117,6 +116,7 @@ export default function Sidebar() {
117116 const { state, dispatch } = useStore();
118117 const api = useNativeApi();
119118 const navigate = useNavigate();
119 const { settings: appSettings } = useAppSettings();
120120 const params = useParams({ strict: false });
121121 const routeThreadId = typeof params.threadId === "string" ? params.threadId : null;
122122 const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({
......@@ -127,7 +127,6 @@ export default function Sidebar() {
127127 const removeWorktreeMutation = useMutation(
128128 gitRemoveWorktreeMutationOptions({ api, queryClient }),
129129 );
130 const { theme, setTheme } = useTheme();
131130 const [addingProject, setAddingProject] = useState(false);
132131 const [newCwd, setNewCwd] = useState("");
133132 const [isPickingFolder, setIsPickingFolder] = useState(false);
......@@ -272,6 +271,17 @@ export default function Sidebar() {
272271
273272 const thread = state.threads.find((t) => t.id === threadId);
274273 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 }
275285 const threadProject = state.projects.find((project) => project.id === thread.projectId);
276286 const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId);
277287 const displayWorktreePath = orphanedWorktreePath
......@@ -349,7 +359,16 @@ export default function Sidebar() {
349359 });
350360 }
351361 },
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 ],
353372 );
354373
355374 const handleProjectContextMenu = useCallback(
......@@ -436,25 +455,10 @@ export default function Sidebar() {
436455 <span className="truncate text-sm font-semibold tracking-tight text-foreground">
437456 T3 <span className="font-normal text-muted-foreground">Code</span>
438457 </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">
440459 {APP_STAGE_LABEL}
441460 </span>
442461 </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>
458462 </div>
459463
460464 {/* New thread (global) */}
apps/web/src/routeTree.gen.ts+21-3
......@@ -11,6 +11,7 @@
1111import { Route as rootRouteImport } from './routes/__root'
1212import { Route as ChatRouteImport } from './routes/_chat'
1313import { Route as ChatIndexRouteImport } from './routes/_chat.index'
14import { Route as ChatSettingsRouteImport } from './routes/_chat.settings'
1415import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId'
1516
1617const ChatRoute = ChatRouteImport.update({
......@@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
2223 path: '/',
2324 getParentRoute: () => ChatRoute,
2425} as any)
26const ChatSettingsRoute = ChatSettingsRouteImport.update({
27 id: '/settings',
28 path: '/settings',
29 getParentRoute: () => ChatRoute,
30} as any)
2531const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
2632 id: '/$threadId',
2733 path: '/$threadId',
......@@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({
3137export interface FileRoutesByFullPath {
3238 '/': typeof ChatIndexRoute
3339 '/$threadId': typeof ChatThreadIdRoute
40 '/settings': typeof ChatSettingsRoute
3441}
3542export interface FileRoutesByTo {
3643 '/$threadId': typeof ChatThreadIdRoute
44 '/settings': typeof ChatSettingsRoute
3745 '/': typeof ChatIndexRoute
3846}
3947export interface FileRoutesById {
4048 __root__: typeof rootRouteImport
4149 '/_chat': typeof ChatRouteWithChildren
4250 '/_chat/$threadId': typeof ChatThreadIdRoute
51 '/_chat/settings': typeof ChatSettingsRoute
4352 '/_chat/': typeof ChatIndexRoute
4453}
4554export interface FileRouteTypes {
4655 fileRoutesByFullPath: FileRoutesByFullPath
47 fullPaths: '/' | '/$threadId'
56 fullPaths: '/' | '/$threadId' | '/settings'
4857 fileRoutesByTo: FileRoutesByTo
49 to: '/$threadId' | '/'
50 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/'
58 to: '/$threadId' | '/settings' | '/'
59 id: '__root__' | '/_chat' | '/_chat/$threadId' | '/_chat/settings' | '/_chat/'
5160 fileRoutesById: FileRoutesById
5261}
5362export interface RootRouteChildren {
......@@ -70,6 +79,13 @@ declare module '@tanstack/react-router' {
7079 preLoaderRoute: typeof ChatIndexRouteImport
7180 parentRoute: typeof ChatRoute
7281 }
82 '/_chat/settings': {
83 id: '/_chat/settings'
84 path: '/settings'
85 fullPath: '/settings'
86 preLoaderRoute: typeof ChatSettingsRouteImport
87 parentRoute: typeof ChatRoute
88 }
7389 '/_chat/$threadId': {
7490 id: '/_chat/$threadId'
7591 path: '/$threadId'
......@@ -82,11 +98,13 @@ declare module '@tanstack/react-router' {
8298
8399interface ChatRouteChildren {
84100 ChatThreadIdRoute: typeof ChatThreadIdRoute
101 ChatSettingsRoute: typeof ChatSettingsRoute
85102 ChatIndexRoute: typeof ChatIndexRoute
86103}
87104
88105const ChatRouteChildren: ChatRouteChildren = {
89106 ChatThreadIdRoute: ChatThreadIdRoute,
107 ChatSettingsRoute: ChatSettingsRoute,
90108 ChatIndexRoute: ChatIndexRoute,
91109}
92110
apps/web/src/routes/_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 @@
1import { Outlet, createFileRoute } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode } from "react";
1import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router";
2import { Activity, Suspense, lazy, type ReactNode, useEffect } from "react";
33
44import Sidebar from "../components/Sidebar";
55import { useMediaQuery } from "../hooks/useMediaQuery";
......@@ -48,6 +48,23 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => {
4848function ChatRouteLayout() {
4949 const { state } = useStore();
5050 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
5269 const diffLoadingFallback =
5370 !state.diffOpen || shouldUseDiffSheet ? (
apps/web/src/vite-env.d.ts+1
......@@ -8,6 +8,7 @@ interface DesktopBridge {
88 confirm: (message: string) => Promise<boolean>;
99 showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>;
1010 openExternal: (url: string) => Promise<boolean>;
11 onMenuAction: (listener: (action: string) => void) => () => void;
1112}
1213
1314declare global {
packages/contracts/src/provider.test.ts+22
......@@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => {
3737 }),
3838 ).toThrow();
3939 });
40
41 it("accepts optional codex binary and home path overrides", () => {
42 const parsed = providerSessionStartInputSchema.parse({
43 codexBinaryPath: "/opt/codex/bin/codex",
44 codexHomePath: "/Users/theo/.codex",
45 });
46 expect(parsed.codexBinaryPath).toBe("/opt/codex/bin/codex");
47 expect(parsed.codexHomePath).toBe("/Users/theo/.codex");
48 });
49
50 it("rejects blank codex overrides", () => {
51 expect(() =>
52 providerSessionStartInputSchema.parse({
53 codexBinaryPath: " ",
54 }),
55 ).toThrow();
56 expect(() =>
57 providerSessionStartInputSchema.parse({
58 codexHomePath: " ",
59 }),
60 ).toThrow();
61 });
4062});
4163
4264describe("providerSendTurnInputSchema", () => {
packages/contracts/src/provider.ts+2
......@@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({
5050 cwd: z.string().min(1).optional(),
5151 model: z.string().trim().min(1).optional(),
5252 resumeThreadId: z.string().trim().min(1).optional(),
53 codexBinaryPath: z.string().trim().min(1).optional(),
54 codexHomePath: z.string().trim().min(1).optional(),
5355 approvalPolicy: providerApprovalPolicySchema.default("never"),
5456 sandboxMode: providerSandboxModeSchema.default("workspace-write"),
5557});