Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions apps/web/src/appSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest";

import {
getAppModelOptions,
getSlashModelOptions,
normalizeCustomModelSlugs,
resolveAppModelSelection,
} from "./appSettings";
Expand Down Expand Up @@ -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"]);
});
});
128 changes: 16 additions & 112 deletions apps/web/src/appSettings.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<string | null | undefined>,
provider: ProviderKind = "codex",
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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<AppSettings>) => {
const next = normalizeAppSettings(
Schema.decodeSync(AppSettingsSchema)({
...getAppSettingsSnapshot(),
const updateSettings = useCallback(
(patch: Partial<AppSettings>) => {
setSettings((prev) => ({
...prev,
...patch,
}),
);
persistSettings(next);
emitChange();
}, []);
}));
},
[setSettings],
);

const resetSettings = useCallback(() => {
persistSettings(DEFAULT_APP_SETTINGS);
emitChange();
}, []);
setSettings(DEFAULT_APP_SETTINGS);
}, [setSettings]);

return {
settings,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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.");
}
Expand Down
21 changes: 3 additions & 18 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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,
Expand Down
27 changes: 8 additions & 19 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down Expand Up @@ -267,9 +268,11 @@ export default function ChatView({ threadId }: ChatViewProps) {
const [composerTrigger, setComposerTrigger] = useState<ComposerTrigger | null>(() =>
detectComposerTrigger(prompt, prompt.length),
);
const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useState<
Record<string, string>
>(() => readLastInvokedScriptByProjectFromStorage());
const [lastInvokedScriptByProjectId, setLastInvokedScriptByProjectId] = useLocalStorage(
LAST_INVOKED_SCRIPT_BY_PROJECT_KEY,
{},
LastInvokedScriptByProjectSchema,
);
const messagesScrollRef = useRef<HTMLDivElement>(null);
const [messagesScrollElement, setMessagesScrollElement] = useState<HTMLDivElement | null>(null);
const shouldAutoScrollRef = useRef(true);
Expand Down Expand Up @@ -1196,6 +1199,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
setThreadError,
storeNewTerminal,
storeSetActiveTerminal,
setLastInvokedScriptByProjectId,
terminalState.activeTerminalId,
terminalState.runningTerminalIds,
terminalState.terminalIds,
Expand Down Expand Up @@ -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") => {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
},
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/GitActionsControl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/ThreadTerminalDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading