| 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,7 +5,15 @@ import net from "node:net"; |
| 5 | import os from "node:os"; | 5 | import os from "node:os"; |
| 6 | import path from "node:path"; | 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 | import { showDesktopConfirmDialog } from "./confirmDialog"; | 18 | import { showDesktopConfirmDialog } from "./confirmDialog"; |
| 11 | import { fixPath } from "./fixPath"; | 19 | import { 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"; |
| 16 | const CONFIRM_CHANNEL = "desktop:confirm"; | 24 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 17 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 25 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 18 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 26 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 27 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | ||
| 19 | const ROOT_DIR = path.resolve(__dirname, "../../.."); | 28 | const ROOT_DIR = path.resolve(__dirname, "../../.."); |
| 20 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); | 29 | const BACKEND_ENTRY = path.join(ROOT_DIR, "apps/server/dist/index.mjs"); |
| 21 | const WEB_ENTRY = path.join(ROOT_DIR, "apps/web/dist/index.html"); | 30 | const 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; |
| 33 | let restartTimer: ReturnType<typeof setTimeout> | null = null; | 42 | let restartTimer: ReturnType<typeof setTimeout> | null = null; |
| 34 | let isQuitting = false; | 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 | function resolveResourcePath(fileName: string): string | null { | 119 | function 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", () => { |
| 322 | 405 | ||
| 323 | app.whenReady().then(() => { | 406 | app.whenReady().then(() => { |
| 324 | configureAppIdentity(); | 407 | configureAppIdentity(); |
| 408 | configureApplicationMenu(); | ||
| 325 | void bootstrap(); | 409 | void bootstrap(); |
| 326 | 410 | ||
| 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"; |
| 4 | const CONFIRM_CHANNEL = "desktop:confirm"; | 4 | const CONFIRM_CHANNEL = "desktop:confirm"; |
| 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 5 | const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 6 | const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; |
| 7 | const MENU_ACTION_CHANNEL = "desktop:menu-action"; | ||
| 7 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; | 8 | const wsUrl = process.env.T3CODE_DESKTOP_WS_URL ?? null; |
| 8 | 9 | ||
| 9 | contextBridge.exposeInMainWorld("desktopBridge", { | 10 | contextBridge.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 | }; |
| 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 | 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 @@ | ||
| 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,6 +35,7 @@ import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; |
| 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; | 35 | import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; |
| 36 | 36 | ||
| 37 | import { isElectron } from "../env"; | 37 | import { isElectron } from "../env"; |
| 38 | import { useAppSettings } from "../appSettings"; | ||
| 38 | import { buildBootstrapInput } from "../historyBootstrap"; | 39 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 39 | import { | 40 | import { |
| 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 @@ |
| 1 | import { MonitorIcon, MoonIcon, SunIcon, TerminalIcon } from "lucide-react"; | 1 | import { TerminalIcon } from "lucide-react"; |
| 2 | import { useCallback, useEffect, useMemo, useState } from "react"; | 2 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; | 3 | import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; |
| 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | 4 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 5 | import { useNavigate, useParams } from "@tanstack/react-router"; | 5 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 6 | import { useAppSettings } from "../appSettings"; | ||
| 6 | import { isElectron } from "../env"; | 7 | import { isElectron } from "../env"; |
| 7 | import { APP_STAGE_LABEL } from "../branding"; | 8 | import { APP_STAGE_LABEL } from "../branding"; |
| 8 | import { useTheme } from "../hooks/useTheme"; | ||
| 9 | import { DEFAULT_MODEL } from "../model-logic"; | 9 | import { DEFAULT_MODEL } from "../model-logic"; |
| 10 | import { derivePendingApprovals } from "../session-logic"; | 10 | import { derivePendingApprovals } from "../session-logic"; |
| 11 | import { useStore } from "../store"; | 11 | import { useStore } from "../store"; |
| ... | @@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast"; | ... | @@ -18,7 +18,6 @@ import { toastManager } from "./ui/toast"; |
| 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; | 18 | import { formatWorktreePathForDisplay, getOrphanedWorktreePathForThread } from "../worktreeCleanup"; |
| 19 | import { createThread } from "../threadFactory"; | 19 | import { createThread } from "../threadFactory"; |
| 20 | 20 | ||
| 21 | const THEME_CYCLE = { system: "light", light: "dark", dark: "system" } as const; | ||
| 22 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; | 21 | const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; |
| 23 | 22 | ||
| 24 | function formatRelativeTime(iso: string): string { | 23 | function 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() { |
| 272 | 271 | ||
| 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 = orphanedWorktreePath | 287 | 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 | ); |
| 354 | 373 | ||
| 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> |
| 459 | 463 | ||
| 460 | {/* New thread (global) */} | 464 | {/* New thread (global) */} |
apps/web/src/routeTree.gen.ts+21-3| ... | @@ -11,6 +11,7 @@ | ... | @@ -11,6 +11,7 @@ |
| 11 | import { Route as rootRouteImport } from './routes/__root' | 11 | import { Route as rootRouteImport } from './routes/__root' |
| 12 | import { Route as ChatRouteImport } from './routes/_chat' | 12 | import { Route as ChatRouteImport } from './routes/_chat' |
| 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 13 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 14 | import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' | ||
| 14 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' | 15 | import { Route as ChatThreadIdRouteImport } from './routes/_chat.$threadId' |
| 15 | 16 | ||
| 16 | const ChatRoute = ChatRouteImport.update({ | 17 | const 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) |
| 26 | const ChatSettingsRoute = ChatSettingsRouteImport.update({ | ||
| 27 | id: '/settings', | ||
| 28 | path: '/settings', | ||
| 29 | getParentRoute: () => ChatRoute, | ||
| 30 | } as any) | ||
| 25 | const ChatThreadIdRoute = ChatThreadIdRouteImport.update({ | 31 | const 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({ |
| 31 | export interface FileRoutesByFullPath { | 37 | export interface FileRoutesByFullPath { |
| 32 | '/': typeof ChatIndexRoute | 38 | '/': typeof ChatIndexRoute |
| 33 | '/$threadId': typeof ChatThreadIdRoute | 39 | '/$threadId': typeof ChatThreadIdRoute |
| 40 | '/settings': typeof ChatSettingsRoute | ||
| 34 | } | 41 | } |
| 35 | export interface FileRoutesByTo { | 42 | export interface FileRoutesByTo { |
| 36 | '/$threadId': typeof ChatThreadIdRoute | 43 | '/$threadId': typeof ChatThreadIdRoute |
| 44 | '/settings': typeof ChatSettingsRoute | ||
| 37 | '/': typeof ChatIndexRoute | 45 | '/': typeof ChatIndexRoute |
| 38 | } | 46 | } |
| 39 | export interface FileRoutesById { | 47 | export interface FileRoutesById { |
| 40 | __root__: typeof rootRouteImport | 48 | __root__: typeof rootRouteImport |
| 41 | '/_chat': typeof ChatRouteWithChildren | 49 | '/_chat': typeof ChatRouteWithChildren |
| 42 | '/_chat/$threadId': typeof ChatThreadIdRoute | 50 | '/_chat/$threadId': typeof ChatThreadIdRoute |
| 51 | '/_chat/settings': typeof ChatSettingsRoute | ||
| 43 | '/_chat/': typeof ChatIndexRoute | 52 | '/_chat/': typeof ChatIndexRoute |
| 44 | } | 53 | } |
| 45 | export interface FileRouteTypes { | 54 | export interface FileRouteTypes { |
| 46 | fileRoutesByFullPath: FileRoutesByFullPath | 55 | fileRoutesByFullPath: FileRoutesByFullPath |
| 47 | fullPaths: '/' | '/$threadId' | 56 | fullPaths: '/' | '/$threadId' | '/settings' |
| 48 | fileRoutesByTo: FileRoutesByTo | 57 | 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: FileRoutesById | 60 | fileRoutesById: FileRoutesById |
| 52 | } | 61 | } |
| 53 | export interface RootRouteChildren { | 62 | export interface RootRouteChildren { |
| ... | @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { | ... | @@ -70,6 +79,13 @@ declare module '@tanstack/react-router' { |
| 70 | preLoaderRoute: typeof ChatIndexRouteImport | 79 | preLoaderRoute: typeof ChatIndexRouteImport |
| 71 | parentRoute: typeof ChatRoute | 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 | '/_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' { |
| 82 | 98 | ||
| 83 | interface ChatRouteChildren { | 99 | interface ChatRouteChildren { |
| 84 | ChatThreadIdRoute: typeof ChatThreadIdRoute | 100 | ChatThreadIdRoute: typeof ChatThreadIdRoute |
| 101 | ChatSettingsRoute: typeof ChatSettingsRoute | ||
| 85 | ChatIndexRoute: typeof ChatIndexRoute | 102 | ChatIndexRoute: typeof ChatIndexRoute |
| 86 | } | 103 | } |
| 87 | 104 | ||
| 88 | const ChatRouteChildren: ChatRouteChildren = { | 105 | const ChatRouteChildren: ChatRouteChildren = { |
| 89 | ChatThreadIdRoute: ChatThreadIdRoute, | 106 | ChatThreadIdRoute: ChatThreadIdRoute, |
| 107 | ChatSettingsRoute: ChatSettingsRoute, | ||
| 90 | ChatIndexRoute: ChatIndexRoute, | 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,5 +1,5 @@ |
| 1 | import { Outlet, createFileRoute } from "@tanstack/react-router"; | 1 | import { Outlet, createFileRoute, useNavigate } from "@tanstack/react-router"; |
| 2 | import { Activity, Suspense, lazy, type ReactNode } from "react"; | 2 | import { Activity, Suspense, lazy, type ReactNode, useEffect } from "react"; |
| 3 | 3 | ||
| 4 | import Sidebar from "../components/Sidebar"; | 4 | import Sidebar from "../components/Sidebar"; |
| 5 | import { useMediaQuery } from "../hooks/useMediaQuery"; | 5 | import { 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 }) => { |
| 48 | function ChatRouteLayout() { | 48 | function 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]); | ||
| 51 | 68 | ||
| 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 | } |
| 12 | 13 | ||
| 13 | declare global { | 14 | declare 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 | }); |
| 41 | 63 | ||
| 42 | describe("providerSendTurnInputSchema", () => { | 64 | describe("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 | }); |