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.