Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 41 additions & 32 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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(
Expand Down Expand Up @@ -107,38 +113,41 @@ export default function App() {
case "editor":
return (
<ShortcutsProvider>
<Suspense
fallback={
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
<svg
className="animate-spin text-[#34B27B]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={28}
height={28}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
</div>
}
>
<VideoEditorEntry />
<ShortcutsConfigDialog />
</Suspense>
<ProviderSettingsProvider>
<Suspense
fallback={
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
<svg
className="animate-spin text-[#34B27B]"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
width={28}
height={28}
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
<span className="text-white/50 text-sm">{tEditor("loadingEditor")}</span>
</div>
}
>
<VideoEditorEntry />
<ShortcutsConfigDialog />
<ProviderSettingsDialog />
</Suspense>
</ProviderSettingsProvider>
</ShortcutsProvider>
);
default:
Expand Down
35 changes: 20 additions & 15 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -737,7 +737,9 @@ function ChatStripPanel() {
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [llmConfig, setLlmConfig] = useState<AiEditionLlmConfig | null>(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 }>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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) => {
Expand All @@ -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
Expand Down Expand Up @@ -1333,7 +1345,7 @@ function ChatStripPanel() {
type="button"
title={t("chat.aiSettings")}
aria-label={t("chat.aiSettings")}
onClick={() => setSettingsOpen(true)}
onClick={openProviderSettings}
>
<svg
width={14}
Expand Down Expand Up @@ -1532,7 +1544,7 @@ function ChatStripPanel() {

<div className={styles.panelBody} ref={scrollRef}>
{!canChat && messages.length === 0 ? (
<ChatWelcome onOpenProviderSettings={() => setSettingsOpen(true)} />
<ChatWelcome onOpenProviderSettings={openProviderSettings} />
) : messages.length === 0 ? (
<p
style={{
Expand Down Expand Up @@ -1853,7 +1865,7 @@ function ChatStripPanel() {
connectedProviders={connectedProviders ?? []}
onClose={() => setModelPopoverOpen(false)}
onConfigChange={() => void refreshLlm()}
onOpenFullSettings={() => setSettingsOpen(true)}
onOpenFullSettings={openProviderSettings}
/>
) : null}
<button
Expand All @@ -1880,13 +1892,6 @@ function ChatStripPanel() {
</button>
</div>
</div>
<ProviderSettings
open={settingsOpen}
onClose={() => {
setSettingsOpen(false);
void refreshLlm();
}}
/>
<ChatHistoryModal
open={chatsOpen}
onClose={() => setChatsOpen(false)}
Expand Down
3 changes: 3 additions & 0 deletions src/components/ai-edition/NewEditorShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { toast } from "sonner";
import type { EditorProjectData } from "@/components/video-editor/projectPersistence";
import { toFileUrl } from "@/components/video-editor/projectPersistence";
import { useScopedT } from "@/contexts/I18nContext";
import { useProviderSettings } from "@/contexts/ProviderSettingsContext";
import { useShortcuts } from "@/contexts/ShortcutsContext";
import {
migrateProjectDataToAxcutDocument,
Expand Down Expand Up @@ -130,6 +131,7 @@ export function NewEditorShell() {
resolve: (choice: UnsavedChoice) => void;
} | null>(null);
const { shortcuts, isMac, openConfig: openShortcutsConfig } = useShortcuts();
const { openProviderSettings } = useProviderSettings();
// 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
Expand Down Expand Up @@ -1136,6 +1138,7 @@ export function NewEditorShell() {
openSettings: handleOpenSettings,
renameProject: handleRenameProject,
toggleChat: () => setChatOpen((v) => !v),
openProviderSettings,
showAbout: handleShowAbout,
checkForUpdates: handleCheckForUpdates,
}}
Expand Down
152 changes: 152 additions & 0 deletions src/components/ai-edition/ProviderSettings.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// @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.
//
// 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
// string rather than two that can drift apart.

import "@testing-library/jest-dom";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
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";

// The dialog reads a provider snapshot over the native bridge the moment it opens. Answer with
// an empty one: which providers exist is the registry's business, and this file's is the door.
vi.mock("@/native/client", () => ({
nativeBridgeClient: {
aiEdition: {
llmGetSnapshot: () =>
Promise.resolve({
config: null,
connectedProviders: [],
availableProviders: [],
credentialSummary: [],
}),
llmListProviderModels: () => Promise.resolve({ models: [] }),
},
},
}));

import { ProviderSettingsDialog } from "./ProviderSettings";

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();
return (
<EditorTopBar
mode={mode}
onModeChange={noop}
projectTitle="Demo Project"
dirty={false}
canExport={false}
chatOpen={false}
actions={{
openProject: noop,
newProject: noop,
save: noop,
export: noop,
openSettings: noop,
renameProject: noop,
toggleChat: noop,
openProviderSettings,
showAbout: noop,
checkForUpdates: noop,
}}
/>
);
}

/** The App.tsx shape, minus the editor body: one provider, one dialog mount, and the top bar
* that has to reach it. Rendered with the real translations — the drift assertion below is
* only worth anything against real strings. */
function renderEditorChrome(locale: string, mode: EditorMode = "edit") {
localStorage.setItem(LOCALE_STORAGE_KEY, locale);
return render(
<I18nProvider>
<ProviderSettingsProvider>
<TopBar mode={mode} />
<ProviderSettingsDialog />
</ProviderSettingsProvider>
</I18nProvider>,
);
}

/** Open the app menu (the wordmark) and click its AI settings row. */
function openAiSettingsFromAppMenu() {
fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
fireEvent.click(screen.getByRole("menuitem", { name: /ai settings/i }));
}

beforeEach(() => {
localStorage.clear();
});

afterEach(() => {
cleanup();
localStorage.clear();
});

describe("ProviderSettings, reached from the app menu", () => {
it("is absent until the menu row is clicked, then mounted as a dialog", () => {
renderEditorChrome("en");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

openAiSettingsFromAppMenu();

expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByRole("heading", { name: /ai settings/i })).toBeInTheDocument();
});

it.each<EditorMode>([
"media",
"rec",
])("opens in %s mode, where the chat panel that used to own it does not exist", (mode) => {
// The reason the state was lifted. LeftPanel renders only under `mode === "edit" &&
// chatOpen`, so before this change the row would have been dead in both of these.
renderEditorChrome("en", mode);

openAiSettingsFromAppMenu();

expect(screen.getByRole("dialog")).toBeInTheDocument();
});

it("labels the menu row with the dialog's own heading, so the two cannot drift", () => {
// Both read `editor.providerSettings.title`. A menu-only key would be free to say
// something else after a copy edit, and the menu would start lying about where it goes.
// Compared as text rather than asserted against a literal, so a copy edit moves both.
renderEditorChrome("en");
fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
const rowLabel = screen.getByRole("menuitem", { name: /ai settings/i }).textContent;

fireEvent.click(screen.getByRole("menuitem", { name: /ai settings/i }));

expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(rowLabel ?? "");
});

it("closes again from the dialog's own close button", () => {
renderEditorChrome("en");
openAiSettingsFromAppMenu();

fireEvent.click(screen.getByRole("button", { name: /close/i }));

expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

it("translates the row with the dialog, not separately", () => {
renderEditorChrome("fr");
fireEvent.click(screen.getByRole("button", { name: /OpenScreen/ }));
const row = screen.getByRole("menuitem", { name: /paramètres ia/i });

fireEvent.click(row);

expect(screen.getByRole("heading", { name: /paramètres ia/i })).toBeInTheDocument();
});
});
Loading
Loading