From a99b8e5b9b8d770c1cc482aa4b0b1433d2078067 Mon Sep 17 00:00:00 2001 From: Nikhil Sah <106432581+Sah-Nikhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 03:45:13 +0530 Subject: [PATCH 1/3] v0.1.0 - Chatbox Overflow Fix + Multi IDE Support Added overflow fix for the chatbox when plan & diff sidebars are open Added multi IDE Support (with fallbacks & path specific values) --- apps/server/src/open.ts | 20 +- .../src/provider/Layers/CopilotAdapter.ts | 8 +- apps/web/src/appSettings.ts | 30 ++- apps/web/src/components/ChatMarkdown.tsx | 16 +- apps/web/src/components/ChatView.tsx | 195 ++++-------------- .../src/components/ComposerPromptEditor.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 16 +- apps/web/src/components/chat/OpenInPicker.tsx | 25 ++- apps/web/src/editorPreferences.ts | 135 ++++++++++-- apps/web/src/routes/__root.tsx | 12 +- apps/web/src/routes/_chat.settings.tsx | 141 ++++++++++++- apps/web/src/wsNativeApi.ts | 4 +- packages/contracts/src/editor.ts | 1 + packages/contracts/src/ipc.ts | 2 +- 14 files changed, 397 insertions(+), 210 deletions(-) diff --git a/apps/server/src/open.ts b/apps/server/src/open.ts index e7238c04b29f..d7d079f8f139 100644 --- a/apps/server/src/open.ts +++ b/apps/server/src/open.ts @@ -8,7 +8,7 @@ */ import { spawn } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; -import { extname, join } from "node:path"; +import { extname, isAbsolute, join } from "node:path"; import { EDITORS, type EditorId } from "@t3tools/contracts"; import { ServiceMap, Schema, Effect, Layer } from "effect"; @@ -25,6 +25,7 @@ export class OpenError extends Schema.TaggedErrorClass()("OpenError", export interface OpenInEditorInput { readonly cwd: string; readonly editor: EditorId; + readonly executablePath?: string | undefined; } interface EditorLaunch { @@ -207,6 +208,23 @@ export const resolveEditorLaunch = Effect.fnUntraced(function* ( input: OpenInEditorInput, platform: NodeJS.Platform = process.platform, ): Effect.fn.Return { + const explicitExecutablePath = input.executablePath?.trim(); + if (explicitExecutablePath && explicitExecutablePath.length > 0) { + if (!isAbsolute(explicitExecutablePath)) { + return yield* new OpenError({ + message: `Executable path must be absolute: ${explicitExecutablePath}`, + }); + } + if (!isCommandAvailable(explicitExecutablePath, { platform })) { + return yield* new OpenError({ + message: `Editor executable path is not runnable: ${explicitExecutablePath}`, + }); + } + return shouldUseGotoFlag(input.editor, input.cwd) + ? { command: explicitExecutablePath, args: ["--goto", input.cwd] } + : { command: explicitExecutablePath, args: [input.cwd] }; + } + const editorDef = EDITORS.find((editor) => editor.id === input.editor); if (!editorDef) { return yield* new OpenError({ message: `Unknown editor: ${input.editor}` }); diff --git a/apps/server/src/provider/Layers/CopilotAdapter.ts b/apps/server/src/provider/Layers/CopilotAdapter.ts index e42f4e95179e..f71d048f027b 100644 --- a/apps/server/src/provider/Layers/CopilotAdapter.ts +++ b/apps/server/src/provider/Layers/CopilotAdapter.ts @@ -1592,10 +1592,10 @@ const makeCopilotAdapter = (options?: CopilotAdapterLiveOptions) => resumeCursor: record.session.sessionId, createdAt: record.createdAt, updatedAt: record.updatedAt, - ...(record.cwd ? { cwd: record.cwd } : {}), - ...(record.model ? { model: record.model } : {}), - ...(record.currentTurnId ? { activeTurnId: record.currentTurnId } : {}), - ...(record.lastError ? { lastError: record.lastError } : {}), + cwd: record.cwd, + model: record.model, + activeTurnId: record.currentTurnId, + lastError: record.lastError, }) satisfies ProviderSession, ), ); diff --git a/apps/web/src/appSettings.ts b/apps/web/src/appSettings.ts index 749f176064b4..c2ef9d49d6af 100644 --- a/apps/web/src/appSettings.ts +++ b/apps/web/src/appSettings.ts @@ -1,6 +1,6 @@ import { useCallback, useSyncExternalStore } from "react"; import { Option, Schema } from "effect"; -import { TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; +import { EditorId, TrimmedNonEmptyString, type ProviderKind } from "@t3tools/contracts"; import { getDefaultModel, getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; const APP_SETTINGS_STORAGE_KEY = "t3code:app-settings:v1"; @@ -43,6 +43,12 @@ const AppSettingsSchema = Schema.Struct({ customCopilotModels: Schema.Array(Schema.String).pipe( Schema.withConstructorDefault(() => Option.some([])), ), + preferredEditor: Schema.NullOr(EditorId).pipe( + Schema.withConstructorDefault(() => Option.some(null)), + ), + preferredEditorExecutablePath: Schema.String.check(Schema.isMaxLength(4096)).pipe( + Schema.withConstructorDefault(() => Option.some("")), + ), textGenerationModel: Schema.optional(TrimmedNonEmptyString), }); export type AppSettings = typeof AppSettingsSchema.Type; @@ -100,6 +106,7 @@ function normalizeAppSettings(settings: AppSettings): AppSettings { ...settings, customCodexModels: normalizeCustomModelSlugs(settings.customCodexModels, "codex"), customCopilotModels: normalizeCustomModelSlugs(settings.customCopilotModels, "copilot"), + preferredEditorExecutablePath: settings.preferredEditorExecutablePath.trim(), }; } @@ -245,6 +252,18 @@ function persistSettings(next: AppSettings): void { cachedSnapshot = next; } +export function updateAppSettings(patch: Partial): AppSettings { + const next = normalizeAppSettings( + Schema.decodeSync(AppSettingsSchema)({ + ...getAppSettingsSnapshot(), + ...patch, + }), + ); + persistSettings(next); + emitChange(); + return next; +} + function subscribe(listener: () => void): () => void { listeners.push(listener); @@ -269,14 +288,7 @@ export function useAppSettings() { ); const updateSettings = useCallback((patch: Partial) => { - const next = normalizeAppSettings( - Schema.decodeSync(AppSettingsSchema)({ - ...getAppSettingsSnapshot(), - ...patch, - }), - ); - persistSettings(next); - emitChange(); + updateAppSettings(patch); }, []); const resetSettings = useCallback(() => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 9663d158ebb0..bdf5e5696a6f 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -23,6 +23,7 @@ import { LRUCache } from "../lib/lruCache"; import { useTheme } from "../hooks/useTheme"; import { resolveMarkdownFileLinkTarget } from "../markdown-links"; import { readNativeApi } from "../nativeApi"; +import { toastManager } from "./ui/toast"; class CodeHighlightErrorBoundary extends React.Component< { fallback: ReactNode; children: ReactNode }, @@ -255,9 +256,20 @@ function ChatMarkdown({ text, cwd, isStreaming = false }: ChatMarkdownProps) { event.stopPropagation(); const api = readNativeApi(); if (api) { - void openInPreferredEditor(api, targetPath); + void openInPreferredEditor(api, targetPath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open file", + description: + error instanceof Error ? error.message : "Unknown editor launch error.", + }); + }); } else { - console.warn("Native API not found. Unable to open file in editor."); + toastManager.add({ + type: "error", + title: "Editor opening is unavailable", + description: "Native API is not available in this environment.", + }); } }} /> diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index eef7c2bc690e..ec9583d870c5 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, @@ -54,6 +53,7 @@ import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery"; import { skillsListQueryOptions } from "~/lib/skillsReactQuery"; +import { OpenInPicker } from "./chat/OpenInPicker"; import { isElectron } from "../env"; import { parseDiffRouteSearch, stripDiffSearchParams } from "../diffRouteSearch"; @@ -125,11 +125,7 @@ import { } from "../lib/turnDiffTree"; import BranchToolbar from "./BranchToolbar"; import GitActionsControl from "./GitActionsControl"; -import { - isOpenFavoriteEditorShortcut, - resolveShortcutCommand, - shortcutLabelForCommand, -} from "../keybindings"; +import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; import ChatMarkdown from "./ChatMarkdown"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; @@ -169,7 +165,6 @@ import { Button } from "./ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; import { Input } from "./ui/input"; import { Separator } from "./ui/separator"; -import { Group, GroupSeparator } from "./ui/group"; import { Menu, MenuGroup, @@ -181,21 +176,10 @@ import { MenuSub, MenuSubPopup, MenuSubTrigger, - MenuShortcut, MenuTrigger, } from "./ui/menu"; -import { - ClaudeAI, - CursorIcon, - Gemini, - GitHubIcon, - Icon, - OpenAI, - OpenCodeIcon, - VisualStudioCode, - Zed, -} from "./Icons"; -import { cn, isMacPlatform, isWindowsPlatform, randomUUID } from "~/lib/utils"; +import { ClaudeAI, CursorIcon, Gemini, GitHubIcon, Icon, OpenAI, OpenCodeIcon } from "./Icons"; +import { cn, randomUUID } from "~/lib/utils"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { Command, CommandItem, CommandList } from "./ui/command"; @@ -305,7 +289,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 MAX_VISIBLE_WORK_LOG_ENTRIES = 6; const ALWAYS_UNVIRTUALIZED_TAIL_ROWS = 8; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; @@ -4467,7 +4450,7 @@ export default function ChatView({ threadId }: ChatViewProps) { data-chat-composer-form="true" >
)} {activePendingApproval ? ( -
+
) : pendingUserInputs.length > 0 ? ( -
+
) : showPlanFollowUpPrompt && activeProposedPlan ? ( -
+
@@ -4644,7 +4627,7 @@ export default function ChatView({ threadId }: ChatViewProps) { {/* Bottom toolbar */} {activePendingApproval ? ( -
+
-
+
+
PENDING APPROVAL - {approvalSummary} + + {approvalSummary} + {pendingCount > 1 ? ( 1/{pendingCount} ) : null} @@ -5336,6 +5321,7 @@ const ComposerPendingApprovalActions = memo(function ComposerPendingApprovalActi - - - }> - - - {options.length === 0 && No installed editors found} - {options.map(({ label, Icon, value }) => ( - openInEditor(value)}> - - ))} - - - - ); -}); diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 39c64aaa16e1..a4d720aa28ec 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -1212,7 +1212,7 @@ function ComposerPromptEditorInner({ } placeholder={ terminalContexts.length > 0 ? null : ( -
+
{placeholder}
) diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 34ad78881467..d8a9db5489dc 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -27,6 +27,7 @@ import { useStore } from "../store"; import { useAppSettings } from "../appSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffPanelLoadingState, DiffPanelShell, type DiffPanelMode } from "./DiffPanelShell"; +import { toastManager } from "./ui/toast"; import { ToggleGroup, Toggle } from "./ui/toggle-group"; type DiffRenderMode = "stacked" | "split"; @@ -306,10 +307,21 @@ export default function DiffPanel({ mode = "inline" }: DiffPanelProps) { const openDiffFileInEditor = useCallback( (filePath: string) => { const api = readNativeApi(); - if (!api) return; + if (!api) { + toastManager.add({ + type: "error", + title: "Editor opening is unavailable", + description: "Native API is not available in this environment.", + }); + return; + } const targetPath = activeCwd ? resolvePathLinkTarget(filePath, activeCwd) : filePath; void openInPreferredEditor(api, targetPath).catch((error) => { - console.warn("Failed to open diff file in editor.", error); + toastManager.add({ + type: "error", + title: "Unable to open file", + description: error instanceof Error ? error.message : "Unknown editor launch error.", + }); }); }, [activeCwd], diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index 9f62f7121e80..93d65ba5c5c2 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -1,12 +1,13 @@ import { EditorId, type ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { memo, useCallback, useEffect, useMemo } from "react"; import { isOpenFavoriteEditorShortcut, shortcutLabelForCommand } from "../../keybindings"; -import { usePreferredEditor } from "../../editorPreferences"; +import { resolveExecutablePathForEditor, usePreferredEditor } from "../../editorPreferences"; import { ChevronDownIcon, FolderClosedIcon } from "lucide-react"; import { Button } from "../ui/button"; import { Group, GroupSeparator } from "../ui/group"; import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; import { AntigravityIcon, CursorIcon, Icon, VisualStudioCode, Zed } from "../Icons"; +import { toastManager } from "../ui/toast"; import { isMacPlatform, isWindowsPlatform } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; @@ -67,10 +68,17 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!api || !openInCwd) return; const editor = editorId ?? preferredEditor; if (!editor) return; - void api.shell.openInEditor(openInCwd, editor); + const executablePath = resolveExecutablePathForEditor(editor, availableEditors); + void api.shell.openInEditor(openInCwd, editor, executablePath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open editor", + description: error instanceof Error ? error.message : "Unknown editor launch error.", + }); + }); setPreferredEditor(editor); }, - [preferredEditor, openInCwd, setPreferredEditor], + [availableEditors, preferredEditor, openInCwd, setPreferredEditor], ); const openFavoriteEditorShortcutLabel = useMemo( @@ -86,11 +94,18 @@ export const OpenInPicker = memo(function OpenInPicker({ if (!preferredEditor) return; e.preventDefault(); - void api.shell.openInEditor(openInCwd, preferredEditor); + const executablePath = resolveExecutablePathForEditor(preferredEditor, availableEditors); + void api.shell.openInEditor(openInCwd, preferredEditor, executablePath).catch((error) => { + toastManager.add({ + type: "error", + title: "Unable to open editor", + description: error instanceof Error ? error.message : "Unknown editor launch error.", + }); + }); }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [preferredEditor, keybindings, openInCwd]); + }, [availableEditors, preferredEditor, keybindings, openInCwd]); return ( diff --git a/apps/web/src/editorPreferences.ts b/apps/web/src/editorPreferences.ts index ca43f3e5d87e..69cf61f2a898 100644 --- a/apps/web/src/editorPreferences.ts +++ b/apps/web/src/editorPreferences.ts @@ -1,35 +1,140 @@ import { EDITORS, EditorId, NativeApi } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo } from "react"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "./hooks/useLocalStorage"; -import { useMemo } from "react"; +import { getAppSettingsSnapshot, updateAppSettings, useAppSettings } from "./appSettings"; const LAST_EDITOR_KEY = "t3code:last-editor"; +export interface ResolvedPreferredEditor { + readonly editor: EditorId | null; + readonly executablePath: string | null; +} + +function normalizeExecutablePath(path: string): string | null { + const trimmed = path.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function resolveFallbackEditor(availableEditorIds: ReadonlySet): EditorId | null { + return EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; +} + +function persistLegacyEditor(editor: EditorId): void { + setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); +} + +function resolvePreferredEditorLaunch(input: { + readonly preferredEditor: EditorId | null; + readonly preferredEditorExecutablePath: string; + readonly availableEditors: readonly EditorId[]; + readonly legacyStoredEditor: EditorId | null; +}): ResolvedPreferredEditor { + const availableEditorIds = new Set(input.availableEditors); + const executablePath = normalizeExecutablePath(input.preferredEditorExecutablePath); + if (input.preferredEditor !== null) { + if (availableEditorIds.has(input.preferredEditor)) { + return { editor: input.preferredEditor, executablePath: null }; + } + if (executablePath) { + return { editor: input.preferredEditor, executablePath }; + } + } else if (input.legacyStoredEditor && availableEditorIds.has(input.legacyStoredEditor)) { + return { editor: input.legacyStoredEditor, executablePath: null }; + } + + const fallbackEditor = resolveFallbackEditor(availableEditorIds); + if (fallbackEditor) { + return { editor: fallbackEditor, executablePath: null }; + } + return { editor: null, executablePath: null }; +} + +export function resolveAndPersistPreferredEditorLaunch( + availableEditors: readonly EditorId[], +): ResolvedPreferredEditor { + const settings = getAppSettingsSnapshot(); + const resolved = resolvePreferredEditorLaunch({ + preferredEditor: settings.preferredEditor, + preferredEditorExecutablePath: settings.preferredEditorExecutablePath, + availableEditors, + legacyStoredEditor: getLocalStorageItem(LAST_EDITOR_KEY, EditorId), + }); + if (resolved.editor) { + persistLegacyEditor(resolved.editor); + if (resolved.executablePath === null && settings.preferredEditor !== resolved.editor) { + updateAppSettings({ preferredEditor: resolved.editor }); + } + } + return resolved; +} + +export function resolveExecutablePathForEditor( + editor: EditorId, + availableEditors: readonly EditorId[], +): string | undefined { + const settings = getAppSettingsSnapshot(); + if (settings.preferredEditor !== editor) { + return undefined; + } + if (availableEditors.includes(editor)) { + return undefined; + } + return normalizeExecutablePath(settings.preferredEditorExecutablePath) ?? undefined; +} + export function usePreferredEditor(availableEditors: ReadonlyArray) { - const [lastEditor, setLastEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); + const { settings, updateSettings } = useAppSettings(); + const [legacyStoredEditor] = useLocalStorage(LAST_EDITOR_KEY, null, EditorId); + + const resolved = useMemo( + () => + resolvePreferredEditorLaunch({ + preferredEditor: settings.preferredEditor, + preferredEditorExecutablePath: settings.preferredEditorExecutablePath, + availableEditors, + legacyStoredEditor, + }), + [ + availableEditors, + legacyStoredEditor, + settings.preferredEditor, + settings.preferredEditorExecutablePath, + ], + ); + + useEffect(() => { + if (!resolved.editor) { + return; + } + persistLegacyEditor(resolved.editor); + if (resolved.executablePath === null && settings.preferredEditor !== resolved.editor) { + updateSettings({ preferredEditor: resolved.editor }); + } + }, [resolved.editor, resolved.executablePath, settings.preferredEditor, updateSettings]); - const effectiveEditor = useMemo(() => { - if (lastEditor && availableEditors.includes(lastEditor)) return lastEditor; - return EDITORS.find((editor) => availableEditors.includes(editor.id))?.id ?? null; - }, [lastEditor, availableEditors]); + const setPreferredEditor = useCallback( + (editor: EditorId | null) => { + updateSettings({ preferredEditor: editor }); + if (editor) { + persistLegacyEditor(editor); + } + }, + [updateSettings], + ); - return [effectiveEditor, setLastEditor] as const; + return [resolved.editor, setPreferredEditor] as const; } export function resolveAndPersistPreferredEditor( availableEditors: readonly EditorId[], ): EditorId | null { - const availableEditorIds = new Set(availableEditors); - const stored = getLocalStorageItem(LAST_EDITOR_KEY, EditorId); - if (stored && availableEditorIds.has(stored)) return stored; - const editor = EDITORS.find((editor) => availableEditorIds.has(editor.id))?.id ?? null; - if (editor) setLocalStorageItem(LAST_EDITOR_KEY, editor, EditorId); - return editor ?? null; + return resolveAndPersistPreferredEditorLaunch(availableEditors).editor; } export async function openInPreferredEditor(api: NativeApi, targetPath: string): Promise { const { availableEditors } = await api.server.getConfig(); - const editor = resolveAndPersistPreferredEditor(availableEditors); + const { editor, executablePath } = resolveAndPersistPreferredEditorLaunch(availableEditors); if (!editor) throw new Error("No available editors found."); - await api.shell.openInEditor(targetPath, editor); + await api.shell.openInEditor(targetPath, editor, executablePath ?? undefined); return editor; } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 34f9c4b82f8d..9dba94169211 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -13,7 +13,7 @@ import { Throttler } from "@tanstack/react-pacer"; 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 { resolveAndPersistPreferredEditorLaunch } from "../editorPreferences"; import { serverConfigQueryOptions, serverQueryKeys } from "../lib/serverReactQuery"; import { readNativeApi } from "../nativeApi"; import { clearPromotedDraftThreads, useComposerDraftStore } from "../composerDraftStore"; @@ -282,11 +282,17 @@ function EventRouter() { void queryClient .ensureQueryData(serverConfigQueryOptions()) .then((config) => { - const editor = resolveAndPersistPreferredEditor(config.availableEditors); + const { editor, executablePath } = resolveAndPersistPreferredEditorLaunch( + config.availableEditors, + ); if (!editor) { throw new Error("No available editors found."); } - return api.shell.openInEditor(config.keybindingsConfigPath, editor); + return api.shell.openInEditor( + config.keybindingsConfigPath, + editor, + executablePath ?? undefined, + ); }) .catch((error) => { toastManager.add({ diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx index dd9c59f71240..c01fd95120b4 100644 --- a/apps/web/src/routes/_chat.settings.tsx +++ b/apps/web/src/routes/_chat.settings.tsx @@ -1,10 +1,15 @@ import { createFileRoute } from "@tanstack/react-router"; import { useQuery } from "@tanstack/react-query"; import { useCallback, useState } from "react"; -import { type ProviderKind, DEFAULT_GIT_TEXT_GENERATION_MODEL } from "@t3tools/contracts"; +import { + DEFAULT_GIT_TEXT_GENERATION_MODEL, + EDITORS, + type EditorId, + type ProviderKind, +} from "@t3tools/contracts"; import { getModelOptions, normalizeModelSlug } from "@t3tools/shared/model"; import { getAppModelOptions, MAX_CUSTOM_MODEL_LENGTH, useAppSettings } from "../appSettings"; -import { resolveAndPersistPreferredEditor } from "../editorPreferences"; +import { resolveAndPersistPreferredEditorLaunch } from "../editorPreferences"; import { isElectron } from "../env"; import { useTheme } from "../hooks/useTheme"; import { serverConfigQueryOptions } from "../lib/serverReactQuery"; @@ -61,6 +66,16 @@ const TIMESTAMP_FORMAT_LABELS = { "12-hour": "12-hour", "24-hour": "24-hour", } as const; +const PREFERRED_EDITOR_DEFAULT_VALUE = "__default__"; +const EMPTY_AVAILABLE_EDITORS: ReadonlyArray = []; + +function isEditorId(value: string): value is EditorId { + return EDITORS.some((editor) => editor.id === value); +} + +function labelForEditor(editorId: EditorId): string { + return EDITORS.find((editor) => editor.id === editorId)?.label ?? editorId; +} function getCustomModelsForProvider( settings: ReturnType["settings"], @@ -111,7 +126,19 @@ function SettingsRouteView() { const codexBinaryPath = settings.codexBinaryPath; const codexHomePath = settings.codexHomePath; const keybindingsConfigPath = serverConfigQuery.data?.keybindingsConfigPath ?? null; - const availableEditors = serverConfigQuery.data?.availableEditors; + const availableEditors = serverConfigQuery.data?.availableEditors ?? EMPTY_AVAILABLE_EDITORS; + const availableEditorIds = new Set(availableEditors); + const preferredEditorOptions = EDITORS.filter((editor) => availableEditorIds.has(editor.id)); + const preferredEditorSelectValue: EditorId | typeof PREFERRED_EDITOR_DEFAULT_VALUE = + settings.preferredEditor !== null && availableEditorIds.has(settings.preferredEditor) + ? settings.preferredEditor + : PREFERRED_EDITOR_DEFAULT_VALUE; + const preferredEditorUnavailable = + settings.preferredEditor !== null && !availableEditorIds.has(settings.preferredEditor); + const preferredEditorPathPlaceholder = + typeof navigator !== "undefined" && navigator.platform.startsWith("Win") + ? "C:\\Tools\\Editor\\editor.exe" + : "/usr/local/bin/editor"; const gitTextGenerationModelOptions = getAppModelOptions( "codex", @@ -129,14 +156,14 @@ function SettingsRouteView() { setOpenKeybindingsError(null); setIsOpeningKeybindings(true); const api = ensureNativeApi(); - const editor = resolveAndPersistPreferredEditor(availableEditors ?? []); + const { editor, executablePath } = resolveAndPersistPreferredEditorLaunch(availableEditors); if (!editor) { setOpenKeybindingsError("No available editors found."); setIsOpeningKeybindings(false); return; } void api.shell - .openInEditor(keybindingsConfigPath, editor) + .openInEditor(keybindingsConfigPath, editor, executablePath ?? undefined) .catch((error) => { setOpenKeybindingsError( error instanceof Error ? error.message : "Unable to open keybindings file.", @@ -320,6 +347,110 @@ function SettingsRouteView() {
+
+
+

Editors

+

+ Choose the default editor used by Open actions and keyboard shortcuts. +

+
+ +
+
+
+

Preferred editor

+

+ System default uses the first available editor from server config. +

+
+ +
+ + + + {availableEditors.length === 0 ? ( +

+ No available editors were detected from server config. +

+ ) : null} + + {preferredEditorUnavailable ? ( +

+ Saved preferred editor ({labelForEditor(settings.preferredEditor)}) is not + currently available. Open actions will try this custom path if set. +

+ ) : null} + + {(settings.preferredEditor !== defaults.preferredEditor || + settings.preferredEditorExecutablePath !== + defaults.preferredEditorExecutablePath) && ( +
+ +
+ )} +
+
+

Codex App Server

diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index ee925dc3b380..b1a3385a265b 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -116,8 +116,8 @@ export function createWsNativeApi(): NativeApi { writeFile: (input) => transport.request(WS_METHODS.projectsWriteFile, input), }, shell: { - openInEditor: (cwd, editor) => - transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor }), + openInEditor: (cwd, editor, executablePath) => + transport.request(WS_METHODS.shellOpenInEditor, { cwd, editor, executablePath }), openExternal: async (url) => { if (window.desktopBridge) { const opened = await window.desktopBridge.openExternal(url); diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 0ebd4fe5ae0d..bbbf919bd018 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -15,5 +15,6 @@ export type EditorId = typeof EditorId.Type; export const OpenInEditorInput = Schema.Struct({ cwd: TrimmedNonEmptyString, editor: EditorId, + executablePath: Schema.optional(Schema.String.check(Schema.isMaxLength(4096))), }); export type OpenInEditorInput = typeof OpenInEditorInput.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 07cd4afc5e04..7d7f01f3d1f6 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -155,7 +155,7 @@ export interface NativeApi { writeFile: (input: ProjectWriteFileInput) => Promise; }; shell: { - openInEditor: (cwd: string, editor: EditorId) => Promise; + openInEditor: (cwd: string, editor: EditorId, executablePath?: string) => Promise; openExternal: (url: string) => Promise; }; git: { From e3af329471ee967b549c9edc953acf749867cea8 Mon Sep 17 00:00:00 2001 From: Nikhil Sah <106432581+Sah-Nikhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 04:03:59 +0530 Subject: [PATCH 2/3] fix: rebrand from T3 Code to V3 Copilot in branding strings Update branding strings in source code and test files: - codexAppServerManager.ts: Change title to 'V3 Copilot Desktop' - codexCliVersion.ts: Update error message to use 'V3 Copilot' - Update test expectations to match new branding Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/codexAppServerManager.test.ts | 8 ++++---- apps/server/src/codexAppServerManager.ts | 2 +- apps/server/src/provider/Layers/ProviderHealth.test.ts | 2 +- apps/server/src/provider/codexCliVersion.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index cea8df0a0b0a..1969c67b92ac 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -274,7 +274,7 @@ describe("startSession", () => { expect(buildCodexInitializeParams()).toEqual({ clientInfo: { name: "t3code_desktop", - title: "T3 Code Desktop", + title: "V3 Copilot Desktop", version: "0.1.0", }, capabilities: { @@ -341,7 +341,7 @@ describe("startSession", () => { ) .mockImplementation(() => { throw new Error( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for V3 Copilot. Upgrade to v0.37.0 or newer and restart V3 Copilot.", ); }); @@ -353,7 +353,7 @@ describe("startSession", () => { runtimeMode: "full-access", }), ).rejects.toThrow( - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for V3 Copilot. Upgrade to v0.37.0 or newer and restart V3 Copilot.", ); expect(versionCheck).toHaveBeenCalledTimes(1); expect(events).toEqual([ @@ -361,7 +361,7 @@ describe("startSession", () => { method: "session/startFailed", kind: "error", message: - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for V3 Copilot. Upgrade to v0.37.0 or newer and restart V3 Copilot.", }, ]); } finally { diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index a8a8ce4607b3..67a53b8140b0 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -405,7 +405,7 @@ export function buildCodexInitializeParams() { return { clientInfo: { name: "t3code_desktop", - title: "T3 Code Desktop", + title: "V3 Copilot Desktop", version: "0.1.0", }, capabilities: { diff --git a/apps/server/src/provider/Layers/ProviderHealth.test.ts b/apps/server/src/provider/Layers/ProviderHealth.test.ts index 9aeb5fd78dbc..76daf1b93163 100644 --- a/apps/server/src/provider/Layers/ProviderHealth.test.ts +++ b/apps/server/src/provider/Layers/ProviderHealth.test.ts @@ -145,7 +145,7 @@ it.layer(NodeServices.layer)("ProviderHealth", (it) => { assert.strictEqual(status.authStatus, "unknown"); assert.strictEqual( status.message, - "Codex CLI v0.36.0 is too old for T3 Code. Upgrade to v0.37.0 or newer and restart T3 Code.", + "Codex CLI v0.36.0 is too old for V3 Copilot. Upgrade to v0.37.0 or newer and restart V3 Copilot.", ); }).pipe( Effect.provide( diff --git a/apps/server/src/provider/codexCliVersion.ts b/apps/server/src/provider/codexCliVersion.ts index 544020016c62..290d7eb982e2 100644 --- a/apps/server/src/provider/codexCliVersion.ts +++ b/apps/server/src/provider/codexCliVersion.ts @@ -137,5 +137,5 @@ export function isCodexCliVersionSupported(version: string): boolean { export function formatCodexCliUpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; - return `Codex CLI ${versionLabel} is too old for T3 Code. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart T3 Code.`; + return `Codex CLI ${versionLabel} is too old for V3 Copilot. Upgrade to v${MINIMUM_CODEX_CLI_VERSION} or newer and restart V3 Copilot.`; } From 49018a6182f8e3d662c60549fc8c77666f3e229d Mon Sep 17 00:00:00 2001 From: Nikhil Sah <106432581+Sah-Nikhil@users.noreply.github.com> Date: Fri, 20 Mar 2026 04:14:00 +0530 Subject: [PATCH 3/3] fix: fix formatting and update client name to v3copilot_desktop - Remove extra blank line in buildCodexInitializeParams - Change client name from 'V3 Copilot_desktop' to 'v3copilot_desktop' (lowercase, no space) - Update test expectations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- apps/server/src/codexAppServerManager.test.ts | 2 +- apps/server/src/codexAppServerManager.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/server/src/codexAppServerManager.test.ts b/apps/server/src/codexAppServerManager.test.ts index 1969c67b92ac..dd2873bcc084 100644 --- a/apps/server/src/codexAppServerManager.test.ts +++ b/apps/server/src/codexAppServerManager.test.ts @@ -273,7 +273,7 @@ describe("startSession", () => { it("enables Codex experimental api capabilities during initialize", () => { expect(buildCodexInitializeParams()).toEqual({ clientInfo: { - name: "t3code_desktop", + name: "v3copilot_desktop", title: "V3 Copilot Desktop", version: "0.1.0", }, diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index b80bb03db3bc..56895e7dc8b7 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -404,10 +404,9 @@ export function normalizeCodexModelSlug( export function buildCodexInitializeParams() { return { clientInfo: { - name: "V3 Copilot_desktop", + name: "v3copilot_desktop", title: "V3 Copilot Desktop", version: "0.1.0", - }, capabilities: { experimentalApi: true,