diff --git a/apps/web/src/appSettings.test.ts b/apps/web/src/appSettings.test.ts index 213e4cd3d4..e7e9a67e16 100644 --- a/apps/web/src/appSettings.test.ts +++ b/apps/web/src/appSettings.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { getAppModelOptions, - getSlashModelOptions, normalizeCustomModelSlugs, resolveAppModelSelection, } from "./appSettings"; @@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => { expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4"); }); }); - -describe("getSlashModelOptions", () => { - it("includes saved custom model slugs for /model command suggestions", () => { - const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex"); - - expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true); - }); - - it("filters slash-model suggestions across built-in and custom model names", () => { - const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex"); - - expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]); - }); -}); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 5ed218fb25..e5018e0bfd 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,7 +1,8 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback } from "react"; import { Option, Schema } from "effect"; import { type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; +import { useLocalStorage } from "./hooks/useLocalStorage"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; const MAX_CUSTOM_MODEL_COUNT = 32; @@ -34,10 +35,6 @@ export interface AppModelOption { const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({}); -let listeners: Array<() => void> = []; -let cachedRawSettings: string | null | undefined; -let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS; - export function normalizeCustomModelSlugs( models: Iterable, provider: ProviderKind = "codex", @@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs( return normalizedModels; } -function normalizeAppSettings(settings: AppSettings): AppSettings { - return { - ...settings, - customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), - }; -} - export function getAppModelOptions( provider: ProviderKind, customModels: readonly string[], @@ -143,112 +133,26 @@ export function resolveAppModelSelection( ); } -export function getSlashModelOptions( - provider: ProviderKind, - customModels: readonly string[], - query: string, - selectedModel?: string | null, -): AppModelOption[] { - const normalizedQuery = query.trim().toLowerCase(); - const options = getAppModelOptions(provider, customModels, selectedModel); - if (!normalizedQuery) { - return options; - } - - return options.filter((option) => { - const searchSlug = option.slug.toLowerCase(); - const searchName = option.name.toLowerCase(); - return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery); - }); -} - -function emitChange(): void { - for (const listener of listeners) { - listener(); - } -} - -function parsePersistedSettings(value: string | null): AppSettings { - if (!value) { - return DEFAULT_APP_SETTINGS; - } - - try { - return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value)); - } catch { - return DEFAULT_APP_SETTINGS; - } -} - -export function getAppSettingsSnapshot(): AppSettings { - if (typeof window === "undefined") { - return DEFAULT_APP_SETTINGS; - } - - const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY); - if (raw === cachedRawSettings) { - return cachedSnapshot; - } - - cachedRawSettings = raw; - cachedSnapshot = parsePersistedSettings(raw); - return cachedSnapshot; -} - -function persistSettings(next: AppSettings): void { - if (typeof window === "undefined") return; - - const raw = JSON.stringify(next); - try { - if (raw !== cachedRawSettings) { - window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw); - } - } catch { - // Best-effort persistence only. - } - - cachedRawSettings = raw; - cachedSnapshot = next; -} - -function subscribe(listener: () => void): () => void { - listeners.push(listener); - - const onStorage = (event: StorageEvent) => { - if (event.key === APP_SETTINGS_STORAGE_KEY) { - emitChange(); - } - }; - - window.addEventListener("storage", onStorage); - return () => { - listeners = listeners.filter((entry) => entry !== listener); - window.removeEventListener("storage", onStorage); - }; -} - export function useAppSettings() { - const settings = useSyncExternalStore( - subscribe, - getAppSettingsSnapshot, - () => DEFAULT_APP_SETTINGS, + const [settings, setSettings] = useLocalStorage( + APP_SETTINGS_STORAGE_KEY, + DEFAULT_APP_SETTINGS, + AppSettingsSchema, ); - const updateSettings = useCallback((patch: Partial) => { - const next = normalizeAppSettings( - Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), + const updateSettings = useCallback( + (patch: Partial) => { + setSettings((prev) => ({ + ...prev, ...patch, - }), - ); - persistSettings(next); - emitChange(); - }, []); + })); + }, + [setSettings], + ); const resetSettings = useCallback(() => { - persistSettings(DEFAULT_APP_SETTINGS); - emitChange(); - }, []); + setSettings(DEFAULT_APP_SETTINGS); + }, [setSettings]); return { settings, diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index f4298fc222..3f8876ec14 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -20,13 +20,13 @@ import React, { import type { Components } from "react-markdown"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { openInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor } from "../terminal-links"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { event.stopPropagation(); const api = readNativeApi(); if (api) { - void api.shell.openInEditor(targetPath, preferredTerminalEditor()); + void openInPreferredEditor(api, targetPath); } else { console.warn("Native API not found. Unable to open file in editor."); } diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4cd64af9f1..59e2904310 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,29 +1,14 @@ -import { type ProviderKind, type ThreadId } from "@t3tools/contracts"; +import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts"; import { type ChatMessage, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; import { getAppModelOptions } from "../appSettings"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { Schema } from "effect"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; -export function readLastInvokedScriptByProjectFromStorage(): Record { - const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - if (!stored) return {}; - - try { - const parsed: unknown = JSON.parse(stored); - if (!parsed || typeof parsed !== "object") return {}; - return Object.fromEntries( - Object.entries(parsed).filter( - (entry): entry is [string, string] => - typeof entry[0] === "string" && typeof entry[1] === "string", - ), - ); - } catch { - return {}; - } -} +export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String); export function buildLocalDraftThread( threadId: ThreadId, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d81e024f3a..76a5610ced 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -148,13 +148,14 @@ import { collectUserMessageBlobPreviewUrls, getCustomModelOptionsByProvider, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, - readLastInvokedScriptByProjectFromStorage, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, } from "./ChatView.logic"; +import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -267,9 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) { const [composerTrigger, setComposerTrigger] = useState(() => detectComposerTrigger(prompt, prompt.length), ); - const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState< - Record - >(() => readLastInvokedScriptByProjectFromStorage()); + const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage( + LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, + {}, + LastInvokedScriptByProjectSchema, + ); const messagesScrollRef = useRef(null); const [messagesScrollElement, setMessagesScrollElement] = useState(null); const shouldAutoScrollRef = useRef(true); @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) { setThreadError, storeNewTerminal, storeSetActiveTerminal, + setLastInvokedScriptByProjectId, terminalState.activeTerminalId, terminalState.runningTerminalIds, terminalState.terminalIds, @@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) { [serverThread], ); - useEffect(() => { - try { - if (Object.keys(lastInvokedScriptByProjectId).length === 0) { - localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); - return; - } - localStorage.setItem( - LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, - JSON.stringify(lastInvokedScriptByProjectId), - ); - } catch { - // Ignore storage write failures (private mode, quota exceeded, etc.) - } - }, [lastInvokedScriptByProjectId]); - // Auto-scroll on new messages const messageCount = timelineMessages.length; const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => { diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 1293f51f7e..b0f1b91c35 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -12,11 +12,12 @@ import { useRef, useState, } from "react"; +import { openInPreferredEditor } from "../editorPreferences"; import { gitBranchesQueryOptions } from "~/lib/gitReactQuery"; import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery"; import { cn } from "~/lib/utils"; import { readNativeApi } from "../nativeApi"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links"; +import { resolvePathLinkTarget } from "../terminal-links"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; @@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const api = readNativeApi(); if (!api) return; const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; - void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, targetPath).catch((error) => { console.warn("Failed to open diff file in editor.", error); }); }, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 379fb5a840..ea1865bc86 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -31,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; import { toastManager } from "~/components/ui/toast"; +import { openInPreferredEditor } from "~/editorPreferences"; import { gitBranchesQueryOptions, gitInitMutationOptions, @@ -40,7 +41,7 @@ import { gitStatusQueryOptions, invalidateGitQueries, } from "~/lib/gitReactQuery"; -import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links"; +import { resolvePathLinkTarget } from "~/terminal-links"; import { readNativeApi } from "~/nativeApi"; interface GitActionsControlProps { @@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions return; } const target = resolvePathLinkTarget(filePath, gitCwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { toastManager.add({ type: "error", title: "Unable to open file", diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cce2ed1338..7861212e48 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -12,10 +12,10 @@ import { useState, } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; +import { openInPreferredEditor } from "../editorPreferences"; import { extractTerminalLinks, isTerminalLinkActivation, - preferredTerminalEditor, resolvePathLinkTarget, } from "../terminal-links"; import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings"; @@ -236,7 +236,7 @@ function TerminalViewport({ } const target = resolvePathLinkTarget(match.text, cwd); - void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => { + void openInPreferredEditor(api, target).catch((error) => { writeSystemMessage( latestTerminal, error instanceof Error ? error.message : "Unable to open path", diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 701204c1be..2e09da03c1 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,6 +1,7 @@ -import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { memo, useCallback, useEffect, useMemo, useState } from "react"; +import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; +import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -const LAST_EDITOR_KEY = "t3code:last-editor"; +const resolveOptions = (platform: string, availableEditors: ReadonlyArray) => { + const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [ + { + label: "Cursor", + Icon: CursorIcon, + value: "cursor", + }, + { + label: "VS Code", + Icon: VisualStudioCode, + value: "vscode", + }, + { + label: "Zed", + Icon: Zed, + value: "zed", + }, + { + label: isMacPlatform(platform) + ? "Finder" + : isWindowsPlatform(platform) + ? "Explorer" + : "Files", + Icon: FolderClosedIcon, + value: "file-manager", + }, + ]; + return baseOptions.filter((option) => availableEditors.includes(option.value)); +}; export const OpenInPicker = memo(function OpenInPicker({ keybindings, @@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [lastEditor, setLastEditor] = useState(() => { - const stored = localStorage.getItem(LAST_EDITOR_KEY); - return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id; - }); - - const allOptions = useMemo>( - () => [ - { - label: "Cursor", - Icon: CursorIcon, - value: "cursor", - }, - { - label: "VS Code", - Icon: VisualStudioCode, - value: "vscode", - }, - { - label: "Zed", - Icon: Zed, - value: "zed", - }, - { - label: isMacPlatform(navigator.platform) - ? "Finder" - : isWindowsPlatform(navigator.platform) - ? "Explorer" - : "Files", - Icon: FolderClosedIcon, - value: "file-manager", - }, - ], - [], - ); + const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors); const options = useMemo( - () => allOptions.filter((option) => availableEditors.includes(option.value)), - [allOptions, availableEditors], + () => resolveOptions(navigator.platform, availableEditors), + [availableEditors], ); - - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null; const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); if (!api || !openInCwd) return; - const editor = editorId ?? effectiveEditor; + const editor = editorId ?? preferredEditor; if (!editor) return; void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); - setLastEditor(editor); + setPreferredEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({ const api = readNativeApi(); if (!isOpenFavoriteEditorShortcut(e, keybindings)) return; if (!api || !openInCwd) return; - if (!effectiveEditor) return; + if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, effectiveEditor); + void api.shell.openInEditor(openInCwd, preferredEditor); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [effectiveEditor, keybindings, openInCwd]); + }, [preferredEditor, keybindings, openInCwd]); return (