From b3e8edbc55e7979171318939a4cf07f48e6182ed Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 22:51:26 +0200 Subject: [PATCH] feat(editor): reach the AI provider settings from the app menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog's open state was a useState inside LeftPanel's ChatStripPanel, which mounts only under `mode === "edit" && chatOpen`. Nothing outside that component could open it, so an "AI Providers" row in the wordmark menu would have been dead in Media, in Rec, and in Edit with the chat panel collapsed. Lift it the way the shortcuts dialog already is: ProviderSettingsContext owns `isProviderSettingsOpen` / `openProviderSettings` / `closeProviderSettings`, `ProviderSettingsDialog` is the one mount in App.tsx beside ShortcutsConfigDialog, and the panel's five call sites ask the context instead of a local setter. The refresh that hung off the old onClose moves with it: connecting a provider is what makes the composer usable, and the panel now watches the falling edge of the lifted state for the same event. The panel's own entry points stay. Two doors to one room is right here — the menu row is the discoverable path, not a replacement. Fixes #420 --- src/App.tsx | 73 +++++---- src/components/ai-edition/LeftPanel.tsx | 35 ++-- src/components/ai-edition/NewEditorShell.tsx | 3 + .../ai-edition/ProviderSettings.test.tsx | 152 ++++++++++++++++++ .../ai-edition/ProviderSettings.tsx | 15 ++ .../ai-edition/v4/EditorTopBar.test.tsx | 24 ++- src/components/ai-edition/v4/EditorTopBar.tsx | 16 ++ src/contexts/ProviderSettingsContext.tsx | 41 +++++ .../architecture/llm-providers.md | 9 +- .../testing/manual-e2e-checklist.md | 7 +- 10 files changed, 322 insertions(+), 53 deletions(-) create mode 100644 src/components/ai-edition/ProviderSettings.test.tsx create mode 100644 src/contexts/ProviderSettingsContext.tsx diff --git a/src/App.tsx b/src/App.tsx index 53d52c742..c86fe6f7b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { SourceSelector } from "./components/launch/SourceSelector"; import { Toaster } from "./components/ui/sonner"; import { TooltipProvider } from "./components/ui/tooltip"; import { useScopedT } from "./contexts/I18nContext"; +import { ProviderSettingsProvider } from "./contexts/ProviderSettingsContext"; import { ShortcutsProvider } from "./contexts/ShortcutsContext"; import { loadAllCustomFonts } from "./lib/customFonts"; @@ -29,6 +30,11 @@ const ShortcutsConfigDialog = lazy(() => default: module.ShortcutsConfigDialog, })), ); +const ProviderSettingsDialog = lazy(() => + import("./components/ai-edition/ProviderSettings").then((module) => ({ + default: module.ProviderSettingsDialog, + })), +); export default function App() { const [windowType, setWindowType] = useState( @@ -107,38 +113,41 @@ export default function App() { case "editor": return ( - - - - - - {tEditor("loadingEditor")} - - } - > - - - + + + + + + + {tEditor("loadingEditor")} + + } + > + + + + + ); default: diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index c2f76d4ec..73242c199 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; +import { useProviderSettings } from "@/contexts/ProviderSettingsContext"; import type { AxcutAsset } from "@/lib/ai-edition/schema"; import { applyAgentDocumentIfCurrent, @@ -33,7 +34,6 @@ import { ChatWelcome } from "./ChatWelcome"; import { canSendChat } from "./chatAvailability"; import { ChatHistoryModal, SourceTranscriptModal } from "./Modals"; import styles from "./NewEditorShell.module.css"; -import { ProviderSettings } from "./ProviderSettings"; import { TranscriptionStatusDot } from "./TranscriptionStatus"; import { useChatBudget } from "./useChatBudget"; @@ -737,7 +737,9 @@ function ChatStripPanel() { const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [llmConfig, setLlmConfig] = useState(null); - const [settingsOpen, setSettingsOpen] = useState(false); + // The dialog itself is mounted in App.tsx so the app menu can reach it from every mode + // (issue #420); this panel only asks for it to be opened. + const { isProviderSettingsOpen, openProviderSettings } = useProviderSettings(); const [chatsOpen, setChatsOpen] = useState(false); const [sessions, setSessions] = useState< Array<{ id: string; title: string; messageCount: number; createdAt: string }> @@ -813,6 +815,16 @@ function ChatStripPanel() { void refreshLlm(); }, [refreshLlm]); + // Re-read after the dialog closes. Connecting a provider there is what makes the composer + // usable here, and the dialog no longer hangs off this component, so there is no onClose to + // do it from — the falling edge of the lifted state is the same event. + const providerSettingsWasOpen = useRef(isProviderSettingsOpen); + useEffect(() => { + const wasOpen = providerSettingsWasOpen.current; + providerSettingsWasOpen.current = isProviderSettingsOpen; + if (wasOpen && !isProviderSettingsOpen) void refreshLlm(); + }, [isProviderSettingsOpen, refreshLlm]); + // ponytail: subscribe to streamed chat events so the reasoning trace (and // any future streaming text deltas) lands live instead of arriving all at // once when chatRun resolves. We only act on `thinking` here — text deltas @@ -881,7 +893,7 @@ function ChatStripPanel() { // but Auto-enhance calls send() directly and Enter can slip through. if (!canChat) { toast.error(t("chat.composerDisabledNoProvider")); - setSettingsOpen(true); + openProviderSettings(); return; } setInput(""); @@ -1143,7 +1155,7 @@ function ChatStripPanel() { // full settings modal (the "providers" screen) instead of toggling a // popover that would render empty. if (!llmConfig) { - setSettingsOpen(true); + openProviderSettings(); return; } setModelPopoverOpen((wasOpen) => { @@ -1163,7 +1175,7 @@ function ChatStripPanel() { } return !wasOpen; }); - }, [llmConfig]); + }, [llmConfig, openProviderSettings]); // Prefer the main process's estimate of the windowed history it actually sends, so // manual compaction can shrink this meter while the complete transcript remains @@ -1333,7 +1345,7 @@ function ChatStripPanel() { type="button" title={t("chat.aiSettings")} aria-label={t("chat.aiSettings")} - onClick={() => setSettingsOpen(true)} + onClick={openProviderSettings} > {!canChat && messages.length === 0 ? ( - setSettingsOpen(true)} /> + ) : messages.length === 0 ? (

setModelPopoverOpen(false)} onConfigChange={() => void refreshLlm()} - onOpenFullSettings={() => setSettingsOpen(true)} + onOpenFullSettings={openProviderSettings} /> ) : null} + {/* Settings surfaces together, above the separator. Both rows are labelled with the + title of the dialog they open, so neither can drift from it — and unlike the AI + panel's own entry points, this one is reachable in Media and Rec too, which is + the whole reason the dialog's open state was lifted out of LeftPanel (#420). */} +

{/* Only the PERMANENT half of the veto is applied here. A Store/Flathub/Snap/Nix copy never offers the check at all; the transient half — not during a take — diff --git a/src/contexts/ProviderSettingsContext.tsx b/src/contexts/ProviderSettingsContext.tsx new file mode 100644 index 000000000..db4d74d30 --- /dev/null +++ b/src/contexts/ProviderSettingsContext.tsx @@ -0,0 +1,41 @@ +import { createContext, type ReactNode, useCallback, useContext, useMemo, useState } from "react"; + +// Open/close state for the AI provider settings dialog, lifted out of the chat panel. +// +// It used to be a `useState` inside `LeftPanel`'s `ChatStripPanel`, which mounts only in Edit +// mode with the chat panel expanded. Nothing outside that component could open the dialog, so +// the app menu had no way to offer it (issue #420). The state lives here for the same reason +// `ShortcutsContext` owns `isConfigOpen`: the surfaces that open a dialog and the place it is +// mounted are on different branches of the tree. +// +// Deliberately just the three members. Everything the dialog itself needs — the provider +// snapshot, the credentials, the save — it reads over the native bridge on open. +interface ProviderSettingsContextValue { + isProviderSettingsOpen: boolean; + openProviderSettings: () => void; + closeProviderSettings: () => void; +} + +const ProviderSettingsContext = createContext(null); + +export function useProviderSettings(): ProviderSettingsContextValue { + const ctx = useContext(ProviderSettingsContext); + if (!ctx) throw new Error("useProviderSettings must be used within "); + return ctx; +} + +export function ProviderSettingsProvider({ children }: { children: ReactNode }) { + const [isProviderSettingsOpen, setIsProviderSettingsOpen] = useState(false); + + const openProviderSettings = useCallback(() => setIsProviderSettingsOpen(true), []); + const closeProviderSettings = useCallback(() => setIsProviderSettingsOpen(false), []); + + const value = useMemo( + () => ({ isProviderSettingsOpen, openProviderSettings, closeProviderSettings }), + [isProviderSettingsOpen, openProviderSettings, closeProviderSettings], + ); + + return ( + {children} + ); +} diff --git a/technical-documentation/architecture/llm-providers.md b/technical-documentation/architecture/llm-providers.md index f01f02b37..d727ac320 100644 --- a/technical-documentation/architecture/llm-providers.md +++ b/technical-documentation/architecture/llm-providers.md @@ -10,7 +10,14 @@ The provider layer defines model metadata, protects credentials, discovers model | [`electron/ai-edition/deep-agent/chat-model.ts`](../../electron/ai-edition/deep-agent/chat-model.ts) | `createOpenScreenChatModel` — the single transport. Picks a `@langchain/*` chat model class per provider. | | [`electron/ai-edition/deep-agent/chat-model.ts`](../../electron/ai-edition/deep-agent/chat-model.ts) | Per-provider reasoning-effort capability and its LangChain wire options. | | [`electron/native-bridge/services/aiEditionService.ts`](../../electron/native-bridge/services/aiEditionService.ts) | IPC surface: connect / disconnect, snapshot, `llmListProviderModels`. | -| [`src/components/ai-edition/ProviderSettings.tsx`](../../src/components/ai-edition/ProviderSettings.tsx) | Renders cards and forms directly from `PROVIDER_DEFINITIONS`. | +| [`src/components/ai-edition/ProviderSettings.tsx`](../../src/components/ai-edition/ProviderSettings.tsx) | Renders cards and forms directly from `PROVIDER_DEFINITIONS`. `ProviderSettingsDialog`, in the same file, is its one mount — `App.tsx`, beside `ShortcutsConfigDialog`. | +| [`src/contexts/ProviderSettingsContext.tsx`](../../src/contexts/ProviderSettingsContext.tsx) | Open state for that dialog, so every entry point opens the same one. | + +> **Two doors, one dialog.** The settings dialog is opened from the AI panel — its gear, the +> welcome card's CTA, the model pill with nothing configured, and a send attempt with no +> provider — and from the app menu under the wordmark. Its open state lives in a context rather +> than in `LeftPanel` because that panel mounts only in Edit mode with the chat panel expanded, +> which would have left the menu item dead in Media and Rec (issue #420). > **There is one transport.** `llm-call.ts` (`streamLlm` / `callLlm`) and > `codex-session.ts` were deleted in 1.8.0 along with the two account-backed diff --git a/technical-documentation/testing/manual-e2e-checklist.md b/technical-documentation/testing/manual-e2e-checklist.md index c7af9d121..c34916ffc 100644 --- a/technical-documentation/testing/manual-e2e-checklist.md +++ b/technical-documentation/testing/manual-e2e-checklist.md @@ -328,12 +328,15 @@ The agent may only call the fixed tool set in [ai-agent.md](../architecture/ai-a - [ ] On macOS, open the application menu and confirm About OpenScreen is followed by Check for Updates. - [ ] On Windows and Linux, right-click the tray icon and confirm it lists Check for Updates and About OpenScreen. Outside the editor the tray is the only surface reachable by default there: the HUD is frameless and the editor and notes windows auto-hide their menu bar, so the Help menu appears only while Alt is held over one of those two windows. - [ ] On Windows and Linux, open the editor, hold Alt, and confirm the Help menu lists Check for Updates and About OpenScreen. -- [ ] In the editor, click the OpenScreen wordmark in the top bar and confirm it opens a menu listing Keyboard Shortcuts, Check for Updates and About OpenScreen. This is the discoverable path on Windows and Linux, where the two above are not. +- [ ] In the editor, click the OpenScreen wordmark in the top bar and confirm it opens a menu listing Keyboard Shortcuts, AI settings, Check for Updates and About OpenScreen. This is the discoverable path on Windows and Linux, where the two above are not. - [ ] Confirm the About row in that menu shows the running version, and that it matches what the About box then reports. - [ ] Open the wordmark menu and pick Keyboard Shortcuts; confirm it opens the same dialog the top bar's gear does, and that only one dialog appears. +- [ ] Open the wordmark menu and pick AI settings; confirm it opens the same provider dialog the AI panel's gear does, and that only one dialog appears. +- [ ] Repeat that in Media mode, in Rec mode, and in Edit mode with the chat panel collapsed — the three states in which the dialog had no owner before, and the reason the row must not be Edit-only. +- [ ] Connect or disconnect a provider from the menu's dialog while the chat panel is open behind it, close the dialog, and confirm the composer and the model pill follow without reopening the panel. - [ ] Open the wordmark menu, then press Escape, click elsewhere in the top bar, and click the wordmark again — confirm each closes it and that the window does not start dragging instead of registering the click. - [ ] With the wordmark menu open, walk it with the Down and Up arrows and confirm focus wraps at both ends. -- [ ] Switch the app language and confirm the wordmark menu's three labels follow, matching the wording the macOS app menu and the tray use. +- [ ] Switch the app language and confirm the wordmark menu's four labels follow — the first two matching the dialogs they open, the last two the wording the macOS app menu and the tray use. - [ ] Open About and confirm it names the running version, the Electron/Chromium/Node versions, and the install channel. - [ ] Confirm the About box opens in front of the HUD rather than behind it. - [ ] On Windows and Linux, press Copy in the About box and confirm the clipboard holds that same block.