{
+ const { DEFAULT_SHORTCUTS } = await import("@/lib/shortcuts");
+ return {
+ useShortcuts: () => ({
+ shortcuts: DEFAULT_SHORTCUTS,
+ isMac: false,
+ isConfigOpen: false,
+ openConfig,
+ closeConfig: () => {
+ /* not exercised here */
+ },
+ setShortcuts: () => {
+ /* not exercised here */
+ },
+ persistShortcuts: () => Promise.resolve(true),
+ }),
+ };
+});
+
+vi.mock("@/contexts/I18nContext", () => ({
+ useI18n: () => ({
+ locale: "en",
+ setLocale: () => {
+ /* fixed locale */
+ },
+ }),
+ useScopedT: () => (key: string) => key,
+}));
+
+import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext";
+import { NewEditorShell } from "./NewEditorShell";
+
+let dialogActions: ReturnType
| null = null;
+
+function CaptureDialogActions() {
+ dialogActions = useEditorDialogActions();
+ return null;
+}
+
+function renderShell() {
+ return render(
+
+
+
+ ,
+ );
+}
+
+/** Shortcuts are bound on `window` and read `e.target`; with a modal open that is the body. */
+function pressOnBody(init: KeyboardEventInit) {
+ fireEvent.keyDown(document.body, init);
+}
+
+beforeEach(() => {
+ openConfig.mockClear();
+ dialogActions = null;
+ // No preload in jsdom, and no scrolling either; the chat transcript pins itself to the
+ // bottom on every render.
+ (window as unknown as { electronAPI?: unknown }).electronAPI = {
+ onAiEditionChatEvent: () => () => {
+ /* unsubscribe */
+ },
+ setTitleBarOverlay: () => {
+ /* no native titlebar */
+ },
+ setHasUnsavedChanges: () => {
+ /* no window close guard */
+ },
+ onRequestCloseConfirm: () => () => {
+ /* unsubscribe */
+ },
+ onRequestSaveBeforeClose: () => () => {
+ /* unsubscribe */
+ },
+ sendCloseConfirmResponse: () => {
+ /* nothing is closing this window */
+ },
+ // The only two other members the editor tree reaches without optional chaining. Both
+ // are user-driven, not mount-driven; they are here so a stray call is a no-op rather
+ // than a crash that reads as a failure of the thing under test.
+ findRecordingCamera: () => Promise.resolve(null),
+ preparePreviewAudioTrack: () => Promise.resolve(null),
+ };
+ Element.prototype.scrollTo = () => {
+ /* no scrolling in jsdom */
+ };
+ // jsdom ships neither; the stage and the timeline both measure themselves.
+ (globalThis as unknown as { ResizeObserver?: unknown }).ResizeObserver = class {
+ observe() {
+ /* never fires: nothing has a layout in jsdom */
+ }
+ unobserve() {
+ /* see observe */
+ }
+ disconnect() {
+ /* see observe */
+ }
+ };
+});
+
+afterEach(() => {
+ cleanup();
+ (window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
+});
+
+describe("NewEditorShell shortcuts, with a dialog over the editor", () => {
+ it("routes ? to the shortcuts dialog while nothing is open", () => {
+ renderShell();
+
+ pressOnBody({ key: "?" });
+
+ expect(openConfig).toHaveBeenCalledTimes(1);
+ });
+
+ it("suppresses ? once a dialog owns the screen, and resumes when it closes", () => {
+ renderShell();
+
+ act(() => {
+ dialogActions?.openDialog("providers");
+ });
+ pressOnBody({ key: "?" });
+ expect(openConfig).not.toHaveBeenCalled();
+
+ act(() => {
+ dialogActions?.closeDialog();
+ });
+ pressOnBody({ key: "?" });
+ expect(openConfig).toHaveBeenCalledTimes(1);
+ });
+
+ // Ctrl+O is handled before the `hasProject` gate, so it fired whatever the editor's state —
+ // this is the one that put a SECOND aria-modal dialog on screen, both of them emitting the
+ // hardcoded `id="modal-title"`. Its handler is async (it awaits the unsaved-changes prompt
+ // before opening the picker), hence the async act: a synchronous assertion here would pass
+ // whether the guard is there or not.
+ it("opens the project picker on Ctrl+O while nothing is open", async () => {
+ renderShell();
+
+ await act(async () => {
+ pressOnBody({ key: "o", ctrlKey: true });
+ });
+
+ // The provider dialog is mounted in App.tsx, not here, so the shell renders no dialog of
+ // its own unless Ctrl+O got through.
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
+ });
+
+ it("suppresses Ctrl+O once a dialog owns the screen", async () => {
+ renderShell();
+
+ act(() => {
+ dialogActions?.openDialog("providers");
+ });
+ await act(async () => {
+ pressOnBody({ key: "o", ctrlKey: true });
+ });
+
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/ai-edition/NewEditorShell.tsx b/src/components/ai-edition/NewEditorShell.tsx
index 3cf34c730..3d6978309 100644
--- a/src/components/ai-edition/NewEditorShell.tsx
+++ b/src/components/ai-edition/NewEditorShell.tsx
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
+import { useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
-import { useProviderSettings } from "@/contexts/ProviderSettingsContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
migrateProjectDataToAxcutDocument,
@@ -130,8 +130,12 @@ export function NewEditorShell() {
action: "close" | "new" | "open" | "record";
resolve: (choice: UnsavedChoice) => void;
} | null>(null);
- const { shortcuts, isMac, openConfig: openShortcutsConfig } = useShortcuts();
- const { openProviderSettings } = useProviderSettings();
+ const { shortcuts, isMac, isConfigOpen, openConfig: openShortcutsConfig } = useShortcuts();
+ // The actions half of the dialog context, not the section: this component only ever *opens*
+ // one, and subscribing it to the open state would re-render the whole editor — timeline,
+ // preview, transport — twice per dialog interaction. `isDialogOpen` answers the keyboard
+ // handler below from a ref, which is why it can live in a value that never changes.
+ const { openDialog, isDialogOpen } = useEditorDialogActions();
// Transcription is local and every transcript-driven feature (Smart cuts,
// captions, the transcript pane) needs one, so the editor produces them by
// itself instead of waiting for the user to find the button. This hook is
@@ -854,6 +858,12 @@ export function NewEditorShell() {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement) return;
if (e.target instanceof HTMLElement && e.target.isContentEditable) return;
+ // A modal owns the screen. Its own controls are buttons, not text fields, so the two
+ // guards above let every editor shortcut through underneath it: Delete destroyed the
+ // selected region behind the backdrop, Ctrl+O stacked a second `aria-modal` dialog on
+ // top, and `?` stacked the shortcuts dialog. Both flags are reachable now that the
+ // open state is lifted out of the components that used to own it (#420).
+ if (isDialogOpen() || isConfigOpen) return;
const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key === "s") {
e.preventDefault();
@@ -1039,6 +1049,8 @@ export function NewEditorShell() {
saveDocument,
copiedClipId,
openShortcutsConfig,
+ isConfigOpen,
+ isDialogOpen,
shortcuts,
isMac,
togglePlay,
@@ -1138,7 +1150,7 @@ export function NewEditorShell() {
openSettings: handleOpenSettings,
renameProject: handleRenameProject,
toggleChat: () => setChatOpen((v) => !v),
- openProviderSettings,
+ openProviderSettings: () => openDialog("providers"),
showAbout: handleShowAbout,
checkForUpdates: handleCheckForUpdates,
}}
diff --git a/src/components/ai-edition/ProviderSettings.test.tsx b/src/components/ai-edition/ProviderSettings.test.tsx
index 86af87d58..3c1debd73 100644
--- a/src/components/ai-edition/ProviderSettings.test.tsx
+++ b/src/components/ai-edition/ProviderSettings.test.tsx
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
// Issue #420: the AI provider dialog used to be a `useState` inside LeftPanel's chat strip, so
// it existed only in Edit mode with the chat panel expanded and nothing else could open it. It
-// is mounted once now, above the mode switch, and driven by ProviderSettingsContext.
+// is mounted once now, above the mode switch, and driven by EditorDialogsContext.
//
// These tests are about *reach*, not about the dialog's own screens: that the app menu's row
// really opens it, that it opens in Media and Rec too, and that the row and the heading are one
@@ -10,8 +10,8 @@
import "@testing-library/jest-dom";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { I18nProvider } from "@/contexts/I18nContext";
-import { ProviderSettingsProvider, useProviderSettings } from "@/contexts/ProviderSettingsContext";
import { LOCALE_STORAGE_KEY } from "@/i18n/config";
import { type EditorMode, EditorTopBar } from "./v4/EditorTopBar";
@@ -39,7 +39,7 @@ const noop = () => {};
/** The top bar as NewEditorShell builds it: the menu row's action is the context's opener, and
* nothing else in `actions` matters here. */
function TopBar({ mode }: { mode: EditorMode }) {
- const { openProviderSettings } = useProviderSettings();
+ const { openDialog } = useEditorDialogActions();
return (
openDialog("providers"),
showAbout: noop,
checkForUpdates: noop,
}}
@@ -71,10 +71,10 @@ function renderEditorChrome(locale: string, mode: EditorMode = "edit") {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
return render(
-
+
-
+
,
);
}
diff --git a/src/components/ai-edition/ProviderSettings.tsx b/src/components/ai-edition/ProviderSettings.tsx
index 6bcf9fa3f..8d66681c7 100644
--- a/src/components/ai-edition/ProviderSettings.tsx
+++ b/src/components/ai-edition/ProviderSettings.tsx
@@ -13,14 +13,14 @@
// with them. Credentials live in the safeStorage blob (LlmConfigStore) — the
// renderer never sees raw keys, only `kind`.
//
-// ponytail: existing consumers (ProviderSettings used as )
-// must keep working. Internal state is local-only.
+// `ProviderSettingsDialog` at the bottom is the only mount, and the only caller of the
+// `open` / `onClose` component above it. Internal state is local-only.
import { AlertCircle, Check, Loader2, Unplug, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
+import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
-import { useProviderSettings } from "@/contexts/ProviderSettingsContext";
import { nativeBridgeClient } from "@/native/client";
import type { AiEditionLlmConfig, AiEditionLlmSnapshot } from "@/native/contracts";
import {
@@ -37,14 +37,9 @@ type Mode = "list" | "form";
interface ProviderSettingsProps {
open: boolean;
onClose: () => void;
- onActiveProviderChanged?: (providerId: string | null) => void;
}
-export function ProviderSettings({
- open,
- onClose,
- onActiveProviderChanged,
-}: ProviderSettingsProps) {
+function ProviderSettings({ open, onClose }: ProviderSettingsProps) {
const te = useScopedT("editor");
const [snapshot, setSnapshot] = useState(null);
const [mode, setMode] = useState("list");
@@ -90,6 +85,10 @@ export function ProviderSettings({
setError(null);
}, [busy]);
+ // Escape is ours alone — see `closeOnEscape={false}` on the ModalShell below. While
+ // ModalShell also handled it, both listeners fired for one keypress and its `onClose` won,
+ // so Escape in the connect form left the dialog entirely (discarding a half-typed key)
+ // instead of stepping back to the grid, and the branch below was dead.
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
@@ -138,8 +137,7 @@ export function ProviderSettings({
setApiKey("");
}
await nativeBridgeClient.aiEdition.llmSetConfig(config);
- const snap = await refreshSnapshot();
- onActiveProviderChanged?.(snap?.config?.provider ?? null);
+ await refreshSnapshot();
toast.success(te("providerSettings.saved", { provider: active.label }));
setMode("list");
setActive(null);
@@ -156,8 +154,11 @@ export function ProviderSettings({
setError(null);
try {
const result = await nativeBridgeClient.aiEdition.llmDisconnect(active.id);
- const snap = result.snapshot ?? (await refreshSnapshot());
- onActiveProviderChanged?.(snap.config?.provider ?? null);
+ // `snapshot` is not optional on the result, so a `?? refreshSnapshot()` fallback here
+ // never ran — and `refreshSnapshot` is the only thing that calls `setSnapshot`. The
+ // form and the grid behind it went on showing the provider as CONNECTED until the
+ // dialog was closed and reopened.
+ setSnapshot(result.snapshot);
toast.success(te("providerSettings.disconnected", { provider: active.label }));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
@@ -170,6 +171,7 @@ export function ProviderSettings({
;
+ const section = useEditorDialogSection();
+ const { closeDialog } = useEditorDialogActions();
+ return ;
}
function ProviderList({
diff --git a/src/components/ai-edition/v4/EditorTopBar.tsx b/src/components/ai-edition/v4/EditorTopBar.tsx
index ba5aefdd0..a369da6a8 100644
--- a/src/components/ai-edition/v4/EditorTopBar.tsx
+++ b/src/components/ai-edition/v4/EditorTopBar.tsx
@@ -278,9 +278,12 @@ function ProjectNameField({
* paid for out of the mode labels, in the most verbose of 13 locales, at the 800px minimum
* window width.
*
- * Every label is an EXISTING common.actions / shortcuts key — the same ones electron/main.ts
- * builds the native menu from. Reusing them is what stops the two menus from drifting apart,
- * and it is why this component adds no translation work. */
+ * No row invents a label. Each one reuses the key of the thing it opens: `common.actions.*`
+ * for the rows electron/main.ts also builds native menu items from (About, Check for
+ * Updates), and the dialog's own title key for the rows that open a dialog (`shortcuts.title`,
+ * `editor.providerSettings.title`). That is what stops this menu from drifting away from the
+ * native menu on one side and from what its rows actually open on the other — and it is why
+ * this component adds no translation work. */
/** Shared by the mount seed and the per-open refresh below. A rejection — no preload, browser
* mode — resolves to "no", which hides the item rather than shipping a button whose click the
* main process would refuse without saying so. */
diff --git a/src/contexts/EditorDialogsContext.tsx b/src/contexts/EditorDialogsContext.tsx
new file mode 100644
index 000000000..4da8d9f9a
--- /dev/null
+++ b/src/contexts/EditorDialogsContext.tsx
@@ -0,0 +1,79 @@
+import { createContext, type ReactNode, useContext, useMemo, useRef, useState } from "react";
+
+// Which of the editor chrome's own dialogs is open, lifted out of the component that used to
+// own it.
+//
+// The AI provider dialog 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 surfaces that open
+// a dialog and the place it is mounted are on different branches of the tree.
+//
+// One `section` rather than a boolean per dialog. The settings unification this is the first
+// step of ends in a single dialog with a sidebar (General / Shortcuts / AI / Devices / About);
+// each section it gains is then a member of this union, not another context, another provider
+// wrapped around App.tsx's editor branch and another near-identical file.
+//
+// Split in two on purpose. The section changes on every open and close, the actions never do.
+// `NewEditorShell` owns the timeline, the preview and the transport, and only ever needs to
+// *open* a dialog — subscribing it to the section would re-render the whole editor twice per
+// dialog interaction, so it takes the actions alone. `isDialogOpen` serves the readers that
+// are event handlers rather than renders: it answers from a ref, which is what lets it sit in
+// a value whose identity never changes.
+export type EditorDialogSection = "providers";
+
+interface EditorDialogsActions {
+ openDialog: (section: EditorDialogSection) => void;
+ closeDialog: () => void;
+ /** A live answer without a subscription — for event handlers, never for rendering. */
+ isDialogOpen: () => boolean;
+}
+
+// `undefined` is the "no provider above me" marker, so that `null` stays free to mean the real
+// state: mounted, nothing open.
+const SectionContext = createContext(undefined);
+const ActionsContext = createContext(null);
+
+export function useEditorDialogSection(): EditorDialogSection | null {
+ const section = useContext(SectionContext);
+ if (section === undefined) {
+ throw new Error("useEditorDialogSection must be used within ");
+ }
+ return section;
+}
+
+export function useEditorDialogActions(): EditorDialogsActions {
+ const ctx = useContext(ActionsContext);
+ if (!ctx) throw new Error("useEditorDialogActions must be used within ");
+ return ctx;
+}
+
+export function EditorDialogsProvider({ children }: { children: ReactNode }) {
+ const [section, setSection] = useState(null);
+ // Mirrored so `isDialogOpen` can read the current section without the actions value having
+ // to depend on it. Written by the two openers, not during render and not from an effect:
+ // during render a discarded one would leave the ref claiming a dialog that never committed,
+ // and from an effect a keystroke landing between the click and the commit would still get
+ // the previous answer. `setSection` is called from nowhere else, so the two cannot drift.
+ const sectionRef = useRef(null);
+
+ const actions = useMemo(
+ () => ({
+ openDialog: (next) => {
+ sectionRef.current = next;
+ setSection(next);
+ },
+ closeDialog: () => {
+ sectionRef.current = null;
+ setSection(null);
+ },
+ isDialogOpen: () => sectionRef.current !== null,
+ }),
+ [],
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/contexts/ProviderSettingsContext.tsx b/src/contexts/ProviderSettingsContext.tsx
deleted file mode 100644
index db4d74d30..000000000
--- a/src/contexts/ProviderSettingsContext.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-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 d727ac320..125805b7c 100644
--- a/technical-documentation/architecture/llm-providers.md
+++ b/technical-documentation/architecture/llm-providers.md
@@ -11,13 +11,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) | 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`. `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. |
+| [`src/contexts/EditorDialogsContext.tsx`](../../src/contexts/EditorDialogsContext.tsx) | Which editor dialog is open (`section`), 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).
+> welcome card's CTA, the model pill with nothing configured, the quick-pick popover's "full
+> settings" row, 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