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
6 changes: 3 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ import { NotesWindow } from "./components/launch/NotesWindow.tsx";
import { SourceSelector } from "./components/launch/SourceSelector";
import { Toaster } from "./components/ui/sonner";
import { TooltipProvider } from "./components/ui/tooltip";
import { EditorDialogsProvider } from "./contexts/EditorDialogsContext";
import { useScopedT } from "./contexts/I18nContext";
import { ProviderSettingsProvider } from "./contexts/ProviderSettingsContext";
import { ShortcutsProvider } from "./contexts/ShortcutsContext";
import { loadAllCustomFonts } from "./lib/customFonts";

Expand Down Expand Up @@ -113,7 +113,7 @@ export default function App() {
case "editor":
return (
<ShortcutsProvider>
<ProviderSettingsProvider>
<EditorDialogsProvider>
<Suspense
fallback={
<div className="flex flex-col items-center justify-center gap-3 h-screen bg-[#09090b]">
Expand Down Expand Up @@ -147,7 +147,7 @@ export default function App() {
<ShortcutsConfigDialog />
<ProviderSettingsDialog />
</Suspense>
</ProviderSettingsProvider>
</EditorDialogsProvider>
</ShortcutsProvider>
);
default:
Expand Down
108 changes: 108 additions & 0 deletions src/components/ai-edition/LeftPanel.providerRefresh.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// @vitest-environment jsdom
// Issue #420: the provider dialog's open state moved out of `ChatStripPanel` and into
// EditorDialogsContext, and the `onClose` that used to re-read the LLM snapshot went with it.
// Connecting a provider in that dialog is what enables the composer here and what fills the
// model pill, so the panel now refreshes whenever the dialog is NOT open — on mount, and again
// on every close.
//
// That re-read is the one behaviour the lift had to re-establish by hand rather than move, and
// it cannot be seen from the dialog's own tests (they never mount this panel), so it is pinned
// here: refreshed once on mount, not again when the dialog opens, once more when it closes.

import "@testing-library/jest-dom";
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

const llmGetSnapshot = vi.fn(() =>
Promise.resolve({
config: null,
connectedProviders: [],
availableProviders: [],
credentialSummary: [],
}),
);

vi.mock("@/native/client", () => ({
nativeBridgeClient: {
aiEdition: {
llmGetSnapshot: () => llmGetSnapshot(),
chatListSessions: () => Promise.resolve([]),
chatBudget: () => Promise.resolve(null),
llmListProviderModels: () => Promise.resolve({ models: [] }),
},
},
}));

// The panel's copy is not what is under test, and an echoing translator keeps this file off
// the critical path of a copy edit.
vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({
locale: "en",
setLocale: () => {
/* fixed locale */
},
}),
useScopedT: () => (key: string) => key,
}));

import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { LeftPanel } from "./LeftPanel";

let dialogActions: ReturnType<typeof useEditorDialogActions> | null = null;

/** Hands the test the context's openers, which the app menu and the panel's own gear share. */
function CaptureDialogActions() {
dialogActions = useEditorDialogActions();
return null;
}

beforeEach(() => {
llmGetSnapshot.mockClear();
dialogActions = null;
// The panel subscribes to streamed chat events on mount; there is no preload in jsdom.
(window as unknown as { electronAPI?: unknown }).electronAPI = {
onAiEditionChatEvent: () => () => {
/* unsubscribe */
},
};
// jsdom implements no scrolling at all, and the transcript pins itself to the bottom on
// every render.
Element.prototype.scrollTo = () => {
/* no scrolling in jsdom */
};
});

afterEach(() => {
cleanup();
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
});

describe("ChatStripPanel, against the lifted provider dialog", () => {
it("re-reads the LLM snapshot when the dialog closes, and not when it opens", async () => {
render(
<EditorDialogsProvider>
<CaptureDialogActions />
<LeftPanel active="chat" />
</EditorDialogsProvider>,
);
// Mount: the dialog is closed, so the same effect that watches for a close seeds the
// composer's view of the provider config.
await act(async () => {
await Promise.resolve();
});
expect(llmGetSnapshot).toHaveBeenCalledTimes(1);

// Opening it must not refresh — nothing has been connected yet, and the old code's
// `onClose` did not fire here either.
await act(async () => {
dialogActions?.openDialog("providers");
});
expect(llmGetSnapshot).toHaveBeenCalledTimes(1);

// Closing is the event that used to be `onClose` -> `refreshLlm()`.
await act(async () => {
dialogActions?.closeDialog();
});
expect(llmGetSnapshot).toHaveBeenCalledTimes(2);
});
});
27 changes: 12 additions & 15 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { ArrowLeft, Check, Film, Loader2, MessageSquare, Plus, Search, X } from
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { useEditorDialogActions, useEditorDialogSection } from "@/contexts/EditorDialogsContext";
import { useScopedT } from "@/contexts/I18nContext";
import { useProviderSettings } from "@/contexts/ProviderSettingsContext";
import type { AxcutAsset } from "@/lib/ai-edition/schema";
import {
applyAgentDocumentIfCurrent,
Expand Down Expand Up @@ -738,8 +738,11 @@ function ChatStripPanel() {
const [busy, setBusy] = useState(false);
const [llmConfig, setLlmConfig] = useState<AiEditionLlmConfig | null>(null);
// 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();
// (issue #420); this panel only asks for it to be opened, and watches it close.
const dialogSection = useEditorDialogSection();
const { openDialog } = useEditorDialogActions();
const providerSettingsOpen = dialogSection === "providers";
const openProviderSettings = useCallback(() => openDialog("providers"), [openDialog]);
const [chatsOpen, setChatsOpen] = useState(false);
const [sessions, setSessions] = useState<
Array<{ id: string; title: string; messageCount: number; createdAt: string }>
Expand Down Expand Up @@ -811,19 +814,13 @@ function ChatStripPanel() {
}
}, []);

// On mount, and again every time the provider dialog closes. Connecting a provider there is
// what makes the composer usable here and what fills the model pill, and the dialog no
// longer hangs off this component, so there is no onClose to do it from. "Not open" covers
// both events at once, which is why there is no ref here watching for the falling edge.
useEffect(() => {
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]);
if (!providerSettingsOpen) void refreshLlm();
}, [providerSettingsOpen, refreshLlm]);

// ponytail: subscribe to streamed chat events so the reasoning trace (and
// any future streaming text deltas) lands live instead of arriving all at
Expand Down
25 changes: 24 additions & 1 deletion src/components/ai-edition/Modals.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ function useEscape(open: boolean, onClose: () => void) {
export function ModalShell({
open,
onClose,
closeOnEscape = true,
title,
subtitle,
wide,
Expand All @@ -89,13 +90,35 @@ export function ModalShell({
title: string;
subtitle?: string;
wide?: boolean;
/** Off for a dialog that handles Escape itself — two listeners both fire for one
* keypress, and this one's `onClose` wins whatever order they registered in. */
closeOnEscape?: boolean;
children: ReactNode;
}) {
const tc = useScopedT("common");
useEscape(open, onClose);
const dialogRef = useRef<HTMLDivElement | null>(null);
useEscape(open && closeOnEscape, onClose);
// Move focus into the dialog as it opens, and hand it back to whatever had it when the
// dialog goes. The app menu closes without restoring focus to its trigger — right for a
// pointer user — so otherwise the opener is left on document.body: Tab then walks the
// editor *behind* the backdrop, and a screen reader is never told a dialog appeared.
// Closing has the mirror problem: the focused node is the one being unmounted.
useEffect(() => {
if (!open) return;
// Whatever opened this. Often document.body (the app menu unmounts its own row before
// the dialog mounts), in which case restoring is a no-op; the panel's gear is still
// there, and gets it back.
const opener = document.activeElement;
dialogRef.current?.focus();
return () => {
if (opener instanceof HTMLElement && opener.isConnected) opener.focus();
};
}, [open]);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!open) return null;
return (
<div
ref={dialogRef}
tabIndex={-1}
className={`${styles.modal} ${open ? styles.isOpen : ""}`}
role="dialog"
aria-modal="true"
Expand Down
Loading
Loading