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
20 changes: 13 additions & 7 deletions src/components/ai-edition/LeftPanel.copyMessage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,25 +154,31 @@ async function renderPanelWithAssistantMessage() {
useProjectStore.setState({ projectId: "project-1" });
const { EditorDialogsProvider } = await import("@/contexts/EditorDialogsContext");
const { ChatStripPanel } = await import("./LeftPanel");
const { TooltipProvider } = await import("@/components/ui/tooltip");
const view = render(
<EditorDialogsProvider>
<ChatStripPanel />
</EditorDialogsProvider>,
<TooltipProvider>
<EditorDialogsProvider>
<ChatStripPanel />
</EditorDialogsProvider>
</TooltipProvider>,
);
// Flush the session load so the assistant bubble (and its copy button) is on screen.
await waitFor(() => {
expect(view.getAllByTitle("chat.copyMessage").length).toBeGreaterThan(0);
expect(view.getAllByRole("button", { name: "chat.copyMessage" }).length).toBeGreaterThan(0);
});
return view;
}

const copyButtonsOf = (view: ReturnType<typeof render>) =>
view.getAllByRole("button", { name: "chat.copyMessage" });

describe("ChatStripPanel Copy message (issue #738)", () => {
it("copies the full message through the preload bridge and reports success, never touching the denied navigator clipboard", async () => {
stubClipboard(copyToClipboard);
const view = await renderPanelWithAssistantMessage();

// Two copy buttons (user + assistant); the assistant message is the last one rendered.
const copyButtons = view.getAllByTitle("chat.copyMessage");
const copyButtons = copyButtonsOf(view);
// user + assistant + the empty trailing assistant turn.
expect(copyButtons).toHaveLength(3);
await act(async () => {
Expand All @@ -193,7 +199,7 @@ describe("ChatStripPanel Copy message (issue #738)", () => {
stubClipboard(undefined);
const view = await renderPanelWithAssistantMessage();

const copyButtons = view.getAllByTitle("chat.copyMessage");
const copyButtons = copyButtonsOf(view);
// The fallback inherits Electron's denial in a bare context: the click
// lands on the navigator path and reports the failure toast.
await act(async () => {
Expand Down Expand Up @@ -229,7 +235,7 @@ describe("ChatStripPanel Copy message (issue #738)", () => {
// copy button sits last. The handler must not branch on content — an
// empty string crosses to the bridge exactly like any other content and
// still reports success.
const copyButtons = view.getAllByTitle("chat.copyMessage");
const copyButtons = copyButtonsOf(view);
await act(async () => {
fireEvent.click(copyButtons[copyButtons.length - 1]);
await Promise.resolve();
Expand Down
11 changes: 7 additions & 4 deletions src/components/ai-edition/LeftPanel.providerRefresh.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ vi.mock("@/contexts/I18nContext", () => ({
useScopedT: () => (key: string) => key,
}));

import { TooltipProvider } from "@/components/ui/tooltip";
import { EditorDialogsProvider, useEditorDialogActions } from "@/contexts/EditorDialogsContext";
import { ChatStripPanel } from "./LeftPanel";

Expand Down Expand Up @@ -80,10 +81,12 @@ afterEach(() => {
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 />
<ChatStripPanel />
</EditorDialogsProvider>,
<TooltipProvider>
<EditorDialogsProvider>
<CaptureDialogActions />
<ChatStripPanel />
</EditorDialogsProvider>
</TooltipProvider>,
);
// Mount: the dialog is closed, so the same effect that watches for a close seeds the
// composer's view of the provider config.
Expand Down
220 changes: 220 additions & 0 deletions src/components/ai-edition/LeftPanel.tooltips.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
// @vitest-environment jsdom
// The chat panel's icon-only controls name themselves in the shared tooltip, not in a native
// `title`. Rewind and Compact say what they do, Send shows Enter as a chip, and a button that is
// greyed for now (Send with no provider, Compact with no conversation) still opens its tooltip:
// that is where "Set up a provider" is said, and a natively disabled button takes no pointer
// events, so it uses `aria-disabled`.

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

const snapshot = vi.hoisted(() => ({
value: {
config: null as { provider: string; model: string } | null,
connectedProviders: [] as string[],
availableProviders: [] as unknown[],
credentialSummary: [] as unknown[],
},
}));
const sessions = vi.hoisted(() => ({ list: [] as unknown[] }));
const chatCompact = vi.hoisted(() => vi.fn());

vi.mock("@/native/client", () => ({
nativeBridgeClient: {
aiEdition: {
llmGetSnapshot: () => Promise.resolve(snapshot.value),
chatListSessions: () => Promise.resolve(sessions.list),
chatSelectSession: () =>
Promise.resolve({
id: "session-1",
projectId: "project-1",
title: "Conversation 1",
createdAt: "2026-09-23T00:00:00Z",
updatedAt: "2026-09-23T00:00:00Z",
messages: [
{
id: "m1",
role: "user",
content: "Cut the silences",
checkpointId: "cp1",
createdAt: "2026-09-23T00:00:01Z",
},
{
id: "m2",
role: "assistant",
content: "Done",
createdAt: "2026-09-23T00:00:02Z",
},
],
}),
chatBudget: () => Promise.resolve(null),
chatCompact,
llmListProviderModels: () => Promise.resolve({ models: [] }),
},
},
}));

vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));

// An echoing translator: the assertions read against keys, not prose that drifts with copy edits.
vi.mock("@/contexts/I18nContext", () => ({
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
useScopedT: () => (key: string) => key,
}));

import { TooltipProvider } from "@/components/ui/tooltip";
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
import { ChatStripPanel } from "./LeftPanel";

class StubResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}

const oneSession = [
{ id: "session-1", title: "Conversation 1", messageCount: 2, createdAt: "2026-09-23T00:00:00Z" },
];

beforeEach(() => {
vi.stubGlobal("ResizeObserver", StubResizeObserver);
// jsdom implements no scrolling, and the transcript pins itself to the bottom on every render.
Element.prototype.scrollTo = vi.fn();
(window as unknown as { electronAPI?: unknown }).electronAPI = {
onAiEditionChatEvent: () => vi.fn(),
};
useProjectStore.setState({ projectId: "project-1" });
snapshot.value = {
config: null,
connectedProviders: [],
availableProviders: [],
credentialSummary: [],
};
sessions.list = [];
chatCompact.mockClear();
});

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

function renderPanel() {
return render(
<TooltipProvider>
<EditorDialogsProvider>
<ChatStripPanel />
</EditorDialogsProvider>
</TooltipProvider>,
);
}

/** Opens a control's tooltip the way the keyboard does (focus opens it at once) and returns what
* it says and whether it shows a shortcut chip. */
async function tooltipOn(control: HTMLElement) {
act(() => control.focus());
const text = (await screen.findByRole("tooltip")).textContent;
const chip = document.querySelector('[data-slot="tooltip-content"] kbd')?.textContent ?? null;
act(() => control.blur());
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
return { text, chip };
}

describe("ChatStripPanel tooltips", () => {
it("names the header buttons in the shared tooltip, with no native title", async () => {
sessions.list = oneSession;
renderPanel();
await screen.findByRole("button", { name: "chat.deleteConversation" });

for (const name of [
"chat.compactContext",
"chat.aiSettings",
"chat.history",
"chat.newConversation",
"chat.renameConversation",
"chat.deleteConversation",
]) {
const button = screen.getByRole("button", { name });
expect(button).not.toHaveAttribute("title");
expect((await tooltipOn(button)).text).toBe(name);
}
});

// Plain text has no interactive element to hold a tooltip, so its hint stays native.
it("keeps the native title only on plain text", async () => {
sessions.list = oneSession;
renderPanel();
expect(await screen.findByTitle("chat.clickToRename")).toBeInTheDocument();
});

it("says what Rewind does, not only what the button is called", async () => {
sessions.list = oneSession;
renderPanel();
const rewind = await screen.findByRole("button", { name: "chat.rewindToMessage" });
expect(rewind).not.toHaveAttribute("title");
expect((await tooltipOn(rewind)).text).toBe("chat.rewindTip");
});

it("names Copy in the shared tooltip too", async () => {
sessions.list = oneSession;
renderPanel();
const [copy] = await screen.findAllByRole("button", { name: "chat.copyMessage" });
expect(copy).not.toHaveAttribute("title");
expect((await tooltipOn(copy)).text).toBe("chat.copyMessage");
});

// Nothing to talk to yet: Send is greyed, but it is where the reason is said.
it("says why Send is off when no provider is set up, and shows no chip then", async () => {
renderPanel();
const send = await screen.findByRole("button", { name: "chat.send" });
await waitFor(() => expect(send).toHaveAttribute("aria-disabled", "true"));
expect(send).not.toBeDisabled();
expect(send).not.toHaveAttribute("title");
const tip = await tooltipOn(send);
expect(tip.text).toBe("chat.composerDisabledNoProvider");
expect(tip.chip).toBeNull();
});

it("shows Enter as a chip on Send once it can send, never inside the string", async () => {
snapshot.value = {
config: { provider: "openai", model: "gpt-x" },
connectedProviders: ["openai"],
availableProviders: [],
credentialSummary: [],
};
renderPanel();
fireEvent.change(await screen.findByPlaceholderText("chat.composerPlaceholder"), {
target: { value: "Cut the silences" },
});
const send = screen.getByRole("button", { name: "chat.send" });
await waitFor(() => expect(send).not.toHaveAttribute("aria-disabled"));
const tip = await tooltipOn(send);
expect(tip.text).toBe("chat.sendEnter");
expect(tip.chip).toBe("Enter");
});

// While the title is being edited the button is greyed, but it stays hoverable and does nothing.
it("keeps Rename tooltip-able while the title is being edited", async () => {
sessions.list = oneSession;
renderPanel();
const rename = await screen.findByRole("button", { name: "chat.renameConversation" });
fireEvent.click(rename);
await screen.findByDisplayValue("Conversation 1");
expect(rename).toHaveAttribute("aria-disabled", "true");
expect(rename).not.toBeDisabled();
expect((await tooltipOn(rename)).text).toBe("chat.renameConversation");
});

it("keeps Compact tooltip-able while there is no conversation, and does nothing", async () => {
renderPanel();
const compact = await screen.findByRole("button", { name: "chat.compactContext" });
expect(compact).toHaveAttribute("aria-disabled", "true");
expect(compact).not.toBeDisabled();
expect((await tooltipOn(compact)).text).toBe("chat.compactContext");
fireEvent.click(compact);
expect(chatCompact).not.toHaveBeenCalled();
});
});
Loading
Loading