| author | |
| committer | |
| log | f8883d0495aa6fb3162077667827b187220c7a7e |
| tree | 1ea336d1bdc64c153b4bb66ede756955d49ea576 |
| parent | 8cb2b3fcfe451932681e73e38adb25321fdcb4cb |
| signature | Commit is signed but in an unrecognized format. |
12 files changed, 570 insertions(+), 29 deletions(-)
apps/desktop/src/main.ts+85-1| ... | ... | @@ -5,7 +5,15 @@ import net from "node:net"; |
| 5 | 5 | import os from "node:os"; |
| 6 | 6 | import path from "node:path"; |
| 7 | 7 | |
| 8 | import { app, BrowserWindow, dialog, ipcMain, Menu, shell } from "electron"; | |
| 8 | import { | |
| 9 | app, | |
| 10 | BrowserWindow, | |
| 11 | dialog, | |
| 12 | ipcMain, | |
| 13 | Menu, | |
| 14 | type MenuItemConstructorOptions, | |
| 15 | shell, | |
| 16 | } from "electron"; | |
| 9 | 17 | |
| 10 | 18 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 11 | 19 | import { fixPath } from "./fixPath"; |
| ... | ... | @@ -16,6 +24,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 16 | 24 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 17 | 25 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 18 | 26 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 27 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 19 | 28 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 20 | 29 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); |
| 21 | 30 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); |
| ... | ... | @@ -33,6 +42,80 @@ let restartAttempt = 0; |
| 33 | 42 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 34 | 43 | let isQuitting = false; |
| 35 | 44 | |
| 45 | function 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 | ||
| 69 | function 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 | ||
| 36 | 119 | function resolveResourcePath(fileName: string): string | null { |
| 37 | 120 | const candidates = [ |
| 38 | 121 | path.join(__dirname, "../resources", fileName), |
| ... | ... | @@ -322,6 +405,7 @@ app.on("before-quit", () => { |
| 322 | 405 | |
| 323 | 406 | app.whenReady().then(() => { |
| 324 | 407 | configureAppIdentity(); |
| 408 | configureApplicationMenu(); | |
| 325 | 409 | void bootstrap(); |
| 326 | 410 | |
| 327 | 411 | app.on("activate", () => { |
apps/desktop/src/preload.ts+12| ... | ... | @@ -4,6 +4,7 @@ const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 4 | 4 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 5 | 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 6 | 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | |
| 7 | 8 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 8 | 9 | |
| 9 | 10 | contextBridge.exposeInMainWorld("desktopBridge", { |
| ... | ... | @@ -13,4 +14,15 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 13 | 14 | showContextMenu: (items: { id: string; label: string }[]) => |
| 14 | 15 | ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items) as Promise<string | null>, |
| 15 | 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 | 163 | updatedAt: now, |
| 164 | 164 | }; |
| 165 | 165 | |
| 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 | 169 | cwd: resolvedCwd, |
| 168 | env: process.env, | |
| 170 | env: { | |
| 171 | ...process.env, | |
| 172 | ...(codexHomePath ? { CODEX_HOME: codexHomePath } : {}), | |
| 173 | }, | |
| 169 | 174 | stdio: ["pipe", "pipe", "pipe"], |
| 170 | 175 | }); |
| 171 | 176 | const output = readline.createInterface({ input: child.stdout }); |
apps/web/src/appSettings.ts created+109| ... | ... | @@ -0,0 +1,109 @@ |
| 1 | import { useCallback, useSyncExternalStore } from "react"; | |
| 2 | import { z } from "zod"; | |
| 3 | ||
| 4 | const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; | |
| 5 | ||
| 6 | const 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 | ||
| 12 | export type AppSettings = z.infer<typeof appSettingsSchema>; | |
| 13 | ||
| 14 | const DEFAULT_APP_SETTINGS: AppSettings = appSettingsSchema.parse({}); | |
| 15 | ||
| 16 | let listeners: Array<() => void> = []; | |
| 17 | let cachedRawSettings: string | null | undefined; | |
| 18 | let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; | |
| 19 | ||
| 20 | function emitChange(): void { | |
| 21 | for (const listener of listeners) { | |
| 22 | listener(); | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | function 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 | ||
| 39 | export 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 | ||
| 54 | function 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 | ||
| 70 | function 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 | ||
| 86 | export 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 | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 36 | 36 | |
| 37 | 37 | import { isElectron } from "../env"; |
| 38 | import { useAppSettings } from "../appSettings"; | |
| 38 | 39 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 39 | 40 | import { |
| 40 | 41 | type ComposerTriggerKind, |
| ... | ... | @@ -376,6 +377,7 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 376 | 377 | const { state, dispatch } = useStore(); |
| 377 | 378 | const api = useNativeApi(); |
| 378 | 379 | const { resolvedTheme } = useTheme(); |
| 380 | const { settings: appSettings } = useAppSettings(); | |
| 379 | 381 | const queryClient = useQueryClient(); |
| 380 | 382 | const createWorktreeMutation = useMutation( |
| 381 | 383 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| ... | ... | @@ -642,6 +644,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 642 | 644 | const runtimeApprovalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; |
| 643 | 645 | const runtimeSandboxMode = |
| 644 | 646 | state.runtimeMode === "full-access" ? "danger-full-access" : "workspace-write"; |
| 647 | const codexBinaryPath = appSettings.codexBinaryPath.trim(); | |
| 648 | const codexHomePath = appSettings.codexHomePath.trim(); | |
| 645 | 649 | const gitCwd = activeThread?.worktreePath ?? activeProject?.cwd ?? null; |
| 646 | 650 | const composerTrigger = useMemo( |
| 647 | 651 | () => detectComposerTrigger(prompt, composerCursor), |
| ... | ... | @@ -1515,6 +1519,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1515 | 1519 | cwd: cwdOverride ?? activeThread.worktreePath ?? activeProject.cwd, |
| 1516 | 1520 | model: selectedModel || undefined, |
| 1517 | 1521 | resumeThreadId: priorCodexThreadId ?? undefined, |
| 1522 | ...(codexBinaryPath.length > 0 ? { codexBinaryPath } : {}), | |
| 1523 | ...(codexHomePath.length > 0 ? { codexHomePath } : {}), | |
| 1518 | 1524 | approvalPolicy: runtimeApprovalPolicy, |
| 1519 | 1525 | sandboxMode: runtimeSandboxMode, |
| 1520 | 1526 | }); |
| ... | ... | @@ -1555,6 +1561,8 @@ export default function ChatView({ threadId }: ChatViewProps) { |
| 1555 | 1561 | activeProject, |
| 1556 | 1562 | activeThread, |
| 1557 | 1563 | api, |
| 1564 | codexBinaryPath, | |
| 1565 | codexHomePath, | |
| 1558 | 1566 | dispatch, |
| 1559 | 1567 | runtimeApprovalPolicy, |
| 1560 | 1568 | runtimeSandboxMode, |
apps/web/src/components/Sidebar.tsx+25-21| ... | ... | @@ -1,11 +1,11 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | |
| 1 | import { TerminalIcon } from "lucide-react"; | |
| 2 | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 4 | 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 5 | 5 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 6 | import { useAppSettings } from "../appSettings"; | |
| 6 | 7 | import { isElectron } from "../env"; |
| 7 | 8 | import { APP_STAGE_LABEL } from "../branding"; |
| 8 | import { useTheme } from "../hooks/useTheme"; | |
| 9 | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | 10 | import { derivePendingApprovals } from "../session-logic"; |
| 11 | 11 | import { useStore } from "../store"; |
| ... | ... | @@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast"; |
| 18 | 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | 19 | import { createThread } from "../threadFactory"; |
| 20 | 20 | |
| 21 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | |
| 22 | 21 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 23 | 22 | |
| 24 | 23 | function formatRelativeTime(iso: string): string { |
| ... | ... | @@ -117,6 +116,7 @@ export default function Sidebar() { |
| 117 | 116 | const { state, dispatch } = useStore(); |
| 118 | 117 | const api = useNativeApi(); |
| 119 | 118 | const navigate = useNavigate(); |
| 119 | const { settings: appSettings } = useAppSettings(); | |
| 120 | 120 | const params = useParams({ strict: false }); |
| 121 | 121 | const routeThreadId = typeof params.threadId === "string" ? params.threadId : null; |
| 122 | 122 | const { data: keybindings = EMPTY_KEYBINDINGS } = useQuery({ |
| ... | ... | @@ -127,7 +127,6 @@ export default function Sidebar() { |
| 127 | 127 | const removeWorktreeMutation = useMutation( |
| 128 | 128 | gitRemoveWorktreeMutationOptions({ api, queryClient }), |
| 129 | 129 | ); |
| 130 | const { theme, setTheme } = useTheme(); | |
| 131 | 130 | const [addingProject, setAddingProject] = useState(false); |
| 132 | 131 | const [newCwd, setNewCwd] = useState(""); |
| 133 | 132 | const [isPickingFolder, setIsPickingFolder] = useState(false); |
| ... | ... | @@ -272,6 +271,17 @@ export default function Sidebar() { |
| 272 | 271 | |
| 273 | 272 | const thread = state.threads.find((t) => t.id === threadId); |
| 274 | 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 | 285 | const threadProject = state.projects.find((project) => project.id === thread.projectId); |
| 276 | 286 | const orphanedWorktreePath = getOrphanedWorktreePathForThread(state.threads, threadId); |
| 277 | 287 | const displayWorktreePath = orphanedWorktreePath |
| ... | ... | @@ -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 | ); |
| 354 | 373 | |
| 355 | 374 | const handleProjectContextMenu = useCallback( |
| ... | ... | @@ -436,25 +455,10 @@ export default function Sidebar() { |
| 436 | 455 | <span className="truncate text-sm font-semibold tracking-tight text-foreground"> |
| 437 | 456 | T3 <span className="font-normal text-muted-foreground">Code</span> |
| 438 | 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 | 459 | {APP_STAGE_LABEL} |
| 441 | 460 | </span> |
| 442 | 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 | 462 | </div> |
| 459 | 463 | |
| 460 | 464 | {/* New thread (global) */} |
apps/web/src/routeTree.gen.ts+21-3| ... | ... | @@ -11,6 +11,7 @@ |
| 11 | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | 12 | import { Route as ChatRouteImport } from './routes/_chat' |
| 13 | 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 14 | import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' | |
| 14 | 15 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' |
| 15 | 16 | |
| 16 | 17 | const ChatRoute = ChatRouteImport.update({ |
| ... | ... | @@ -22,6 +23,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ |
| 22 | 23 | path: '/', |
| 23 | 24 | getParentRoute: () => ChatRoute, |
| 24 | 25 | } as any) |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | |
| 27 | id: '/settings', | |
| 28 | path: '/settings', | |
| 29 | getParentRoute: () => ChatRoute, | |
| 30 | } as any) | |
| 25 | 31 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 26 | 32 | id: '/$threadId', |
| 27 | 33 | path: '/$threadId', |
| ... | ... | @@ -31,23 +37,26 @@ const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ |
| 31 | 37 | export interface FileRoutesByFullPath { |
| 32 | 38 | '/': typeof ChatIndexRoute |
| 33 | 39 | '/$threadId': typeof ChatThreadIdRoute |
| 40 | '/settings': typeof ChatSettingsRoute | |
| 34 | 41 | } |
| 35 | 42 | export interface FileRoutesByTo { |
| 36 | 43 | '/$threadId': typeof ChatThreadIdRoute |
| 44 | '/settings': typeof ChatSettingsRoute | |
| 37 | 45 | '/': typeof ChatIndexRoute |
| 38 | 46 | } |
| 39 | 47 | export interface FileRoutesById { |
| 40 | 48 | __root__: typeof rootRouteImport |
| 41 | 49 | '/_chat': typeof ChatRouteWithChildren |
| 42 | 50 | '/_chat/$threadId': typeof ChatThreadIdRoute |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | |
| 43 | 52 | '/_chat/': typeof ChatIndexRoute |
| 44 | 53 | } |
| 45 | 54 | export interface FileRouteTypes { |
| 46 | 55 | fileRoutesByFullPath: FileRoutesByFullPath |
| 47 | fullPaths: '/' | '/$threadId' | |
| 56 | fullPaths: '/' | '/$threadId' | '/settings' | |
| 48 | 57 | 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/' | |
| 51 | 60 | fileRoutesById: FileRoutesById |
| 52 | 61 | } |
| 53 | 62 | export interface RootRouteChildren { |
| ... | ... | @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { |
| 70 | 79 | preLoaderRoute: typeof ChatIndexRouteImport |
| 71 | 80 | 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 | 89 | '/_chat/$threadId': { |
| 74 | 90 | id: '/_chat/$threadId' |
| 75 | 91 | path: '/$threadId' |
| ... | ... | @@ -82,11 +98,13 @@ declare module '@tanstack/react-router' { |
| 82 | 98 | |
| 83 | 99 | interface ChatRouteChildren { |
| 84 | 100 | ChatThreadIdRoute: typeof ChatThreadIdRoute |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | |
| 85 | 102 | ChatIndexRoute: typeof ChatIndexRoute |
| 86 | 103 | } |
| 87 | 104 | |
| 88 | 105 | const ChatRouteChildren: ChatRouteChildren = { |
| 89 | 106 | ChatThreadIdRoute: ChatThreadIdRoute, |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | |
| 90 | 108 | ChatIndexRoute: ChatIndexRoute, |
| 91 | 109 | } |
| 92 | 110 |
apps/web/src/routes/_chat.settings.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import { createFileRoute } from "@tanstack/react-router"; | |
| 2 | ||
| 3 | import { useAppSettings } from "../appSettings"; | |
| 4 | import { isElectron } from "../env"; | |
| 5 | import { useTheme } from "../hooks/useTheme"; | |
| 6 | import { useStore } from "../store"; | |
| 7 | import { Button } from "../components/ui/button"; | |
| 8 | import { Input } from "../components/ui/input"; | |
| 9 | import { Switch } from "../components/ui/switch"; | |
| 10 | ||
| 11 | const 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 | ||
| 29 | const 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 | ||
| 42 | function 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 | ||
| 257 | export const Route = createFileRoute("/_chat/settings")({ | |
| 258 | component: SettingsRouteView, | |
| 259 | }); |
apps/web/src/routes/_chat.tsx+19-2| ... | ... | @@ -1,5 +1,5 @@ |
| 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | |
| 2 | import { Activity, Suspense, lazy, type ReactNode } from "react"; | |
| 1 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; | |
| 2 | import { Activity, Suspense, lazy, type ReactNode, useEffect } from "react"; | |
| 3 | 3 | |
| 4 | 4 | import Sidebar from "../components/Sidebar"; |
| 5 | 5 | import { useMediaQuery } from "../hooks/useMediaQuery"; |
| ... | ... | @@ -48,6 +48,23 @@ const DiffPanelWrapper = (props: { children: ReactNode; sheet: boolean }) => { |
| 48 | 48 | function ChatRouteLayout() { |
| 49 | 49 | const { state } = useStore(); |
| 50 | 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]); | |
| 51 | 68 | |
| 52 | 69 | const diffLoadingFallback = |
| 53 | 70 | !state.diffOpen || shouldUseDiffSheet ? ( |
apps/web/src/vite-env.d.ts+1| ... | ... | @@ -8,6 +8,7 @@ interface DesktopBridge { |
| 8 | 8 | confirm: (message: string) => Promise<boolean>; |
| 9 | 9 | showContextMenu: (items: readonly { id: string; label: string }[]) => Promise<string | null>; |
| 10 | 10 | openExternal: (url: string) => Promise<boolean>; |
| 11 | onMenuAction: (listener: (action: string) => void) => () => void; | |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | declare global { |
packages/contracts/src/provider.test.ts+22| ... | ... | @@ -37,6 +37,28 @@ describe("providerSessionStartInputSchema", () => { |
| 37 | 37 | }), |
| 38 | 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 | }); |
| 41 | 63 | |
| 42 | 64 | describe("providerSendTurnInputSchema", () => { |
packages/contracts/src/provider.ts+2| ... | ... | @@ -50,6 +50,8 @@ export const providerSessionStartInputSchema = z.object({ |
| 50 | 50 | cwd: z.string().min(1).optional(), |
| 51 | 51 | model: z.string().trim().min(1).optional(), |
| 52 | 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 | 55 | approvalPolicy: providerApprovalPolicySchema.default("never"), |
| 54 | 56 | sandboxMode: providerSandboxModeSchema.default("workspace-write"), |
| 55 | 57 | }); |