From 305d4afc1965dc1f6ca9a4acb442a7381b92abbf Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Mon, 9 Mar 2026 12:08:33 +0100 Subject: [PATCH 1/5] fix(web): resolve preferred editor from available editors --- apps/web/src/components/ChatMarkdown.tsx | 4 +- apps/web/src/components/ChatView.tsx | 28 +++++--- apps/web/src/components/DiffPanel.tsx | 5 +- apps/web/src/components/GitActionsControl.tsx | 5 +- .../src/components/ThreadTerminalDrawer.tsx | 4 +- apps/web/src/editorPreferences.test.ts | 44 ++++++++++++ apps/web/src/editorPreferences.ts | 67 +++++++++++++++++++ apps/web/src/routes/__root.tsx | 12 ++-- apps/web/src/routes/_chat.settings.tsx | 13 +++- apps/web/src/terminal-links.ts | 22 ------ 10 files changed, 157 insertions(+), 47 deletions(-) create mode 100644 apps/web/src/editorPreferences.test.ts create mode 100644 apps/web/src/editorPreferences.ts diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index da44045c98..c3ccac7ae0 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 }, @@ -252,7 +252,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.tsx b/apps/web/src/components/ChatView.tsx index 51b300ba8e..6f89fb04ad 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,6 @@ import { type ApprovalRequestId, DEFAULT_MODEL_BY_PROVIDER, - EDITORS, type EditorId, type KeybindingCommand, type CodexReasoningEffort, @@ -29,6 +28,11 @@ import { normalizeModelSlug, resolveModelSlugForProvider, } from "@t3tools/shared/model"; +import { + readStoredPreferredEditor, + resolvePreferredEditor, + writeStoredPreferredEditor, +} from "../editorPreferences"; import { memo, useCallback, @@ -251,7 +255,6 @@ function formatWorkingTimer(startIso: string, endIso: string): string | null { return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; } -const LAST_EDITOR_KEY = "t3code:last-editor"; const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; @@ -5758,10 +5761,7 @@ 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 [lastEditor, setLastEditor] = useState(() => readStoredPreferredEditor()); const allOptions = useMemo>( () => [ @@ -5797,11 +5797,19 @@ const OpenInPicker = memo(function OpenInPicker({ [allOptions, availableEditors], ); - const effectiveEditor = options.some((option) => option.value === lastEditor) - ? lastEditor - : (options[0]?.value ?? null); + const effectiveEditor = + lastEditor && options.some((option) => option.value === lastEditor) + ? lastEditor + : resolvePreferredEditor(availableEditors); const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; + useEffect(() => { + if (!effectiveEditor) return; + const stored = readStoredPreferredEditor(); + if (stored === effectiveEditor) return; + writeStoredPreferredEditor(effectiveEditor); + }, [effectiveEditor]); + const openInEditor = useCallback( (editorId: EditorId | null) => { const api = readNativeApi(); @@ -5809,7 +5817,7 @@ const OpenInPicker = memo(function OpenInPicker({ const editor = editorId ?? effectiveEditor; if (!editor) return; void api.shell.openInEditor(openInCwd, editor); - localStorage.setItem(LAST_EDITOR_KEY, editor); + writeStoredPreferredEditor(editor); setLastEditor(editor); }, [effectiveEditor, openInCwd, setLastEditor], diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 27248ce634..4901ed80f3 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -5,11 +5,12 @@ import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { ThreadId, type TurnId } from "@t3tools/contracts"; import { ChevronLeftIcon, ChevronRightIcon, Columns2Icon, Rows3Icon } from "lucide-react"; import { type WheelEvent as ReactWheelEvent, useCallback, useEffect, useMemo, 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"; @@ -304,7 +305,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 4f183e3935..0b260a625a 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 { @@ -565,7 +566,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/editorPreferences.test.ts b/apps/web/src/editorPreferences.test.ts new file mode 100644 index 0000000000..eb08f6e022 --- /dev/null +++ b/apps/web/src/editorPreferences.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; + +import { + readStoredPreferredEditor, + resolveAndPersistPreferredEditor, + resolvePreferredEditor, +} from "./editorPreferences"; + +function createStorage(initial: Record = {}) { + const values = new Map(Object.entries(initial)); + return { + getItem(key: string) { + return values.get(key) ?? null; + }, + setItem(key: string, value: string) { + values.set(key, value); + }, + }; +} + +describe("resolvePreferredEditor", () => { + it("prefers a stored editor when it is available", () => { + const storage = createStorage({ "t3code:last-editor": "vscode" }); + expect(resolvePreferredEditor(["cursor", "vscode", "file-manager"], storage)).toBe("vscode"); + }); + + it("falls back to the first available editor in configured preference order", () => { + const storage = createStorage(); + expect(resolvePreferredEditor(["vscode", "file-manager"], storage)).toBe("vscode"); + }); + + it("returns null when no editors are available", () => { + const storage = createStorage({ "t3code:last-editor": "cursor" }); + expect(resolvePreferredEditor([], storage)).toBeNull(); + }); +}); + +describe("resolveAndPersistPreferredEditor", () => { + it("persists the inferred fallback editor", () => { + const storage = createStorage(); + expect(resolveAndPersistPreferredEditor(["vscode", "file-manager"], storage)).toBe("vscode"); + expect(readStoredPreferredEditor(storage)).toBe("vscode"); + }); +}); diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts new file mode 100644 index 0000000000..3cfda905df --- /dev/null +++ b/apps/web/src/editorPreferences.ts @@ -0,0 +1,67 @@ +import { EDITORS, type EditorId, type NativeApi } from "@t3tools/contracts"; + +const LAST_EDITOR_KEY = "t3code:last-editor"; + +type StorageLike = Pick; + +function defaultStorage(): StorageLike | null { + if (typeof window === "undefined") return null; + return window.localStorage; +} + +function isEditorId(value: string | null): value is EditorId { + return EDITORS.some((editor) => editor.id === value); +} + +export function readStoredPreferredEditor( + storage: StorageLike | null = defaultStorage(), +): EditorId | null { + const stored = storage?.getItem(LAST_EDITOR_KEY) ?? null; + return isEditorId(stored) ? stored : null; +} + +export function writeStoredPreferredEditor( + editor: EditorId, + storage: StorageLike | null = defaultStorage(), +): void { + storage?.setItem(LAST_EDITOR_KEY, editor); +} + +export function resolvePreferredEditor( + availableEditors: readonly EditorId[], + storage: StorageLike | null = defaultStorage(), +): EditorId | null { + const stored = readStoredPreferredEditor(storage); + if (stored && availableEditors.includes(stored)) { + return stored; + } + + const availableEditorIds = new Set(availableEditors); + return EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; +} + +export function resolveAndPersistPreferredEditor( + availableEditors: readonly EditorId[], + storage: StorageLike | null = defaultStorage(), +): EditorId | null { + const editor = resolvePreferredEditor(availableEditors, storage); + if (editor) { + writeStoredPreferredEditor(editor, storage); + } + return editor; +} + +export async function openInPreferredEditor( + api: Pick, + targetPath: string, + storage: StorageLike | null = defaultStorage(), +): Promise { + const { availableEditors } = await api.server.getConfig(); + const editor = resolveAndPersistPreferredEditor(availableEditors, storage); + if (!editor) { + throw new Error("No available editors found."); + } + + await api.shell.openInEditor(targetPath, editor); + return editor; +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index eb3eca9cbd..2ed34141ee 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -12,12 +12,12 @@ import { QueryClient, useQueryClient } from "@tanstack/react-query"; import { APP_DISPLAY_NAME } from "../branding"; import { Button } from "../components/ui/button"; import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast"; +import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { useComposerDraftStore } from "../composerDraftStore"; import { useStore } from "../store"; import { useTerminalStateStore } from "../terminalStateStore"; -import { preferredTerminalEditor } from "../terminal-links"; import { terminalRunningSubprocessFromEvent } from "../terminalActivity"; import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi"; import { providerQueryKeys } from "../lib/providerReactQuery"; @@ -263,9 +263,13 @@ function EventRouter() { onClick: () => { void queryClient .ensureQueryData(serverConfigQueryOptions()) - .then((config) => - api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()), - ) + .then((config) => { + const editor = resolveAndPersistPreferredEditor(config.availableEditors); + if (!editor) { + throw new Error("No available editors found."); + } + return api.shell.openInEditor(config.keybindingsConfigPath, editor); + }) .catch((error) => { toastManager.add({ type: "error", diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index cc4a39a272..43f691508d 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -11,11 +11,11 @@ import { shouldShowFastTierIcon, useAppSettings, } from "../appSettings"; +import { resolveAndPersistPreferredEditor } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { ensureNativeApi } from "../nativeApi"; -import { preferredTerminalEditor } from "../terminal-links"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../components/ui/select"; @@ -105,14 +105,21 @@ function SettingsRouteView() { const codexHomePath = settings.codexHomePath; const codexServiceTier = settings.codexServiceTier; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; + const availableEditors = serverConfigQuery.data?.availableEditors; const openKeybindingsFile = useCallback(() => { if (!keybindingsConfigPath) return; setOpenKeybindingsError(null); setIsOpeningKeybindings(true); const api = ensureNativeApi(); + const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + if (!editor) { + setOpenKeybindingsError("No available editors found."); + setIsOpeningKeybindings(false); + return; + } void api.shell - .openInEditor(keybindingsConfigPath, preferredTerminalEditor()) + .openInEditor(keybindingsConfigPath, editor) .catch((error) => { setOpenKeybindingsError( error instanceof Error ? error.message : "Unable to open keybindings file.", @@ -121,7 +128,7 @@ function SettingsRouteView() { .finally(() => { setIsOpeningKeybindings(false); }); - }, [keybindingsConfigPath]); + }, [availableEditors, keybindingsConfigPath]); const addCustomModel = useCallback((provider: ProviderKind) => { const customModelInput = customModelInputByProvider[provider]; diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 64e18670fa..1119fc9087 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -1,4 +1,3 @@ -import { EDITORS, type EditorId } from "@t3tools/contracts"; import { isMacPlatform } from "./lib/utils"; export type TerminalLinkKind = "url" | "path"; @@ -14,7 +13,6 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g; const FILE_PATH_PATTERN = /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g; const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/; -const LAST_EDITOR_KEY = "t3code:last-editor"; function trimClosingDelimiters(value: string): string { let output = value.replace(TRAILING_PUNCTUATION_PATTERN, ""); @@ -175,23 +173,3 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string { if (!line) return resolvedPath; return `${resolvedPath}:${line}${column ? `:${column}` : ""}`; } - -export function preferredTerminalEditor(): EditorId { - const fallback = EDITORS.find((editor) => editor.command)?.id ?? EDITORS[0]?.id ?? "cursor"; - - if (typeof window === "undefined") { - return fallback; - } - - const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY); - if (!storedEditor) { - return fallback; - } - - const configured = EDITORS.find((editor) => editor.id === storedEditor); - if (!configured?.command) { - return fallback; - } - - return configured.id; -} From 1f1901fe4df810b46a74196b60765fb8339a83d3 Mon Sep 17 00:00:00 2001 From: Magnus Buvarp Date: Mon, 9 Mar 2026 12:29:04 +0100 Subject: [PATCH 2/5] fix(web): guard localStorage access for editor preference --- apps/web/src/editorPreferences.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index 3cfda905df..858b92d7a3 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -6,7 +6,11 @@ type StorageLike = Pick; function defaultStorage(): StorageLike | null { if (typeof window === "undefined") return null; - return window.localStorage; + try { + return window.localStorage; + } catch { + return null; + } } function isEditorId(value: string | null): value is EditorId { From 1aea9ae056aaf4ea4c1b1dffd0a524230d674052 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Mar 2026 10:17:59 -0700 Subject: [PATCH 3/5] useLocalStorage --- apps/web/src/appSettings.test.ts | 15 -- apps/web/src/appSettings.ts | 128 ++-------------- apps/web/src/components/ChatView.logic.ts | 21 +-- apps/web/src/components/ChatView.tsx | 27 +--- apps/web/src/components/chat/OpenInPicker.tsx | 109 ++++++-------- apps/web/src/components/ui/sidebar.tsx | 8 +- apps/web/src/editorPreferences.test.ts | 44 ------ apps/web/src/editorPreferences.ts | 74 +++------ apps/web/src/hooks/useLocalStorage.ts | 141 ++++++++++++++++++ 9 files changed, 238 insertions(+), 329 deletions(-) delete mode 100644 apps/web/src/editorPreferences.test.ts create mode 100644 apps/web/src/hooks/useLocalStorage.ts 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/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/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 73e2526b6c..2e09da03c1 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,11 +1,7 @@ -import { 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 { - readStoredPreferredEditor, - resolvePreferredEditor, - writeStoredPreferredEditor, -} from "../../editorPreferences"; +import { usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; @@ -14,6 +10,36 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; +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, availableEditors, @@ -23,66 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({ availableEditors: ReadonlyArray; openInCwd: string | null; }) { - const [lastEditor, setLastEditor] = useState(() => readStoredPreferredEditor()); - - 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 = - lastEditor && options.some((option) => option.value === lastEditor) - ? lastEditor - : resolvePreferredEditor(availableEditors); - const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null; - - useEffect(() => { - if (!effectiveEditor) return; - const stored = readStoredPreferredEditor(); - if (stored === effectiveEditor) return; - writeStoredPreferredEditor(effectiveEditor); - }, [effectiveEditor]); + 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); - writeStoredPreferredEditor(editor); - setLastEditor(editor); + setPreferredEditor(editor); }, - [effectiveEditor, openInCwd, setLastEditor], + [preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -95,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 (