From ded26875d53065052a523cb7bf703bd65b20c84c Mon Sep 17 00:00:00 2001 From: mrytsr Date: Sat, 5 Sep 2026 00:35:19 +0800 Subject: [PATCH 01/19] fix(chat): preserve native selection menu on touch long press --- .../conversation-detail-panel.tsx | 3 + .../use-context-menu-pointer-guard.test.tsx | 124 ++++++++++++++++++ src/hooks/use-context-menu-pointer-guard.ts | 41 ++++++ 3 files changed, 168 insertions(+) create mode 100644 src/hooks/use-context-menu-pointer-guard.test.tsx create mode 100644 src/hooks/use-context-menu-pointer-guard.ts diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 2e9d0b36e8..2423c879f1 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -49,6 +49,7 @@ import { useAdvertisedGoalActions } from "@/hooks/use-goal-actions" import { ConversationShell } from "@/components/chat/conversation-shell" import { SessionConfigStaleBanner } from "@/components/chat/session-config-stale-banner" import { PiProjectTrustBanner } from "@/components/chat/pi-project-trust-banner" +import { useContextMenuPointerGuard } from "@/hooks/use-context-menu-pointer-guard" import { FeedbackNotesDisplay } from "@/components/chat/feedback-notes-display" import { FeedbackDialog } from "@/components/chat/feedback-dialog" import { AgentDiagnosticsDialog } from "@/components/settings/agent-diagnostics-dialog" @@ -2378,6 +2379,7 @@ export function ConversationDetailPanel() { const [detailsOpen, setDetailsOpen] = useState(false) const exportLabels = useExportLabels() + const contextMenuPointerGuard = useContextMenuPointerGuard() // Release the old connection as soon as a preview tab is replaced (the next // single-click in the sidebar takes its slot) instead of waiting for a sweep. @@ -2865,6 +2867,7 @@ export function ConversationDetailPanel() {
{/* Flat sibling shells keyed by stable group id + divider overlays — stable across every split/tile flip, otherwise diff --git a/src/hooks/use-context-menu-pointer-guard.test.tsx b/src/hooks/use-context-menu-pointer-guard.test.tsx new file mode 100644 index 0000000000..a383c6a5ad --- /dev/null +++ b/src/hooks/use-context-menu-pointer-guard.test.tsx @@ -0,0 +1,124 @@ +import { fireEvent, render, renderHook, screen } from "@testing-library/react" +import { describe, expect, it, vi } from "vitest" + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/components/ui/context-menu" +import { useContextMenuPointerGuard } from "./use-context-menu-pointer-guard" + +/** + * jsdom's `fireEvent.pointerDown` drops `pointerType` (it builds a plain + * MouseEvent), so the property is pinned by hand — the guard reads exactly + * that field to distinguish a native text-selection long-press from a mouse + * right-click. + */ +function pointerDown(element: Element, pointerType: string) { + const event = new MouseEvent("pointerdown", { + bubbles: true, + cancelable: true, + }) + Object.defineProperty(event, "pointerType", { value: pointerType }) + fireEvent(element, event) +} + +function renderGuarded(onContextMenu: (event: Event) => void) { + const { result } = renderHook(() => useContextMenuPointerGuard()) + + return render( +
+
+ message text +
+
+ ) +} + +describe("useContextMenuPointerGuard", () => { + it("lets a touch long-press reach the native menu but not the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "touch") + const event = fireEvent.contextMenu(text) + + expect(onContextMenu).not.toHaveBeenCalled() + // Stopping Radix's propagation must not cancel the browser's native + // selection/image menu, which is the whole point on touch. + expect(event).toBe(true) + }) + + it("lets a pen long-press reach the native menu but not the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "pen") + fireEvent.contextMenu(text) + + expect(onContextMenu).not.toHaveBeenCalled() + }) + + it("keeps a mouse right-click on the app menu", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + const text = screen.getByTestId("text") + + pointerDown(text, "mouse") + fireEvent.contextMenu(text) + + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) + + it("keeps an app-menu shortcut usable when no pointer press preceded it", () => { + const onContextMenu = vi.fn() + renderGuarded(onContextMenu) + + fireEvent.contextMenu(screen.getByTestId("text")) + + expect(onContextMenu).toHaveBeenCalledTimes(1) + }) +}) + +describe("context menu integration", () => { + function renderRadixGuarded() { + const { result } = renderHook(() => useContextMenuPointerGuard()) + + return render( + + +
+ message text +
+
+ + App action + +
+ ) + } + + it("keeps a touch long-press from opening the Radix menu", () => { + renderRadixGuarded() + const trigger = screen.getByTestId("trigger") + + pointerDown(trigger, "touch") + const event = fireEvent.contextMenu(trigger) + + expect(screen.queryByRole("menu")).not.toBeInTheDocument() + expect(event).toBe(true) + }) + + it("opens the Radix menu for a mouse right-click", () => { + renderRadixGuarded() + const trigger = screen.getByTestId("trigger") + + pointerDown(trigger, "mouse") + fireEvent.contextMenu(trigger) + + expect(screen.getByRole("menu")).toBeInTheDocument() + }) +}) diff --git a/src/hooks/use-context-menu-pointer-guard.ts b/src/hooks/use-context-menu-pointer-guard.ts new file mode 100644 index 0000000000..c9c581d5dd --- /dev/null +++ b/src/hooks/use-context-menu-pointer-guard.ts @@ -0,0 +1,41 @@ +"use client" + +import { + useCallback, + useRef, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react" + +interface ContextMenuTriggerGuardProps { + /** Record the gesture before the browser translates a long-press into `contextmenu`. */ + onPointerDownCapture: (event: ReactPointerEvent) => void + /** Suppress the app menu for touch/pen while leaving the native menu intact. */ + onContextMenuCapture: (event: ReactMouseEvent) => void +} + +/** + * Text long-presses should belong to the platform's selection UI, not Radix's + * app menu. The pointer type is captured on the way in because `contextmenu` + * itself no longer says whether the gesture came from touch or mouse. + */ +export function useContextMenuPointerGuard() { + const pointerTypeRef = useRef(null) + + const trackPointerType = useCallback((event: ReactPointerEvent) => { + pointerTypeRef.current = event.pointerType + }, []) + + const suppressAppMenu = useCallback((event: ReactMouseEvent) => { + if (pointerTypeRef.current && pointerTypeRef.current !== "mouse") { + event.stopPropagation() + } + }, []) + + const triggerProps: ContextMenuTriggerGuardProps = { + onPointerDownCapture: trackPointerType, + onContextMenuCapture: suppressAppMenu, + } + + return { triggerProps } +} From 64f8a35103792a22d3204e856fb630311c15fded Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 6 Sep 2026 01:15:37 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(workspace):=20=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E5=8C=BA=E6=B5=8F=E8=A7=88=E5=99=A8=E5=90=8E=E9=80=80=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E4=B8=8E=E7=A6=BB=E5=BC=80=E5=AE=88=E5=8D=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 2 + src/app/workspace/layout.tsx | 12 +- src/components/ui/drawer.tsx | 15 ++ .../workspace/workspace-leave-guard.tsx | 48 ++++ src/contexts/workbench-route-context.tsx | 7 + src/contexts/workspace-context.tsx | 26 +- src/contexts/workspace-window-history.tsx | 239 ++++++++++++++++++ src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + 17 files changed, 348 insertions(+), 11 deletions(-) create mode 100644 src/components/workspace/workspace-leave-guard.tsx create mode 100644 src/contexts/workspace-window-history.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 852464b201..f5b6e70970 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -13,6 +13,7 @@ import { OverlayScrollbarsInit } from "@/components/overlay-scrollbars-init" import { ClipboardFallbackInit } from "@/components/clipboard-fallback-init" import { WebConnectionGuard } from "@/components/connection/web-connection-guard" import { WindowResizeGrips } from "@/components/layout/window-resize-grips" +import { WorkspaceLeaveGuard } from "@/components/workspace/workspace-leave-guard" export const viewport: Viewport = { width: "device-width", @@ -73,6 +74,7 @@ export default async function RootLayout({ + {children} diff --git a/src/app/workspace/layout.tsx b/src/app/workspace/layout.tsx index cef8021bb8..918566bcc1 100644 --- a/src/app/workspace/layout.tsx +++ b/src/app/workspace/layout.tsx @@ -35,6 +35,8 @@ import { WorkbenchRouteProvider, useWorkbenchRoute, } from "@/contexts/workbench-route-context" +import { WorkspaceWindowHistoryProvider } from "@/contexts/workspace-window-history" + import { WorkbenchRoutePage, WorkbenchRouteStrip, @@ -1267,8 +1269,9 @@ function WorkbenchRouteConversationSync() { function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { return ( - - + + + @@ -1314,8 +1317,9 @@ function WorkspaceLayoutInner({ children }: { children: React.ReactNode }) { - - + + + ) } diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx index 6e42cad6c1..ae951fc426 100644 --- a/src/components/ui/drawer.tsx +++ b/src/components/ui/drawer.tsx @@ -9,6 +9,7 @@ import { attachRef } from "@/lib/attach-ref" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { XIcon } from "lucide-react" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" type DrawerContextProps = { hasSnapPoints: boolean @@ -177,6 +178,20 @@ function Drawer({ showSwipeHandle?: boolean }) { const hasSnapPoints = snapPoints != null && snapPoints.length > 0 + useBrowserBackWindow({ + open: props.open ?? false, + onClose: () => + onOpenChange?.(false, { + reason: "none", + event: new Event("close"), + cancel: () => {}, + allowPropagation: () => {}, + isCanceled: false, + isPropagationAllowed: true, + trigger: undefined, + preventUnmountOnClose: () => {}, + }), + }) const contextValue = React.useMemo( () => ({ hasSnapPoints, modal, showSwipeHandle, swipeDirection }), [hasSnapPoints, modal, showSwipeHandle, swipeDirection] diff --git a/src/components/workspace/workspace-leave-guard.tsx b/src/components/workspace/workspace-leave-guard.tsx new file mode 100644 index 0000000000..a239ee1a3a --- /dev/null +++ b/src/components/workspace/workspace-leave-guard.tsx @@ -0,0 +1,48 @@ +"use client" + +import { useEffect } from "react" +import { usePathname, useRouter } from "next/navigation" +import { useTranslations } from "next-intl" +import { isDesktop } from "@/lib/platform" + +/** + * Confirms before a same-document back/forward traversal leaves /workspace, + * and before the page is closed or refreshed while the workspace is open. + * In-window navigation (drawers, in-memory routes) is handled by + * WorkspaceWindowHistoryProvider and never reaches this guard. + * Applies on every platform; only the beforeunload half skips the desktop + * client, where suppressing unload could block the app window from closing. + */ +export function WorkspaceLeaveGuard() { + const t = useTranslations("Folder.workspaceContext") + const pathname = usePathname() + const router = useRouter() + + useEffect(() => { + const onPopState = () => { + if ( + pathname !== "/workspace" || + window.location.pathname === "/workspace" + ) { + return + } + if (!window.confirm(t("confirmLeaveWorkspace"))) { + router.replace("/workspace", { scroll: false }) + } + } + window.addEventListener("popstate", onPopState) + return () => window.removeEventListener("popstate", onPopState) + }, [pathname, router, t]) + + useEffect(() => { + if (isDesktop() || pathname !== "/workspace") return + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault() + event.returnValue = "" + } + window.addEventListener("beforeunload", onBeforeUnload) + return () => window.removeEventListener("beforeunload", onBeforeUnload) + }, [pathname]) + + return null +} diff --git a/src/contexts/workbench-route-context.tsx b/src/contexts/workbench-route-context.tsx index 6f2c3366f0..be8a17c7e8 100644 --- a/src/contexts/workbench-route-context.tsx +++ b/src/contexts/workbench-route-context.tsx @@ -8,6 +8,7 @@ import { useState, type ReactNode, } from "react" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" /** * The view occupying the main content region. `"conversations"` is the default @@ -77,6 +78,12 @@ export function WorkbenchRouteProvider({ children }: { children: ReactNode }) { const setRoute = useCallback((id: WorkbenchRouteId) => setRouteId(id), []) const openConversations = useCallback(() => setRouteId("conversations"), []) + useBrowserBackWindow({ + open: routeId !== "conversations", + onClose: openConversations, + key: "workbench-route", + }) + const value = useMemo( () => ({ routeId, diff --git a/src/contexts/workspace-context.tsx b/src/contexts/workspace-context.tsx index d312fdb8a5..e4b28a70b4 100644 --- a/src/contexts/workspace-context.tsx +++ b/src/contexts/workspace-context.tsx @@ -12,6 +12,7 @@ import { } from "react" import { useTranslations } from "next-intl" import { useActiveFolder } from "@/contexts/active-folder-context" +import { useBrowserBackWindow } from "@/contexts/workspace-window-history" import { useAppWorkspaceStore } from "@/stores/app-workspace-store" import { buildFileTabId } from "@/lib/file-tab-id" import { @@ -111,7 +112,9 @@ interface WorkspaceActionsValue { switchFileTab: (tabId: string) => void closeFileTab: (tabId: string) => void closeOtherFileTabs: (tabId: string) => void - closeAllFileTabs: () => void + /** Returns false when the user vetoes the dirty-tabs confirm, so the + * browser-back integration can restore the history entry it consumed. */ + closeAllFileTabs: () => boolean reorderFileTabs: (tabs: FileWorkspaceTab[]) => void // Open a file tab. Accepts absolute paths, `~/` paths (expanded via the // backend home dir), and paths relative to a folder root. `folderId` is @@ -2243,13 +2246,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { [activateFilePane, t] ) - const closeAllFileTabs = useCallback(() => { - setFileTabs((prev) => { - if (prev.some(isDirtyFileTab)) { - const confirmed = window.confirm(t("confirmCloseAllDirtyTabs")) - if (!confirmed) return prev - } + const closeAllFileTabs = useCallback((): boolean => { + // Confirm outside the state updater: the browser-back handler needs the + // veto synchronously to decide whether to restore the history entry. + if (fileTabsRef.current.some(isDirtyFileTab)) { + const confirmed = window.confirm(t("confirmCloseAllDirtyTabs")) + if (!confirmed) return false + } + setFileTabs((prev) => { for (const tab of prev) { const closed = snapshotFileTab(tab) if (closed) pushClosedTab(closed) @@ -2261,8 +2266,15 @@ export function WorkspaceProvider({ children }: WorkspaceProviderProps) { activateConversationPane() return [] }) + return true }, [activateConversationPane, t]) + useBrowserBackWindow({ + open: fileTabs.length > 0, + onClose: closeAllFileTabs, + key: "file-workspace", + }) + const reorderFileTabs = useCallback((tabs: FileWorkspaceTab[]) => { setFileTabs(tabs) }, []) diff --git a/src/contexts/workspace-window-history.tsx b/src/contexts/workspace-window-history.tsx new file mode 100644 index 0000000000..b0607f6df6 --- /dev/null +++ b/src/contexts/workspace-window-history.tsx @@ -0,0 +1,239 @@ +"use client" + +import { + createContext, + useCallback, + useContext, + useEffect, + useId, + useMemo, + useRef, + type ReactNode, +} from "react" + +type CloseSource = "manual" | "abandon" + +type Entry = { + key: string + close: () => boolean | void + /** Set when the browser back button (not the UI) dismissed this entry. */ + popped?: boolean +} + +type HistoryContextValue = { + open: (entry: Entry) => Entry + close: (entry: Entry, source: CloseSource) => void + attach: (entry: Entry) => Entry | null +} + +const HistoryContext = createContext(null) +const STATE_KEY = "codegWorkspaceWindow" + +function pushWindowState(key: string) { + if (typeof window === "undefined") return + // Spread the current state so entries pushed through Next.js' patched + // pushState keep `__NA` and the internal router tree — without them the + // app router reloads the page when it sees our entries on popstate. + window.history.pushState( + { ...(window.history.state ?? {}), [STATE_KEY]: key }, + "", + window.location.href + ) +} + +function stateKeyOf(state: unknown): string | null { + if (!state || typeof state !== "object") return null + const key = (state as Record)[STATE_KEY] + return typeof key === "string" ? key : null +} + +/** + * A LIFO stack of "windows" (drawers, in-memory routes, the file workspace) + * mirrored into the browser history so the back button (mobile gesture, + * hardware key, or desktop browser chrome) dismisses the topmost window + * instead of leaving the workspace. Applies on every platform — web and + * desktop client, any viewport. + * + * Every open window pushes one same-URL history entry tagged with its key. + * The popstate handler decides what to do from the state it LANDS on: + * + * - a key that is still registered → the user backed over the windows above + * it, so close everything above that key; + * - a key that is no longer registered → a phantom entry whose owner was + * unmounted without a matching back(); skip it silently with one more + * back() so a dead entry never swallows a back press; + * - no key (the workspace base entry or another page) → close the topmost + * window. A close handler may veto by returning `false` (e.g. unsaved + * files), in which case the entry is re-pushed to undo the traversal. + * + * Manual closes call history.back() themselves; the traversals those cause + * are swallowed via a counter rather than a boolean so rapid consecutive + * closes cannot leak a synthetic popstate into the user path. + */ +export function WorkspaceWindowHistoryProvider({ + children, +}: { + children: ReactNode +}) { + const entriesRef = useRef([]) + const byKeyRef = useRef(new Map()) + const ignoreNextPopCountRef = useRef(0) + + const open = useCallback((entry: Entry) => { + const existing = byKeyRef.current.get(entry.key) + if (existing) { + existing.close = entry.close + return existing + } + entriesRef.current.push(entry) + byKeyRef.current.set(entry.key, entry) + pushWindowState(entry.key) + return entry + }, []) + + const close = useCallback((entry: Entry, source: CloseSource) => { + if (!byKeyRef.current.has(entry.key)) return + byKeyRef.current.delete(entry.key) + entriesRef.current = entriesRef.current.filter( + (candidate) => candidate.key !== entry.key + ) + if (source === "manual") { + // Defer the traversal and re-check before backing out: a window + // opening in the same commit (e.g. tapping a file in the aux drawer + // closes the drawer and opens the file workspace together) pushes + // its own entry in a later effect of the same flush. history.back() + // is asynchronous — issued now, the traversal would run AFTER that + // push and land below the new entry, leaving the browser pointer + // misaligned with the live stack (the next real back press would + // then leave the page instead of closing that window). When this + // entry is no longer the current one, skip back(); its history + // entry becomes a phantom the popstate handler skips. + const key = entry.key + setTimeout(() => { + if (stateKeyOf(window.history.state) !== key) return + ignoreNextPopCountRef.current += 1 + window.history.back() + }, 0) + } + // "abandon" (owner unmounted) only cleans the memory stack. The browser + // entry becomes a phantom and is skipped by the popstate handler. + }, []) + + const attach = useCallback((entry: Entry) => { + const existing = byKeyRef.current.get(entry.key) + if (!existing) return null + existing.close = entry.close + return existing + }, []) + + useEffect(() => { + const onPopState = (event: PopStateEvent) => { + if (ignoreNextPopCountRef.current > 0) { + ignoreNextPopCountRef.current -= 1 + return + } + const landedKey = stateKeyOf(event.state) + if (landedKey !== null) { + if (!byKeyRef.current.has(landedKey)) { + // Phantom: the window owning this entry is gone. Skip the dead + // entry so back still does something visible. + window.history.back() + return + } + // Close every window above the one we landed on. + while (entriesRef.current.length > 0) { + const top = entriesRef.current[entriesRef.current.length - 1] + if (top.key === landedKey) break + const closed = top.close() + if (closed === false) { + pushWindowState(top.key) + break + } + top.popped = true + entriesRef.current.pop() + byKeyRef.current.delete(top.key) + } + return + } + // Landed on the workspace base entry or a page outside the workspace. + const entry = entriesRef.current[entriesRef.current.length - 1] + if (!entry) return + const closed = entry.close() + if (closed === false) { + pushWindowState(entry.key) + return + } + entry.popped = true + entriesRef.current.pop() + byKeyRef.current.delete(entry.key) + } + window.addEventListener("popstate", onPopState) + return () => window.removeEventListener("popstate", onPopState) + }, []) + + const value = useMemo( + () => ({ open, close, attach }), + [attach, close, open] + ) + return ( + + {children} + + ) +} + +export function useBrowserBackWindow({ + open, + onClose, + key, +}: { + open: boolean + onClose: () => boolean | void + key?: string +}) { + const context = useContext(HistoryContext) + const autoKey = useId() + const entryKey = key ?? autoKey + const closeRef = useRef(onClose) + const entryRef = useRef(null) + + useEffect(() => { + closeRef.current = onClose + }, [onClose]) + + // Register while open. Runs on every onClose change too, but `attach` + // finds the existing entry then and only refreshes its close callback — + // it never pushes a second history entry. Re-registering here also + // recovers the entry after a StrictMode simulated remount, which the + // abandon cleanup below removes. + useEffect(() => { + if (!context || !open) return + // The browser already dismissed this window; the parent just hasn't + // applied the closed state yet. Re-registering here would resurrect + // its history entry. + if (entryRef.current?.popped) return + const candidate: Entry = { + key: entryKey, + close: () => closeRef.current(), + } + entryRef.current = context.attach(candidate) ?? context.open(candidate) + }, [context, entryKey, onClose, open]) + + // Closed through the UI: consume the matching history entry. + useEffect(() => { + if (!context || open || !entryRef.current) return + const entry = entryRef.current + entryRef.current = null + context.close(entry, "manual") + }, [context, open]) + + // Unmounted while open: drop the memory entry. Its history entry stays + // behind as a phantom and is skipped on the next back press. + useEffect(() => { + return () => { + if (entryRef.current && context) { + context.close(entryRef.current, "abandon") + } + } + }, [context]) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 045a42731a..8091926053 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "إغلاق \"{title}\" بدون حفظ؟", "confirmCloseOtherDirtyTabs": "إغلاق التبويبات الأخرى التي تحتوي تغييرات غير محفوظة؟", "confirmCloseAllDirtyTabs": "إغلاق جميع التبويبات التي تحتوي تغييرات غير محفوظة؟", + "confirmLeaveWorkspace": "مغادرة مساحة العمل؟", "unableLoadContent": "تعذر تحميل المحتوى.\n\n{message}", "previewRequestTimedOut": "انتهت مهلة طلب المعاينة", "diffRequestTimedOut": "انتهت مهلة طلب Diff", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 8db46636ee..6a2d72802a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "„{title}“ ohne Speichern schließen?", "confirmCloseOtherDirtyTabs": "Andere Tabs mit ungespeicherten Änderungen schließen?", "confirmCloseAllDirtyTabs": "Alle Tabs mit ungespeicherten Änderungen schließen?", + "confirmLeaveWorkspace": "Arbeitsbereich verlassen?", "unableLoadContent": "Inhalt konnte nicht geladen werden.\n\n{message}", "previewRequestTimedOut": "Vorschauanfrage hat das Zeitlimit überschritten", "diffRequestTimedOut": "Diff-Anfrage hat das Zeitlimit überschritten", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 02a85a0963..7a12cae761 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "Close \"{title}\" without saving?", "confirmCloseOtherDirtyTabs": "Close other tabs with unsaved changes?", "confirmCloseAllDirtyTabs": "Close all tabs with unsaved changes?", + "confirmLeaveWorkspace": "Leave the workspace?", "unableLoadContent": "Unable to load content.\n\n{message}", "previewRequestTimedOut": "Preview request timed out", "diffRequestTimedOut": "Diff request timed out", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 5c03465895..f944f5c6ab 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "¿Cerrar \"{title}\" sin guardar?", "confirmCloseOtherDirtyTabs": "¿Cerrar otras pestañas con cambios sin guardar?", "confirmCloseAllDirtyTabs": "¿Cerrar todas las pestañas con cambios sin guardar?", + "confirmLeaveWorkspace": "¿Salir del espacio de trabajo?", "unableLoadContent": "No se puede cargar el contenido.\n\n{message}", "previewRequestTimedOut": "La solicitud de vista previa agotó el tiempo", "diffRequestTimedOut": "La solicitud de Diff agotó el tiempo", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index d22e037fe2..b01294c332 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "Fermer « {title} » sans enregistrer ?", "confirmCloseOtherDirtyTabs": "Fermer les autres onglets avec des modifications non enregistrées ?", "confirmCloseAllDirtyTabs": "Fermer tous les onglets avec des modifications non enregistrées ?", + "confirmLeaveWorkspace": "Quitter l'espace de travail ?", "unableLoadContent": "Impossible de charger le contenu.\n\n{message}", "previewRequestTimedOut": "La requête de prévisualisation a expiré", "diffRequestTimedOut": "La requête Diff a expiré", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 9c8a70d77d..4b4c6d508f 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "保存せずに「{title}」を閉じますか?", "confirmCloseOtherDirtyTabs": "未保存の変更がある他のタブを閉じますか?", "confirmCloseAllDirtyTabs": "未保存の変更があるすべてのタブを閉じますか?", + "confirmLeaveWorkspace": "ワークスペースから移動しますか?", "unableLoadContent": "内容を読み込めません。\n\n{message}", "previewRequestTimedOut": "プレビュー要求がタイムアウトしました", "diffRequestTimedOut": "Diff 要求がタイムアウトしました", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 76940d8f2a..54c5d9a0fb 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "저장하지 않고 \"{title}\" 탭을 닫을까요?", "confirmCloseOtherDirtyTabs": "저장되지 않은 변경 사항이 있는 다른 탭을 닫을까요?", "confirmCloseAllDirtyTabs": "저장되지 않은 변경 사항이 있는 모든 탭을 닫을까요?", + "confirmLeaveWorkspace": "워크스페이스를 떠나시겠습니까?", "unableLoadContent": "콘텐츠를 불러올 수 없습니다.\n\n{message}", "previewRequestTimedOut": "미리보기 요청 시간이 초과되었습니다", "diffRequestTimedOut": "Diff 요청 시간이 초과되었습니다", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a0dce36931..048c64fce2 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "Fechar \"{title}\" sem salvar?", "confirmCloseOtherDirtyTabs": "Fechar outras abas com alterações não salvas?", "confirmCloseAllDirtyTabs": "Fechar todas as abas com alterações não salvas?", + "confirmLeaveWorkspace": "Sair do espaço de trabalho?", "unableLoadContent": "Não foi possível carregar o conteúdo.\n\n{message}", "previewRequestTimedOut": "A solicitação de preview expirou", "diffRequestTimedOut": "A solicitação de Diff expirou", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 0f01609bf7..fe577ba5d0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "文件“{title}”有未保存更改,确定关闭吗?", "confirmCloseOtherDirtyTabs": "其它标签页有未保存更改,确定关闭吗?", "confirmCloseAllDirtyTabs": "存在未保存更改,确定关闭全部标签页吗?", + "confirmLeaveWorkspace": "要离开工作区吗?", "unableLoadContent": "无法加载内容。\n\n{message}", "previewRequestTimedOut": "预览请求超时", "diffRequestTimedOut": "Diff 请求超时", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 5791024bc5..fa78a91956 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2772,6 +2772,7 @@ "confirmCloseDirtyTab": "檔案「{title}」有未儲存變更,確定關閉嗎?", "confirmCloseOtherDirtyTabs": "其他分頁有未儲存變更,確定關閉嗎?", "confirmCloseAllDirtyTabs": "存在未儲存變更,確定關閉全部分頁嗎?", + "confirmLeaveWorkspace": "要離開工作區嗎?", "unableLoadContent": "無法載入內容。\n\n{message}", "previewRequestTimedOut": "預覽請求逾時", "diffRequestTimedOut": "Diff 請求逾時", From 32b45a1edd204f8650bca01f88afb6dc6d53886b Mon Sep 17 00:00:00 2001 From: mrytsr Date: Sun, 6 Sep 2026 19:00:58 +0800 Subject: [PATCH 03/19] feat: add model provider settings and source selection UI --- src/app/globals.css | 6 + src/components/chat/message-input.tsx | 57 +- .../chat/model-provider-picker-demo.test.tsx | 62 + .../chat/model-provider-picker-demo.tsx | 189 + .../settings/acp-agent-settings.tsx | 4688 +++++++++++++++-- .../settings/add-model-provider-dialog.tsx | 391 -- .../settings/builtin-provider-picker.tsx | 130 + .../settings/edit-model-provider-dialog.tsx | 410 -- .../settings/model-provider-editor.test.tsx | 132 + .../settings/model-provider-editor.tsx | 455 ++ .../settings/model-provider-model-row.tsx | 211 + .../settings/model-provider-settings.test.tsx | 107 + .../settings/model-provider-settings.tsx | 379 +- src/i18n/messages/ar.json | 77 +- src/i18n/messages/de.json | 77 +- src/i18n/messages/en.json | 77 +- src/i18n/messages/es.json | 77 +- src/i18n/messages/fr.json | 77 +- src/i18n/messages/ja.json | 77 +- src/i18n/messages/ko.json | 77 +- src/i18n/messages/pt.json | 77 +- src/i18n/messages/zh-CN.json | 77 +- src/i18n/messages/zh-TW.json | 77 +- src/lib/model-provider-capabilities.test.ts | 45 + src/lib/model-provider-capabilities.ts | 80 + src/lib/model-provider-service.ts | 38 + src/lib/model-provider-types.ts | 133 + src/stores/model-provider-mock.test.ts | 137 + src/stores/model-provider-mock.ts | 337 ++ tasks/plan.md | 427 ++ tasks/todo.md | 39 + 31 files changed, 7756 insertions(+), 1467 deletions(-) create mode 100644 src/components/chat/model-provider-picker-demo.test.tsx create mode 100644 src/components/chat/model-provider-picker-demo.tsx delete mode 100644 src/components/settings/add-model-provider-dialog.tsx create mode 100644 src/components/settings/builtin-provider-picker.tsx delete mode 100644 src/components/settings/edit-model-provider-dialog.tsx create mode 100644 src/components/settings/model-provider-editor.test.tsx create mode 100644 src/components/settings/model-provider-editor.tsx create mode 100644 src/components/settings/model-provider-model-row.tsx create mode 100644 src/components/settings/model-provider-settings.test.tsx create mode 100644 src/lib/model-provider-capabilities.test.ts create mode 100644 src/lib/model-provider-capabilities.ts create mode 100644 src/lib/model-provider-service.ts create mode 100644 src/lib/model-provider-types.ts create mode 100644 src/stores/model-provider-mock.test.ts create mode 100644 src/stores/model-provider-mock.ts create mode 100644 tasks/plan.md create mode 100644 tasks/todo.md diff --git a/src/app/globals.css b/src/app/globals.css index 15fed69e81..6f60e15add 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1043,6 +1043,12 @@ font-size: 0.625rem; /* 10px @100% */ } +/* 保持 input type 为 text,用 WebKit 的 text-security 做密钥视觉遮蔽 + (pios ModelConfigModal 同款;眼睛按钮只切换这个遮蔽类)。 */ +@utility masked-key { + -webkit-text-security: disc; +} + @layer base { * { @apply border-border outline-ring/50; diff --git a/src/components/chat/message-input.tsx b/src/components/chat/message-input.tsx index 0e228f1e92..94d1cf3efe 100644 --- a/src/components/chat/message-input.tsx +++ b/src/components/chat/message-input.tsx @@ -1,6 +1,14 @@ "use client" -import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + Fragment, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactElement, +} from "react" import { useTranslations } from "next-intl" import { isImeCompositionKey } from "@/lib/ime-composition" import { Button } from "@/components/ui/button" @@ -81,6 +89,7 @@ import { InlineSessionConfigToggle, } from "@/components/chat/session-config-selector" import { ModelOptionPicker } from "@/components/chat/model-option-picker" +import { ModelProviderPickerDemo } from "@/components/chat/model-provider-picker-demo" import { SelectorTooltip } from "@/components/chat/selector-tooltip" import { SessionSelectorsPanel, @@ -1444,10 +1453,10 @@ export function MessageInput({ availableConfigOptions.map((option) => { // On/off options flip in place — a dropdown for a binary choice is a // wasted interaction. + let selector: ReactElement if (option.kind.type === "boolean") { - return ( + selector = ( ) - } - // Long model lists get the searchable + virtualized popover (a Radix - // menu of hundreds of items is the scroll jank); every other option — - // and short model lists — keep the lightweight inline dropdown. - const listGroups = modelPickerGroups(option) - if (listGroups) { - return ( + } else { + // Long model lists get the searchable + virtualized popover (a Radix + // menu of hundreds of items is the scroll jank); every other option — + // and short model lists — keep the lightweight inline dropdown. + const listGroups = modelPickerGroups(option) + selector = listGroups ? ( onConfigOptionChange?.(configId, valueId) } /> + ) : ( + + onConfigOptionChange?.(configId, valueId) + } + /> ) } return ( - - onConfigOptionChange?.(configId, valueId) - } - /> + + {selector} + {/* DEMO: fake two-level provider → model picker next to the live one. */} + {isModelConfigOption(option) && } + ) })} {showModeSelector && ( @@ -1935,6 +1947,13 @@ export function MessageInput({ hasInlineSelectors && "@[30rem]:hidden" )} > + {/* DEMO: same fake picker, reachable on narrow/mobile + composers where the inline selectors are collapsed. */} + + + + ) +} + +describe("ModelProviderPickerDemo", () => { + it("defaults to the first enabled provider's first model", async () => { + renderDemo() + const trigger = await screen.findByRole("button", { + name: "Demo provider picker", + }) + expect(within(trigger).getByText("DEMO")).toBeInTheDocument() + await waitFor(() => + expect( + within(trigger).getByText("deepseek · deepseek-chat") + ).toBeInTheDocument() + ) + }) + + it("shows every provider's models expanded and applies a selection", async () => { + renderDemo() + const trigger = await screen.findByRole("button", { + name: "Demo provider picker", + }) + fireEvent.click(trigger) + + // Fully expanded: models of every provider are visible without expanding. + const openrouterModel = await screen.findByText( + "openrouter/anthropic/claude-sonnet-4-5" + ) + expect(screen.getByText("deepseek-chat")).toBeInTheDocument() + expect(screen.queryByText("qwen2.5-72b-instruct")).not.toBeInTheDocument() + + // Group headers are not interactive — no expand/collapse affordance. + expect(screen.queryByText("OpenRouter gateway")).not.toBeInTheDocument() + + fireEvent.click(openrouterModel) + await waitFor(() => + expect( + within(trigger).getByText( + "openrouter · openrouter/anthropic/claude-sonnet-4-5" + ) + ).toBeInTheDocument() + ) + }) +}) diff --git a/src/components/chat/model-provider-picker-demo.tsx b/src/components/chat/model-provider-picker-demo.tsx new file mode 100644 index 0000000000..a53857c90f --- /dev/null +++ b/src/components/chat/model-provider-picker-demo.tsx @@ -0,0 +1,189 @@ +"use client" + +import { useEffect, useState } from "react" +import { Check, ChevronDown, Loader2, Server } from "lucide-react" +import { useTranslations } from "next-intl" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { ScrollArea } from "@/components/ui/scroll-area" +import { useModelProviderService } from "@/stores/model-provider-mock" +import type { ModelProviderRecord } from "@/lib/model-provider-types" +import { cn } from "@/lib/utils" + +interface Selection { + providerId: string + modelId: string +} + +interface ModelProviderPickerDemoProps { + /** Extra classes for the trigger button (compact on mobile, etc.). */ + className?: string + side?: "top" | "bottom" + align?: "start" | "center" | "end" +} + +/** + * DEMO — the "new design" two-level model selector (provider → model) as a + * single fully expanded list: every provider renders as a static group header + * with all of its models visible beneath it (no expand/collapse). Fed by the + * mock provider store, so it shares the same data as the Model Providers + * settings page. Mounted next to the real model selector on wide composers and + * next to the cog on narrow/mobile ones so the interaction can be reviewed + * before the backend wiring lands. + */ +export function ModelProviderPickerDemo({ + className, + side = "bottom", + align = "start", +}: ModelProviderPickerDemoProps) { + const t = useTranslations("ModelProviderPickerDemo") + const service = useModelProviderService() + const [records, setRecords] = useState(null) + const [selection, setSelection] = useState(null) + const [open, setOpen] = useState(false) + + useEffect(() => { + let alive = true + service.list().then((rows) => { + if (!alive) return + setRecords(rows) + const first = rows.find((r) => r.enabled && r.models.length > 0) + if (first) { + setSelection( + (prev) => + prev ?? { + providerId: first.providerId, + modelId: first.models[0].id, + } + ) + } + }) + return () => { + alive = false + } + }, [service]) + + const visibleRecords = records?.filter((r) => r.enabled) ?? [] + const label = selection + ? `${selection.providerId} · ${selection.modelId}` + : t("placeholder") + + return ( + + + + + + {!records ? ( +
+ +
+ ) : ( + +
+ {visibleRecords.map((r) => ( +
+
+ + + {r.providerId} + + + {r.models.length} · {r.enabled ? r.api : t("disabled")} + + +
+
+ {r.models.map((m) => { + const active = + selection?.providerId === r.providerId && + selection?.modelId === m.id + return ( + + ) + })} + {r.models.length === 0 && ( +
+ {t("noModels")} +
+ )} +
+
+ ))} + {visibleRecords.length === 0 && ( +
+ + {t("noProviders")} +
+ )} +
+
+ )} +
+
+ ) +} diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 1586960d2c..026452637a 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -107,7 +107,6 @@ import { acpOpenHermesSetupTerminal, codexPollDeviceCode, codexRequestDeviceCode, - listModelProviders, opencodeProviderCatalog, } from "@/lib/api" import type { @@ -132,6 +131,16 @@ import { type CodexModelConfig, } from "@/lib/types" import { CodexModelListEditor } from "@/components/settings/codex-model-list-editor" +import { + countCompatibleModelProviders, + getModelProviderApiTypes, + MODEL_PROVIDER_SOURCE_CARD_AGENT_TYPES, +} from "@/lib/model-provider-capabilities" +import type { + ModelProviderApiType, + ModelProviderRecord, +} from "@/lib/model-provider-types" +import { useModelProviderService } from "@/stores/model-provider-mock" import { OpenCodeConnectDialog, OpenCodeCustomProviderDialog, @@ -3339,8 +3348,7 @@ export function patchImportantConfigText( * Make a Claude agent's native config provider-authoritative. When a provider * was bound in an earlier session, the on-disk config loaded into the draft can * still carry stale model keys (e.g. a leftover ANTHROPIC_CUSTOM_MODEL_OPTION) - * that no longer match the provider — `handleModelProviderSelect` only rewrites - * configText when the dropdown changes, not on reload. A config-management save + * that no longer match a legacy provider binding. A config-management save * would otherwise persist that stale text back over the backend bind cascade, so * re-derive the provider-controlled keys here (empty => cleared by `assignEnv`) * before saving. Unrelated config/env keys are preserved. @@ -4253,6 +4261,68 @@ function AgentReorderItem({ ) } +function ModelProviderApiTypeBadges({ + apiTypes, +}: { + apiTypes: readonly ModelProviderApiType[] +}) { + if (apiTypes.length === 0) return null + + return ( +
+ {apiTypes.map((apiType) => ( + + {apiType} + + ))} +
+ ) +} + +function ModelProviderSourceCard({ + apiTypes, + count, + selected, + onSelect, +}: { + apiTypes: readonly ModelProviderApiType[] + count: number + selected: boolean + onSelect: (selected: boolean) => void +}) { + const t = useTranslations("AcpAgentSettings") + + return ( +
+
+ +

+ {t("modelProviderSourceHint")} +

+
+ + +
+ ) +} + export function AcpAgentSettings() { const ime = useImeGuard() const locale = useLocale() @@ -4289,7 +4359,13 @@ export function AcpAgentSettings() { const [savingConfig, setSavingConfig] = useState< Partial> >({}) - const [modelProviders, setModelProviders] = useState([]) + const modelProviderService = useModelProviderService() + const [modelProviderRecords, setModelProviderRecords] = useState< + ModelProviderRecord[] + >([]) + const [modelProviderSources, setModelProviderSources] = useState< + Partial> + >({}) const [uninstallConfirmAgent, setUninstallConfirmAgent] = useState(null) const [removeConfirmAgent, setRemoveConfirmAgent] = @@ -4407,11 +4483,11 @@ export function AcpAgentSettings() { try { const [next, providers] = await Promise.all([ acpListAgents(), - listModelProviders().catch(() => [] as ModelProviderInfo[]), + modelProviderService.list().catch(() => [] as ModelProviderRecord[]), ]) setAgents(next) publishAgentDisplay(next) - setModelProviders(providers) + setModelProviderRecords(providers) setDrafts((prev) => { const updated = { ...prev } for (const agent of next) { @@ -4449,7 +4525,7 @@ export function AcpAgentSettings() { } finally { setLoadingAgents(false) } - }, []) + }, [modelProviderService]) const runPreflight = useCallback( async (agentType: AgentType, forceRefresh?: boolean) => { @@ -5461,29 +5537,27 @@ export function AcpAgentSettings() { const selectedIsSavingConfig = selectedAgent ? Boolean(savingConfig[selectedAgent.agent_type]) : false - const selectedAgentKind = selectedAgent?.agent_type ?? null - const selectedModelProviders = useMemo(() => { - if (!selectedAgent) return [] - return modelProviders.filter( - (p) => p.agent_type === selectedAgent.agent_type - ) - }, [modelProviders, selectedAgent]) - - const selectedNeedsModelProvider = useMemo(() => { - if (!selectedDraft) return false - if (!selectedAgent) return false - const at = selectedAgent.agent_type - if (at === "claude_code") - return selectedDraft.claudeAuthMode === "model_provider" - if (at === "codex") return selectedDraft.codexAuthMode === "model_provider" - if (at === "gemini") - return selectedDraft.geminiAuthMode === "model_provider" - return false - }, [selectedAgent, selectedDraft]) + const selectedAgentKind = selectedAgent?.agent_type ?? null + const selectedModelProviderApiTypes = useMemo( + () => + selectedAgentKind ? getModelProviderApiTypes(selectedAgentKind) : [], + [selectedAgentKind] + ) + const selectedCompatibleModelProviderCount = useMemo( + () => + selectedAgentKind + ? countCompatibleModelProviders(modelProviderRecords, selectedAgentKind) + : 0, + [modelProviderRecords, selectedAgentKind] + ) + const selectedAgentSupportsSourceCard = selectedAgentKind + ? MODEL_PROVIDER_SOURCE_CARD_AGENT_TYPES.includes(selectedAgentKind) + : false + const selectedUsesModelProviderSource = + selectedAgentSupportsSourceCard && + Boolean(selectedAgentKind && modelProviderSources[selectedAgentKind]) - const selectedMissingModelProvider = - selectedNeedsModelProvider && selectedDraft?.modelProviderId == null const selectedConfigText = selectedDraft?.configText ?? "" const selectedOpenCodeAuthJsonText = selectedDraft?.openCodeAuthJsonText ?? "" // Inline validation for `writable_roots`: codex would accept a relative entry @@ -5897,236 +5971,6 @@ export function AcpAgentSettings() { [selectedAgent, selectedDraft, updateSelectedDraft] ) - const handleModelProviderSelect = useCallback( - (providerIdStr: string) => { - if (!selectedAgent || !selectedDraft) return - const providerId = providerIdStr ? Number(providerIdStr) : null - const provider = providerId - ? modelProviders.find((p) => p.id === providerId) - : null - const apiUrl = provider?.api_url ?? "" - const apiKey = provider?.api_key ?? "" - const agentType = selectedAgent.agent_type - - if (agentType === "claude_code") { - // Provider's model fields are authoritative: missing/empty keys clear - // the corresponding draft + env value. - const claudeModel = parseClaudeProviderModel(provider?.model ?? null) - const claudeMain = claudeModel.main ?? "" - const claudeReasoning = claudeModel.reasoning ?? "" - const claudeHaiku = claudeModel.haiku ?? "" - const claudeSonnet = claudeModel.sonnet ?? "" - const claudeOpus = claudeModel.opus ?? "" - const claudeCustomOption = claudeModel.customOption ?? "" - const claudeCustomOptionName = claudeModel.customOptionName ?? "" - const claudeCustomOptionDescription = - claudeModel.customOptionDescription ?? "" - const nextConfigJson = patchImportantConfigText( - agentType, - selectedDraft.configText, - { - apiBaseUrl: apiUrl, - apiKey, - model: selectedDraft.model, - claudeMainModel: claudeMain, - claudeReasoningModel: claudeReasoning, - claudeDefaultHaikuModel: claudeHaiku, - claudeDefaultSonnetModel: claudeSonnet, - claudeDefaultOpusModel: claudeOpus, - // The custom model option travels with the provider's model JSON, - // authoritative like the five model fields: a defined value sets it, - // an empty/omitted value clears the key from config.env. - claudeCustomModelOption: claudeCustomOption, - claudeCustomModelOptionName: claudeCustomOptionName, - claudeCustomModelOptionDescription: claudeCustomOptionDescription, - } - ) - setConfigErrors((prev) => ({ - ...prev, - [agentType]: null, - })) - updateSelectedDraft((current) => { - let nextEnvText = patchEnvByImportantKey( - agentType, - current.envText, - "apiBaseUrl", - apiUrl - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "apiKey", - apiKey - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeMainModel", - claudeMain - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeReasoningModel", - claudeReasoning - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeDefaultHaikuModel", - claudeHaiku - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeDefaultSonnetModel", - claudeSonnet - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeDefaultOpusModel", - claudeOpus - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeCustomModelOption", - claudeCustomOption - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeCustomModelOptionName", - claudeCustomOptionName - ) - nextEnvText = patchEnvByImportantKey( - agentType, - nextEnvText, - "claudeCustomModelOptionDescription", - claudeCustomOptionDescription - ) - return { - ...current, - modelProviderId: providerId, - apiBaseUrl: apiUrl, - apiKey, - claudeMainModel: claudeMain, - claudeReasoningModel: claudeReasoning, - claudeDefaultHaikuModel: claudeHaiku, - claudeDefaultSonnetModel: claudeSonnet, - claudeDefaultOpusModel: claudeOpus, - claudeCustomModelOption: claudeCustomOption, - claudeCustomModelOptionName: claudeCustomOptionName, - claudeCustomModelOptionDescription: claudeCustomOptionDescription, - envText: nextEnvText, - configText: nextConfigJson.configText, - } - }) - } else if (agentType === "codex") { - // The provider stores a structured model config; root `model` is its - // default slug and we reference the catalog the bind path generates. - const codexList = parseCodexModelConfig(provider?.model ?? null) - const codexHasConfig = - codexList.customs.length > 0 || - (codexList.excludedOfficials?.length ?? 0) > 0 - const codexModel = codexList.default ?? codexList.customs[0]?.slug ?? "" - const nextAuthPatch = patchCodexAuthJsonText( - selectedDraft.codexAuthJsonText, - { apiKey, authMode: null } - ) - const nextAuthJsonText = nextAuthPatch.authJsonText - // Always pass the provider's model (empty string clears it from the toml). - let nextConfigTomlText = patchCodexConfigTomlText( - selectedDraft.codexConfigTomlText, - { - modelProvider: CODEX_DEFAULT_MODEL_PROVIDER, - apiBaseUrl: apiUrl, - model: codexModel, - } - ) - nextConfigTomlText = updateTomlRootStringKey( - nextConfigTomlText, - "model_catalog_json", - codexHasConfig ? "codeg-model-catalog.json" : "" - ) - const synced = extractCodexImportantValues( - nextAuthJsonText, - nextConfigTomlText - ) - updateSelectedDraft((current) => ({ - ...current, - modelProviderId: providerId, - apiBaseUrl: apiUrl, - apiKey, - model: codexModel, - codexModelList: codexList, - codexAuthJsonText: nextAuthJsonText, - codexConfigTomlText: nextConfigTomlText, - codexModelProvider: CODEX_DEFAULT_MODEL_PROVIDER, - codexProviderOptions: synced.providerOptions, - envText: patchEnvText(current.envText, { - OPENAI_API_KEY: apiKey, - OPENAI_BASE_URL: apiUrl, - OPENAI_MODEL: codexModel, - }), - })) - } else if (agentType === "gemini") { - const geminiModel = provider?.model?.trim() ?? "" - const nextConfigJson = patchGeminiConfigText(selectedDraft.configText, { - apiBaseUrl: apiUrl, - geminiApiKey: apiKey, - }) - setConfigErrors((prev) => ({ - ...prev, - [agentType]: null, - })) - updateSelectedDraft((current) => { - let nextEnvText = patchGeminiEnvText(current.envText, { - apiBaseUrl: apiUrl, - geminiApiKey: apiKey, - }) - // Always overwrite GEMINI_MODEL with the provider's value (empty - // string clears it). - nextEnvText = patchEnvText(nextEnvText, { - GEMINI_MODEL: geminiModel, - }) - return { - ...current, - modelProviderId: providerId, - apiBaseUrl: apiUrl, - apiKey, - geminiApiKey: apiKey, - model: geminiModel, - envText: nextEnvText, - configText: nextConfigJson.configText, - } - }) - } else { - updateSelectedDraft((current) => ({ - ...current, - modelProviderId: providerId, - })) - } - }, - [selectedAgent, selectedDraft, modelProviders, updateSelectedDraft] - ) - - // Auto-select the first available provider when the user switches an agent to - // "model_provider" auth mode and hasn't picked one yet. If the list is empty, - // the existing "noModelProviderAvailable" hint handles the empty state. - useEffect(() => { - if (!selectedNeedsModelProvider) return - if (selectedDraft?.modelProviderId != null) return - if (selectedModelProviders.length === 0) return - handleModelProviderSelect(String(selectedModelProviders[0].id)) - }, [ - selectedNeedsModelProvider, - selectedDraft?.modelProviderId, - selectedModelProviders, - handleModelProviderSelect, - ]) - const handleGeminiFieldChange = useCallback( ( key: @@ -8095,135 +7939,4047 @@ export function AcpAgentSettings() {
- {selectedAgent.agent_type === "codex" ? ( -
-
- -

- {t("codex.configDescription")} -

-
- -
- - -

- {selectedDraft.codexAuthMode === "chatgpt_subscription" - ? t("codex.chatgptSubscriptionHint") - : selectedDraft.codexAuthMode === "model_provider" - ? t("modelProviderHint") - : t("authModeCustomEndpointHint")} -

-
+ {selectedAgentSupportsSourceCard ? ( + <> + { + if (!selectedAgentKind) return + setModelProviderSources((prev) => ({ + ...prev, + [selectedAgentKind]: selected, + })) + }} + /> + {!selectedUsesModelProviderSource && + (selectedAgent.agent_type === "codex" ? ( +
+
+ +

+ {t("codex.configDescription")} +

+
- {selectedDraft.codexAuthMode === "chatgpt_subscription" && ( -
- {hasCodexChatgptTokens( - selectedDraft.codexAuthJsonText - ) && - codexLoginStatus !== "polling" && - codexLoginStatus !== "requesting" && ( -
- - {t("codex.loggedIn")} -
+ {selectedModelProviderApiTypes.length > 0 && ( + )} - {codexLoginStatus === "idle" && ( - - )} - {codexLoginStatus === "requesting" && ( -
- - {t("codex.loginRequesting")} -
- )} - {codexLoginStatus === "polling" && codexDeviceCode && ( -
-

{t("codex.loginStep1")}

- -

- {t("codex.loginStep2")} -

-
- - {codexDeviceCode.userCode} - - + )} + {codexLoginStatus === "requesting" && ( +
+ + {t("codex.loginRequesting")} +
+ )} + {codexLoginStatus === "polling" && + codexDeviceCode && ( +
+

+ {t("codex.loginStep1")} +

+ +

+ {t("codex.loginStep2")} +

+
+ + {codexDeviceCode.userCode} + + +
+
+ + {t("codex.loginPolling")} +
+ +
+ )} + {codexLoginStatus === "success" && ( +
+ + {t("codex.loginSuccess")} +
+ )} + {codexLoginStatus === "error" && ( +
+

+ {t("codex.loginFailed", { + message: + codexLoginError ?? "Unknown error", + })} +

+ +
+ )} +
+ )} + + {selectedDraft.codexAuthMode === "api_key" && ( +
+ + { + handleCodexImportantConfigChange( + "apiBaseUrl", + event.target.value + ) + }} + placeholder="https://api.openai.com/v1" + /> +
+ )} + + {selectedDraft.codexAuthMode === "api_key" && ( +
+ +
+ { + handleCodexImportantConfigChange( + "apiKey", + event.target.value + ) + }} + placeholder="sk-..." + /> + +
+
+ )} + + {selectedDraft.codexAuthMode === "api_key" && ( +
+ +
+ )} + +
+
+ + +
+
+ +
+
+ + +
+
+ + {/* `[features].default_mode_request_user_input` — without + it codex refuses its own `request_user_input` tool + outside Plan mode, so codeg's question cards never + appear in an ordinary turn (openai/codex#24750). */} +
+
+ + +
+

+ {t("codex.enableDefaultModeRequestUserInputHint")} +

+
+ +
+
+ + +
+
+ + {/* ---- Sandbox & approvals (config.toml thread defaults) ---- + These govern the turns codex starts by itself: /goal, + /review, /compact. Ordinary prompts carry the composer + preset's own policy per turn and ignore these keys. */} +
+
+

+ {t("codex.sandboxGroupTitle")} +

+

+ {t("codex.sandboxGroupHint")} +

+
+ + {selectedDraft.codexSandboxShadowed ? ( +

+ {t("codex.sandboxShadowedWarning")} +

+ ) : null} + {selectedDraft.codexSandboxHasPermissionsTable && + !selectedDraft.codexSandboxShadowed ? ( +

+ {t("codex.sandboxPermissionsTableWarning")} +

+ ) : null} + +
+ + + {/* `untrusted` has no equivalent in codex-acp's three + approval presets, so an ACP session cannot honor it + (#442). Say so where the user picks it, rather than + letting it look effective. */} + {selectedDraft.codexApprovalPolicy === + "untrusted" ? ( +

+ {t("codex.approvalPolicyUntrustedAcpWarning")} +

+ ) : null} +
+ + {selectedDraft.codexApprovalPolicy === + "granular" ? ( +
+

+ {t("codex.granularHint")} +

+ {CODEX_GRANULAR_KEYS.map((key) => ( +
+ + { + updateSelectedDraft((current) => ({ + ...current, + codexGranular: { + ...current.codexGranular, + [key]: checked, + }, + })) + }} + aria-label={t(`codex.granular_${key}`)} + /> +
+ ))} +
+ ) : null} + +
+ + +

+ {t("codex.sandboxModeHint")} +

+ {/* Sandbox mode is what codeg maps onto the session's + starting approval preset (#442), so it reaches + ordinary prompts even though approval_policy does + not. Worth stating next to the control that does it. */} + {codexSandboxSeedsAcpPreset( + selectedDraft.codexSandboxShadowed + ) ? ( +

+ {t("codex.sandboxModeSeedsPresetHint")} +

+ ) : null} + {/* codex-acp 1.7.0 redefined its `read-only` preset to + carry a workspace-write sandbox, and it re-sends + that policy every turn — so an ACP session cannot + honor a read-only sandbox at all any more. This + control keeps working for codex CLI/IDE sessions, + which is exactly why the divergence has to be said + out loud rather than left to look effective. */} + {showsCodexReadOnlyAcpWarning( + selectedDraft.codexSandboxMode, + selectedDraft.codexSandboxShadowed + ) ? ( +

+ {t("codex.sandboxModeReadOnlyAcpWarning")} +

+ ) : null} +
+ + {codexWorkspaceWriteApplies( + selectedDraft.codexSandboxMode + ) && !selectedDraft.codexSandboxShadowed ? ( +
+
+ +