authorgravatar for magnus.buvarp@gmail.comMagnus Buvarp <magnus.buvarp@gmail.com> 2026-03-12 18:42:17+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-03-12 10:42:17-07:00
loga01d712751f8103001b5e728d64c12bc7c3d652a
tree811330e0eea7ac18b113908337aab3ac9dfe062e
parent774cff9a74bbd937021e27da524b96dbb7319a7c
signature Signed by PGP key B5690EEEBB952194

fix(web): resolve preferred editor from available editors & introduce `useLocalStorage` helper (#662)

Co-authored-by: Julius Marminge <julius0216@outlook.com>

15 files changed, 283 insertions(+), 259 deletions(-)

apps/web/src/appSettings.test.ts-15
......@@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";
22
33import {
44 getAppModelOptions,
5 getSlashModelOptions,
65 normalizeCustomModelSlugs,
76 resolveAppModelSelection,
87} from "./appSettings";
......@@ -58,17 +57,3 @@ describe("resolveAppModelSelection", () => {
5857 expect(resolveAppModelSelection("codex", [], "")).toBe("gpt-5.4");
5958 });
6059});
61
62describe("getSlashModelOptions", () => {
63 it("includes saved custom model slugs for /model command suggestions", () => {
64 const options = getSlashModelOptions("codex", ["custom/internal-model"], "", "gpt-5.3-codex");
65
66 expect(options.some((option) => option.slug === "custom/internal-model")).toBe(true);
67 });
68
69 it("filters slash-model suggestions across built-in and custom model names", () => {
70 const options = getSlashModelOptions("codex", ["openai/gpt-oss-120b"], "oss", "gpt-5.3-codex");
71
72 expect(options.map((option) => option.slug)).toEqual(["openai/gpt-oss-120b"]);
73 });
74});
apps/web/src/appSettings.ts+16-112
......@@ -1,7 +1,8 @@
1import { useCallback, useSyncExternalStore } from "react";
1import { useCallback } from "react";
22import { Option, Schema } from "effect";
33import { type ProviderKind } from "@t3tools/contracts";
44import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
5import { useLocalStorage } from "./hooks/useLocalStorage";
56
67const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1";
78const MAX_CUSTOM_MODEL_COUNT = 32;
......@@ -34,10 +35,6 @@ export interface AppModelOption {
3435
3536const DEFAULT_APP_SETTINGS = AppSettingsSchema.makeUnsafe({});
3637
37let listeners: Array<() => void> = [];
38let cachedRawSettings: string | null | undefined;
39let cachedSnapshot: AppSettings = DEFAULT_APP_SETTINGS;
40
4138export function normalizeCustomModelSlugs(
4239 models: Iterable<string | null | undefined>,
4340 provider: ProviderKind = "codex",
......@@ -67,13 +64,6 @@ export function normalizeCustomModelSlugs(
6764 return normalizedModels;
6865}
6966
70function normalizeAppSettings(settings: AppSettings): AppSettings {
71 return {
72 ...settings,
73 customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"),
74 };
75}
76
7767export function getAppModelOptions(
7868 provider: ProviderKind,
7969 customModels: readonly string[],
......@@ -143,112 +133,26 @@ export function resolveAppModelSelection(
143133 );
144134}
145135
146export function getSlashModelOptions(
147 provider: ProviderKind,
148 customModels: readonly string[],
149 query: string,
150 selectedModel?: string | null,
151): AppModelOption[] {
152 const normalizedQuery = query.trim().toLowerCase();
153 const options = getAppModelOptions(provider, customModels, selectedModel);
154 if (!normalizedQuery) {
155 return options;
156 }
157
158 return options.filter((option) => {
159 const searchSlug = option.slug.toLowerCase();
160 const searchName = option.name.toLowerCase();
161 return searchSlug.includes(normalizedQuery) || searchName.includes(normalizedQuery);
162 });
163}
164
165function emitChange(): void {
166 for (const listener of listeners) {
167 listener();
168 }
169}
170
171function parsePersistedSettings(value: string | null): AppSettings {
172 if (!value) {
173 return DEFAULT_APP_SETTINGS;
174 }
175
176 try {
177 return normalizeAppSettings(Schema.decodeSync(Schema.fromJsonString(AppSettingsSchema))(value));
178 } catch {
179 return DEFAULT_APP_SETTINGS;
180 }
181}
182
183export function getAppSettingsSnapshot(): AppSettings {
184 if (typeof window === "undefined") {
185 return DEFAULT_APP_SETTINGS;
186 }
187
188 const raw = window.localStorage.getItem(APP_SETTINGS_STORAGE_KEY);
189 if (raw === cachedRawSettings) {
190 return cachedSnapshot;
191 }
192
193 cachedRawSettings = raw;
194 cachedSnapshot = parsePersistedSettings(raw);
195 return cachedSnapshot;
196}
197
198function persistSettings(next: AppSettings): void {
199 if (typeof window === "undefined") return;
200
201 const raw = JSON.stringify(next);
202 try {
203 if (raw !== cachedRawSettings) {
204 window.localStorage.setItem(APP_SETTINGS_STORAGE_KEY, raw);
205 }
206 } catch {
207 // Best-effort persistence only.
208 }
209
210 cachedRawSettings = raw;
211 cachedSnapshot = next;
212}
213
214function subscribe(listener: () => void): () => void {
215 listeners.push(listener);
216
217 const onStorage = (event: StorageEvent) => {
218 if (event.key === APP_SETTINGS_STORAGE_KEY) {
219 emitChange();
220 }
221 };
222
223 window.addEventListener("storage", onStorage);
224 return () => {
225 listeners = listeners.filter((entry) => entry !== listener);
226 window.removeEventListener("storage", onStorage);
227 };
228}
229
230136export function useAppSettings() {
231 const settings = useSyncExternalStore(
232 subscribe,
233 getAppSettingsSnapshot,
234 () => DEFAULT_APP_SETTINGS,
137 const [settings, setSettings] = useLocalStorage(
138 APP_SETTINGS_STORAGE_KEY,
139 DEFAULT_APP_SETTINGS,
140 AppSettingsSchema,
235141 );
236142
237 const updateSettings = useCallback((patch: Partial<AppSettings>) => {
238 const next = normalizeAppSettings(
239 Schema.decodeSync(AppSettingsSchema)({
240 ...getAppSettingsSnapshot(),
143 const updateSettings = useCallback(
144 (patch: Partial<AppSettings>) => {
145 setSettings((prev) => ({
146 ...prev,
241147 ...patch,
242 }),
243 );
244 persistSettings(next);
245 emitChange();
246 }, []);
148 }));
149 },
150 [setSettings],
151 );
247152
248153 const resetSettings = useCallback(() => {
249 persistSettings(DEFAULT_APP_SETTINGS);
250 emitChange();
251 }, []);
154 setSettings(DEFAULT_APP_SETTINGS);
155 }, [setSettings]);
252156
253157 return {
254158 settings,
apps/web/src/components/ChatMarkdown.tsx+2-2
......@@ -20,13 +20,13 @@ import React, {
2020import type { Components } from "react-markdown";
2121import ReactMarkdown from "react-markdown";
2222import remarkGfm from "remark-gfm";
23import { openInPreferredEditor } from "../editorPreferences";
2324import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering";
2425import { fnv1a32 } from "../lib/diffRendering";
2526import { LRUCache } from "../lib/lruCache";
2627import { useTheme } from "../hooks/useTheme";
2728import { resolveMarkdownFileLinkTarget } from "../markdown-links";
2829import { readNativeApi } from "../nativeApi";
29import { preferredTerminalEditor } from "../terminal-links";
3030
3131class CodeHighlightErrorBoundary extends React.Component<
3232 { fallback: ReactNode; children: ReactNode },
......@@ -254,7 +254,7 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) {
254254 event.stopPropagation();
255255 const api = readNativeApi();
256256 if (api) {
257 void api.shell.openInEditor(targetPath, preferredTerminalEditor());
257 void openInPreferredEditor(api, targetPath);
258258 } else {
259259 console.warn("Native API not found. Unable to open file in editor.");
260260 }
apps/web/src/components/ChatView.logic.ts+3-18
......@@ -1,29 +1,14 @@
1import { type ProviderKind, type ThreadId } from "@t3tools/contracts";
1import { ProjectId, type ProviderKind, type ThreadId } from "@t3tools/contracts";
22import { type ChatMessage, type Thread } from "../types";
33import { randomUUID } from "~/lib/utils";
44import { getAppModelOptions } from "../appSettings";
55import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore";
6import { Schema } from "effect";
67
78export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project";
89const WORKTREE_BRANCH_PREFIX = "t3code";
910
10export function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
11 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
12 if (!stored) return {};
13
14 try {
15 const parsed: unknown = JSON.parse(stored);
16 if (!parsed || typeof parsed !== "object") return {};
17 return Object.fromEntries(
18 Object.entries(parsed).filter(
19 (entry): entry is [string, string] =>
20 typeof entry[0] === "string" && typeof entry[1] === "string",
21 ),
22 );
23 } catch {
24 return {};
25 }
26}
11export const LastInvokedScriptByProjectSchema = Schema.Record(ProjectId, Schema.String);
2712
2813export function buildLocalDraftThread(
2914 threadId: ThreadId,
apps/web/src/components/ChatView.tsx+8-19
......@@ -148,13 +148,14 @@ import {
148148 collectUserMessageBlobPreviewUrls,
149149 getCustomModelOptionsByProvider,
150150 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
151 LastInvokedScriptByProjectSchema,
151152 PullRequestDialogState,
152153 readFileAsDataUrl,
153 readLastInvokedScriptByProjectFromStorage,
154154 revokeBlobPreviewUrl,
155155 revokeUserMessagePreviewUrls,
156156 SendPhase,
157157} from "./ChatView.logic";
158import { useLocalStorage } from "~/hooks/useLocalStorage";
158159
159160const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000;
160161const 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) {
267268 const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
268269 detectComposerTrigger(prompt, prompt.length),
269270 );
270 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
271 Record<string, string>
272 >(() => readLastInvokedScriptByProjectFromStorage());
271 const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
272 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
273 {},
274 LastInvokedScriptByProjectSchema,
275 );
273276 const messagesScrollRef = useRef<HTMLDivElement>(null);
274277 const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
275278 const shouldAutoScrollRef = useRef(true);
......@@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
11961199 setThreadError,
11971200 storeNewTerminal,
11981201 storeSetActiveTerminal,
1202 setLastInvokedScriptByProjectId,
11991203 terminalState.activeTerminalId,
12001204 terminalState.runningTerminalIds,
12011205 terminalState.terminalIds,
......@@ -1438,21 +1442,6 @@ export default function ChatView({ threadId }: ChatViewProps) {
14381442 [serverThread],
14391443 );
14401444
1441 useEffect(() => {
1442 try {
1443 if (Object.keys(lastInvokedScriptByProjectId).length === 0) {
1444 localStorage.removeItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
1445 return;
1446 }
1447 localStorage.setItem(
1448 LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
1449 JSON.stringify(lastInvokedScriptByProjectId),
1450 );
1451 } catch {
1452 // Ignore storage write failures (private mode, quota exceeded, etc.)
1453 }
1454 }, [lastInvokedScriptByProjectId]);
1455
14561445 // Auto-scroll on new messages
14571446 const messageCount = timelineMessages.length;
14581447 const scrollMessagesToBottom = useCallback((behavior: ScrollBehavior = "auto") => {
apps/web/src/components/DiffPanel.tsx+3-2
......@@ -12,11 +12,12 @@ import {
1212 useRef,
1313 useState,
1414} from "react";
15import { openInPreferredEditor } from "../editorPreferences";
1516import { gitBranchesQueryOptions } from "~/lib/gitReactQuery";
1617import { checkpointDiffQueryOptions } from "~/lib/providerReactQuery";
1718import { cn } from "~/lib/utils";
1819import { readNativeApi } from "../nativeApi";
19import { preferredTerminalEditor, resolvePathLinkTarget } from "../terminal-links";
20import { resolvePathLinkTarget } from "../terminal-links";
2021import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch";
2122import { isElectron } from "../env";
2223import { useTheme } from "../hooks/useTheme";
......@@ -311,7 +312,7 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) {
311312 const api = readNativeApi();
312313 if (!api) return;
313314 const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath;
314 void api.shell.openInEditor(targetPath, preferredTerminalEditor()).catch((error) => {
315 void openInPreferredEditor(api, targetPath).catch((error) => {
315316 console.warn("Failed to open diff file in editor.", error);
316317 });
317318 },
apps/web/src/components/GitActionsControl.tsx+3-2
......@@ -31,6 +31,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
3131import { ScrollArea } from "~/components/ui/scroll-area";
3232import { Textarea } from "~/components/ui/textarea";
3333import { toastManager } from "~/components/ui/toast";
34import { openInPreferredEditor } from "~/editorPreferences";
3435import {
3536 gitBranchesQueryOptions,
3637 gitInitMutationOptions,
......@@ -40,7 +41,7 @@ import {
4041 gitStatusQueryOptions,
4142 invalidateGitQueries,
4243} from "~/lib/gitReactQuery";
43import { preferredTerminalEditor, resolvePathLinkTarget } from "~/terminal-links";
44import { resolvePathLinkTarget } from "~/terminal-links";
4445import { readNativeApi } from "~/nativeApi";
4546
4647interface GitActionsControlProps {
......@@ -581,7 +582,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions
581582 return;
582583 }
583584 const target = resolvePathLinkTarget(filePath, gitCwd);
584 void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
585 void openInPreferredEditor(api, target).catch((error) => {
585586 toastManager.add({
586587 type: "error",
587588 title: "Unable to open file",
apps/web/src/components/ThreadTerminalDrawer.tsx+2-2
......@@ -12,10 +12,10 @@ import {
1212 useState,
1313} from "react";
1414import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover";
15import { openInPreferredEditor } from "../editorPreferences";
1516import {
1617 extractTerminalLinks,
1718 isTerminalLinkActivation,
18 preferredTerminalEditor,
1919 resolvePathLinkTarget,
2020} from "../terminal-links";
2121import { isTerminalClearShortcut, terminalNavigationShortcutData } from "../keybindings";
......@@ -236,7 +236,7 @@ function TerminalViewport({
236236 }
237237
238238 const target = resolvePathLinkTarget(match.text, cwd);
239 void api.shell.openInEditor(target, preferredTerminalEditor()).catch((error) => {
239 void openInPreferredEditor(api, target).catch((error) => {
240240 writeSystemMessage(
241241 latestTerminal,
242242 error instanceof Error ? error.message : "Unable to open path",
apps/web/src/components/chat/OpenInPicker.tsx+45-54
......@@ -1,6 +1,7 @@
1import { EDITORS, type EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo, useState } from "react";
1import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts";
2import { memo, useCallback, useEffect, useMemo } from "react";
33import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings";
4import { usePreferredEditor } from "../../editorPreferences";
45import { ChevronDownIcon, FolderClosedIcon } from "lucide-react";
56import { Button } from "../ui/button";
67import { Group, GroupSeparator } from "../ui/group";
......@@ -9,7 +10,35 @@ import { CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons";
910import { isMacPlatform, isWindowsPlatform } from "~/lib/utils";
1011import { readNativeApi } from "~/nativeApi";
1112
12const LAST_EDITOR_KEY = "t3code:last-editor";
13const resolveOptions = (platform: string, availableEditors: ReadonlyArray<EditorId>) => {
14 const baseOptions: ReadonlyArray<{ label: string; Icon: Icon; value: EditorId }> = [
15 {
16 label: "Cursor",
17 Icon: CursorIcon,
18 value: "cursor",
19 },
20 {
21 label: "VS Code",
22 Icon: VisualStudioCode,
23 value: "vscode",
24 },
25 {
26 label: "Zed",
27 Icon: Zed,
28 value: "zed",
29 },
30 {
31 label: isMacPlatform(platform)
32 ? "Finder"
33 : isWindowsPlatform(platform)
34 ? "Explorer"
35 : "Files",
36 Icon: FolderClosedIcon,
37 value: "file-manager",
38 },
39 ];
40 return baseOptions.filter((option) => availableEditors.includes(option.value));
41};
1342
1443export const OpenInPicker = memo(function OpenInPicker({
1544 keybindings,
......@@ -20,61 +49,23 @@ export const OpenInPicker = memo(function OpenInPicker({
2049 availableEditors: ReadonlyArray<EditorId>;
2150 openInCwd: string | null;
2251}) {
23 const [lastEditor, setLastEditor] = useState<EditorId>(() => {
24 const stored = localStorage.getItem(LAST_EDITOR_KEY);
25 return EDITORS.some((e) => e.id === stored) ? (stored as EditorId) : EDITORS[0].id;
26 });
27
28 const allOptions = useMemo<Array<{ label: string; Icon: Icon; value: EditorId }>>(
29 () => [
30 {
31 label: "Cursor",
32 Icon: CursorIcon,
33 value: "cursor",
34 },
35 {
36 label: "VS Code",
37 Icon: VisualStudioCode,
38 value: "vscode",
39 },
40 {
41 label: "Zed",
42 Icon: Zed,
43 value: "zed",
44 },
45 {
46 label: isMacPlatform(navigator.platform)
47 ? "Finder"
48 : isWindowsPlatform(navigator.platform)
49 ? "Explorer"
50 : "Files",
51 Icon: FolderClosedIcon,
52 value: "file-manager",
53 },
54 ],
55 [],
56 );
52 const [preferredEditor, setPreferredEditor] = usePreferredEditor(availableEditors);
5753 const options = useMemo(
58 () => allOptions.filter((option) => availableEditors.includes(option.value)),
59 [allOptions, availableEditors],
54 () => resolveOptions(navigator.platform, availableEditors),
55 [availableEditors],
6056 );
61
62 const effectiveEditor = options.some((option) => option.value === lastEditor)
63 ? lastEditor
64 : (options[0]?.value ?? null);
65 const primaryOption = options.find(({ value }) => value === effectiveEditor) ?? null;
57 const primaryOption = options.find(({ value }) => value === preferredEditor) ?? null;
6658
6759 const openInEditor = useCallback(
6860 (editorId: EditorId | null) => {
6961 const api = readNativeApi();
7062 if (!api || !openInCwd) return;
71 const editor = editorId ?? effectiveEditor;
63 const editor = editorId ?? preferredEditor;
7264 if (!editor) return;
7365 void api.shell.openInEditor(openInCwd, editor);
74 localStorage.setItem(LAST_EDITOR_KEY, editor);
75 setLastEditor(editor);
66 setPreferredEditor(editor);
7667 },
77 [effectiveEditor, openInCwd, setLastEditor],
68 [preferredEditor, openInCwd, setPreferredEditor],
7869 );
7970
8071 const openFavoriteEditorShortcutLabel = useMemo(
......@@ -87,22 +78,22 @@ export const OpenInPicker = memo(function OpenInPicker({
8778 const api = readNativeApi();
8879 if (!isOpenFavoriteEditorShortcut(e, keybindings)) return;
8980 if (!api || !openInCwd) return;
90 if (!effectiveEditor) return;
81 if (!preferredEditor) return;
9182
9283 e.preventDefault();
93 void api.shell.openInEditor(openInCwd, effectiveEditor);
84 void api.shell.openInEditor(openInCwd, preferredEditor);
9485 };
9586 window.addEventListener("keydown", handler);
9687 return () => window.removeEventListener("keydown", handler);
97 }, [effectiveEditor, keybindings, openInCwd]);
88 }, [preferredEditor, keybindings, openInCwd]);
9889
9990 return (
10091 <Group aria-label="Subscription actions">
10192 <Button
10293 size="xs"
10394 variant="outline"
104 disabled={!effectiveEditor || !openInCwd}
105 onClick={() => openInEditor(effectiveEditor)}
95 disabled={!preferredEditor || !openInCwd}
96 onClick={() => openInEditor(preferredEditor)}
10697 >
10798 {primaryOption?.Icon && <primaryOption.Icon aria-hidden="true" className="size-3.5" />}
10899 <span className="sr-only @sm/header-actions:not-sr-only @sm/header-actions:ml-0.5">
......@@ -120,7 +111,7 @@ export const OpenInPicker = memo(function OpenInPicker({
120111 <MenuItem key={value} onClick={() => openInEditor(value)}>
121112 <Icon aria-hidden="true" className="text-muted-foreground" />
122113 {label}
123 {value === effectiveEditor && openFavoriteEditorShortcutLabel && (
114 {value === preferredEditor && openFavoriteEditorShortcutLabel && (
124115 <MenuShortcut>{openFavoriteEditorShortcutLabel}</MenuShortcut>
125116 )}
126117 </MenuItem>
apps/web/src/components/ui/sidebar.tsx+5-3
......@@ -18,6 +18,8 @@ import {
1818import { Skeleton } from "~/components/ui/skeleton";
1919import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip";
2020import { useMediaQuery } from "~/hooks/useMediaQuery";
21import { getLocalStorageItem, setLocalStorageItem } from "~/hooks/useLocalStorage";
22import { Schema } from "effect";
2123
2224const SIDEBAR_COOKIE_NAME = "sidebar_state";
2325const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
......@@ -371,7 +373,7 @@ function SidebarRail({
371373 element.style.removeProperty("transition-duration");
372374 });
373375 if (resolvedResizable?.storageKey && typeof window !== "undefined") {
374 window.localStorage.setItem(resolvedResizable.storageKey, String(resizeState.width));
376 setLocalStorageItem(resolvedResizable.storageKey, resizeState.width, Schema.Finite);
375377 }
376378 resolvedResizable?.onResize?.(resizeState.width);
377379 resizeStateRef.current = null;
......@@ -541,8 +543,8 @@ function SidebarRail({
541543 const wrapper = rail.closest<HTMLElement>("[data-slot='sidebar-wrapper']");
542544 if (!wrapper) return;
543545
544 const storedWidth = Number(window.localStorage.getItem(resolvedResizable.storageKey));
545 if (!Number.isFinite(storedWidth)) return;
546 const storedWidth = getLocalStorageItem(resolvedResizable.storageKey, Schema.Finite);
547 if (storedWidth === null) return;
546548 const clampedWidth = clampSidebarWidth(storedWidth, resolvedResizable);
547549 wrapper.style.setProperty("--sidebar-width", `${clampedWidth}px`);
548550 resolvedResizable.onResize?.(clampedWidth);
apps/web/src/editorPreferences.ts created+35
......@@ -0,0 +1,35 @@
1import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts";
2import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage";
3import { useMemo } from "react";
4
5const LAST_EDITOR_KEY = "t3code:last-editor";
6
7export function usePreferredEditor(availableEditors: ReadonlyArray<EditorId>) {
8 const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId);
9
10 const effectiveEditor = useMemo(() => {
11 if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor;
12 return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null;
13 }, [lastEditor, availableEditors]);
14
15 return [effectiveEditor, setLastEditor] as const;
16}
17
18export function resolveAndPersistPreferredEditor(
19 availableEditors: readonly EditorId[],
20): EditorId | null {
21 const availableEditorIds = new Set(availableEditors);
22 const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId);
23 if (stored && availableEditorIds.has(stored)) return stored;
24 const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null;
25 if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId);
26 return editor ?? null;
27}
28
29export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise<EditorId> {
30 const { availableEditors } = await api.server.getConfig();
31 const editor = resolveAndPersistPreferredEditor(availableEditors);
32 if (!editor) throw new Error("No available editors found.");
33 await api.shell.openInEditor(targetPath, editor);
34 return editor;
35}
apps/web/src/hooks/useLocalStorage.ts created+143
......@@ -0,0 +1,143 @@
1import * as Schema from "effect/Schema";
2import * as Record from "effect/Record";
3import { useCallback, useEffect, useRef, useState } from "react";
4
5const isomorphicLocalStorage: Storage =
6 typeof window !== "undefined"
7 ? window.localStorage
8 : (function () {
9 const store = new Map<string, string>();
10 return {
11 clear: () => store.clear(),
12 getItem: (_) => store.get(_) ?? null,
13 key: (_) => Record.keys(store).at(_) ?? null,
14 get length() {
15 return store.size;
16 },
17 removeItem: (_) => store.delete(_),
18 setItem: (_, value) => store.set(_, value),
19 };
20 })();
21
22const decode = <T, E>(schema: Schema.Codec<T, E>, value: string) =>
23 Schema.decodeSync(Schema.fromJsonString(schema))(value);
24
25const encode = <T, E>(schema: Schema.Codec<T, E>, value: T) =>
26 Schema.encodeSync(Schema.fromJsonString(schema))(value);
27
28export const getLocalStorageItem = <T, E>(key: string, schema: Schema.Codec<T, E>): T | null => {
29 const item = isomorphicLocalStorage.getItem(key);
30 return item ? decode(schema, item) : null;
31};
32
33export const setLocalStorageItem = <T, E>(key: string, value: T, schema: Schema.Codec<T, E>) => {
34 const valueToSet = encode(schema, value);
35 isomorphicLocalStorage.setItem(key, valueToSet);
36};
37
38export const removeLocalStorageItem = (key: string) => {
39 isomorphicLocalStorage.removeItem(key);
40};
41
42const LOCAL_STORAGE_CHANGE_EVENT = "t3code:local_storage_change";
43
44interface LocalStorageChangeDetail {
45 key: string;
46}
47
48function dispatchLocalStorageChange(key: string) {
49 if (typeof window === "undefined") return;
50 window.dispatchEvent(
51 new CustomEvent<LocalStorageChangeDetail>(LOCAL_STORAGE_CHANGE_EVENT, {
52 detail: { key },
53 }),
54 );
55}
56
57export function useLocalStorage<T, E>(
58 key: string,
59 initialValue: T,
60 schema: Schema.Codec<T, E>,
61): [T, (value: T | ((val: T) => T)) => void] {
62 // Get the initial value from localStorage or use the provided initialValue
63 const [storedValue, setStoredValue] = useState<T>(() => {
64 try {
65 const item = getLocalStorageItem(key, schema);
66 return item ?? initialValue;
67 } catch (error) {
68 console.error("[LOCALSTORAGE] Error:", error);
69 return initialValue;
70 }
71 });
72
73 // Return a wrapped version of useState's setter function that persists the new value to localStorage
74 const setValue = useCallback(
75 (value: T | ((val: T) => T)) => {
76 try {
77 setStoredValue((prev) => {
78 const valueToStore = typeof value === "function" ? (value as (val: T) => T)(prev) : value;
79 if (valueToStore === null) {
80 removeLocalStorageItem(key);
81 } else {
82 setLocalStorageItem(key, valueToStore, schema);
83 }
84 // Dispatch event after state update completes to avoid nested state updates
85 queueMicrotask(() => dispatchLocalStorageChange(key));
86 return valueToStore;
87 });
88 } catch (error) {
89 console.error("[LOCALSTORAGE] Error:", error);
90 }
91 },
92 [key, schema],
93 );
94
95 const prevKeyRef = useRef(key);
96
97 // Re-sync from localStorage when key changes
98 useEffect(() => {
99 if (prevKeyRef.current !== key) {
100 prevKeyRef.current = key;
101 try {
102 const newValue = getLocalStorageItem(key, schema);
103 setStoredValue(newValue ?? initialValue);
104 } catch (error) {
105 console.error("[LOCALSTORAGE] Error:", error);
106 }
107 }
108 }, [key, initialValue, schema]);
109
110 // Listen for storage events from other tabs AND custom events from the same tab
111 useEffect(() => {
112 const syncFromStorage = () => {
113 try {
114 const newValue = getLocalStorageItem(key, schema);
115 setStoredValue(newValue ?? initialValue);
116 } catch (error) {
117 console.error("[LOCALSTORAGE] Error:", error);
118 }
119 };
120
121 const handleStorageChange = (event: StorageEvent) => {
122 if (event.key === key) {
123 syncFromStorage();
124 }
125 };
126
127 const handleLocalChange = (event: CustomEvent<LocalStorageChangeDetail>) => {
128 if (event.detail.key === key) {
129 syncFromStorage();
130 }
131 };
132
133 window.addEventListener("storage", handleStorageChange);
134 window.addEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
135
136 return () => {
137 window.removeEventListener("storage", handleStorageChange);
138 window.removeEventListener(LOCAL_STORAGE_CHANGE_EVENT, handleLocalChange as EventListener);
139 };
140 }, [key, initialValue, schema]);
141
142 return [storedValue, setValue];
143}
apps/web/src/routes/__root.tsx+8-4
......@@ -13,12 +13,12 @@ import { Throttler } from "@tanstack/react-pacer";
1313import { APP_DISPLAY_NAME } from "../branding";
1414import { Button } from "../components/ui/button";
1515import { AnchoredToastProvider, ToastProvider, toastManager } from "../components/ui/toast";
16import { resolveAndPersistPreferredEditor } from "../editorPreferences";
1617import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery";
1718import { readNativeApi } from "../nativeApi";
1819import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore";
1920import { useStore } from "../store";
2021import { useTerminalStateStore } from "../terminalStateStore";
21import { preferredTerminalEditor } from "../terminal-links";
2222import { terminalRunningSubprocessFromEvent } from "../terminalActivity";
2323import { onServerConfigUpdated, onServerWelcome } from "../wsNativeApi";
2424import { providerQueryKeys } from "../lib/providerReactQuery";
......@@ -281,9 +281,13 @@ function EventRouter() {
281281 onClick: () => {
282282 void queryClient
283283 .ensureQueryData(serverConfigQueryOptions())
284 .then((config) =>
285 api.shell.openInEditor(config.keybindingsConfigPath, preferredTerminalEditor()),
286 )
284 .then((config) => {
285 const editor = resolveAndPersistPreferredEditor(config.availableEditors);
286 if (!editor) {
287 throw new Error("No available editors found.");
288 }
289 return api.shell.openInEditor(config.keybindingsConfigPath, editor);
290 })
287291 .catch((error) => {
288292 toastManager.add({
289293 type: "error",
apps/web/src/routes/_chat.settings.tsx+10-4
......@@ -3,13 +3,12 @@ import { useQuery } from "@tanstack/react-query";
33import { useCallback, useState } from "react";
44import { type ProviderKind } from "@t3tools/contracts";
55import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model";
6
76import { MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings";
7import { resolveAndPersistPreferredEditor } from "../editorPreferences";
88import { isElectron } from "../env";
99import { useTheme } from "../hooks/useTheme";
1010import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1111import { ensureNativeApi } from "../nativeApi";
12import { preferredTerminalEditor } from "../terminal-links";
1312import { Button } from "../components/ui/button";
1413import { Input } from "../components/ui/input";
1514import { Switch } from "../components/ui/switch";
......@@ -98,14 +97,21 @@ function SettingsRouteView() {
9897 const codexBinaryPath = settings.codexBinaryPath;
9998 const codexHomePath = settings.codexHomePath;
10099 const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null;
100 const availableEditors = serverConfigQuery.data?.availableEditors;
101101
102102 const openKeybindingsFile = useCallback(() => {
103103 if (!keybindingsConfigPath) return;
104104 setOpenKeybindingsError(null);
105105 setIsOpeningKeybindings(true);
106106 const api = ensureNativeApi();
107 const editor = resolveAndPersistPreferredEditor(availableEditors ?? []);
108 if (!editor) {
109 setOpenKeybindingsError("No available editors found.");
110 setIsOpeningKeybindings(false);
111 return;
112 }
107113 void api.shell
108 .openInEditor(keybindingsConfigPath, preferredTerminalEditor())
114 .openInEditor(keybindingsConfigPath, editor)
109115 .catch((error) => {
110116 setOpenKeybindingsError(
111117 error instanceof Error ? error.message : "Unable to open keybindings file.",
......@@ -114,7 +120,7 @@ function SettingsRouteView() {
114120 .finally(() => {
115121 setIsOpeningKeybindings(false);
116122 });
117 }, [keybindingsConfigPath]);
123 }, [availableEditors, keybindingsConfigPath]);
118124
119125 const addCustomModel = useCallback(
120126 (provider: ProviderKind) => {
apps/web/src/terminal-links.ts-22
......@@ -1,4 +1,3 @@
1import { EDITORS, type EditorId } from "@t3tools/contracts";
21import { isMacPlatform } from "./lib/utils";
32
43export type TerminalLinkKind = "url" | "path";
......@@ -14,7 +13,6 @@ const URL_PATTERN = /https?:\/\/[^\s"'`<>]+/g;
1413const FILE_PATH_PATTERN =
1514 /(?:~\/|\.{1,2}\/|\/|[A-Za-z]:\\|\\\\)[^\s"'`<>]+|[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)+(?::\d+){0,2}/g;
1615const TRAILING_PUNCTUATION_PATTERN = /[.,;!?]+$/;
17const LAST_EDITOR_KEY = "t3code:last-editor";
1816
1917function trimClosingDelimiters(value: string): string {
2018 let output = value.replace(TRAILING_PUNCTUATION_PATTERN, "");
......@@ -175,23 +173,3 @@ export function resolvePathLinkTarget(rawPath: string, cwd: string): string {
175173 if (!line) return resolvedPath;
176174 return `${resolvedPath}:${line}${column ? `:${column}` : ""}`;
177175}
178
179export function preferredTerminalEditor(): EditorId {
180 const fallback = EDITORS.find((editor) => editor.command)?.id ?? EDITORS[0]?.id ?? "cursor";
181
182 if (typeof window === "undefined") {
183 return fallback;
184 }
185
186 const storedEditor = window.localStorage.getItem(LAST_EDITOR_KEY);
187 if (!storedEditor) {
188 return fallback;
189 }
190
191 const configured = EDITORS.find((editor) => editor.id === storedEditor);
192 if (!configured?.command) {
193 return fallback;
194 }
195
196 return configured.id;
197}