Skip to content

Commit 5afc8fd

Browse files
committed
feat(editor): rewrite the editor tooltips
Timeline toolbar, transport, top bar, inspector rail, Record mode and chat use the shared tooltip. Shortcuts are chips read from the live bindings, the Record mode cursor row is the Editable cursor, and Clear timeline says what goes and what stays. timeline.json, editor.json and settings.json in all 15 locales: 17 keys added, 13 rewritten, 9 removed.
1 parent a885a39 commit 5afc8fd

69 files changed

Lines changed: 1990 additions & 862 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/components/ai-edition/LeftPanel.copyMessage.test.tsx‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,25 +154,31 @@ async function renderPanelWithAssistantMessage() {
154154
useProjectStore.setState({ projectId: "project-1" });
155155
const { EditorDialogsProvider } = await import("@/contexts/EditorDialogsContext");
156156
const { ChatStripPanel } = await import("./LeftPanel");
157+
const { TooltipProvider } = await import("@/components/ui/tooltip");
157158
const view = render(
158-
<EditorDialogsProvider>
159-
<ChatStripPanel />
160-
</EditorDialogsProvider>,
159+
<TooltipProvider>
160+
<EditorDialogsProvider>
161+
<ChatStripPanel />
162+
</EditorDialogsProvider>
163+
</TooltipProvider>,
161164
);
162165
// Flush the session load so the assistant bubble (and its copy button) is on screen.
163166
await waitFor(() => {
164-
expect(view.getAllByTitle("chat.copyMessage").length).toBeGreaterThan(0);
167+
expect(view.getAllByRole("button", { name: "chat.copyMessage" }).length).toBeGreaterThan(0);
165168
});
166169
return view;
167170
}
168171

172+
const copyButtonsOf = (view: ReturnType<typeof render>) =>
173+
view.getAllByRole("button", { name: "chat.copyMessage" });
174+
169175
describe("ChatStripPanel Copy message (issue #738)", () => {
170176
it("copies the full message through the preload bridge and reports success, never touching the denied navigator clipboard", async () => {
171177
stubClipboard(copyToClipboard);
172178
const view = await renderPanelWithAssistantMessage();
173179

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

196-
const copyButtons = view.getAllByTitle("chat.copyMessage");
202+
const copyButtons = copyButtonsOf(view);
197203
// The fallback inherits Electron's denial in a bare context: the click
198204
// lands on the navigator path and reports the failure toast.
199205
await act(async () => {
@@ -229,7 +235,7 @@ describe("ChatStripPanel Copy message (issue #738)", () => {
229235
// copy button sits last. The handler must not branch on content — an
230236
// empty string crosses to the bridge exactly like any other content and
231237
// still reports success.
232-
const copyButtons = view.getAllByTitle("chat.copyMessage");
238+
const copyButtons = copyButtonsOf(view);
233239
await act(async () => {
234240
fireEvent.click(copyButtons[copyButtons.length - 1]);
235241
await Promise.resolve();

‎src/components/ai-edition/LeftPanel.providerRefresh.test.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ vi.mock("@/contexts/I18nContext", () => ({
4545
useScopedT: () => (key: string) => key,
4646
}));
4747

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

@@ -80,10 +81,12 @@ afterEach(() => {
8081
describe("ChatStripPanel, against the lifted provider dialog", () => {
8182
it("re-reads the LLM snapshot when the dialog closes, and not when it opens", async () => {
8283
render(
83-
<EditorDialogsProvider>
84-
<CaptureDialogActions />
85-
<ChatStripPanel />
86-
</EditorDialogsProvider>,
84+
<TooltipProvider>
85+
<EditorDialogsProvider>
86+
<CaptureDialogActions />
87+
<ChatStripPanel />
88+
</EditorDialogsProvider>
89+
</TooltipProvider>,
8790
);
8891
// Mount: the dialog is closed, so the same effect that watches for a close seeds the
8992
// composer's view of the provider config.
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// @vitest-environment jsdom
2+
// The chat panel's icon-only controls name themselves in the shared tooltip, not in a native
3+
// `title`. Rewind and Compact say what they do, Send shows Enter as a chip, and a button that is
4+
// greyed for now (Send with no provider, Compact with no conversation) still opens its tooltip:
5+
// that is where "Set up a provider" is said, and a natively disabled button takes no pointer
6+
// events, so it uses `aria-disabled`.
7+
8+
import "@testing-library/jest-dom";
9+
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
10+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11+
12+
const snapshot = vi.hoisted(() => ({
13+
value: {
14+
config: null as { provider: string; model: string } | null,
15+
connectedProviders: [] as string[],
16+
availableProviders: [] as unknown[],
17+
credentialSummary: [] as unknown[],
18+
},
19+
}));
20+
const sessions = vi.hoisted(() => ({ list: [] as unknown[] }));
21+
const chatCompact = vi.hoisted(() => vi.fn());
22+
23+
vi.mock("@/native/client", () => ({
24+
nativeBridgeClient: {
25+
aiEdition: {
26+
llmGetSnapshot: () => Promise.resolve(snapshot.value),
27+
chatListSessions: () => Promise.resolve(sessions.list),
28+
chatSelectSession: () =>
29+
Promise.resolve({
30+
id: "session-1",
31+
projectId: "project-1",
32+
title: "Conversation 1",
33+
createdAt: "2026-09-23T00:00:00Z",
34+
updatedAt: "2026-09-23T00:00:00Z",
35+
messages: [
36+
{
37+
id: "m1",
38+
role: "user",
39+
content: "Cut the silences",
40+
checkpointId: "cp1",
41+
createdAt: "2026-09-23T00:00:01Z",
42+
},
43+
{
44+
id: "m2",
45+
role: "assistant",
46+
content: "Done",
47+
createdAt: "2026-09-23T00:00:02Z",
48+
},
49+
],
50+
}),
51+
chatBudget: () => Promise.resolve(null),
52+
chatCompact,
53+
llmListProviderModels: () => Promise.resolve({ models: [] }),
54+
},
55+
},
56+
}));
57+
58+
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn() } }));
59+
60+
// An echoing translator: the assertions read against keys, not prose that drifts with copy edits.
61+
vi.mock("@/contexts/I18nContext", () => ({
62+
useI18n: () => ({ locale: "en", setLocale: vi.fn() }),
63+
useScopedT: () => (key: string) => key,
64+
}));
65+
66+
import { TooltipProvider } from "@/components/ui/tooltip";
67+
import { EditorDialogsProvider } from "@/contexts/EditorDialogsContext";
68+
import { useProjectStore } from "@/lib/ai-edition/store/projectStore";
69+
import { ChatStripPanel } from "./LeftPanel";
70+
71+
class StubResizeObserver {
72+
observe = vi.fn();
73+
unobserve = vi.fn();
74+
disconnect = vi.fn();
75+
}
76+
77+
const oneSession = [
78+
{ id: "session-1", title: "Conversation 1", messageCount: 2, createdAt: "2026-09-23T00:00:00Z" },
79+
];
80+
81+
beforeEach(() => {
82+
vi.stubGlobal("ResizeObserver", StubResizeObserver);
83+
// jsdom implements no scrolling, and the transcript pins itself to the bottom on every render.
84+
Element.prototype.scrollTo = vi.fn();
85+
(window as unknown as { electronAPI?: unknown }).electronAPI = {
86+
onAiEditionChatEvent: () => vi.fn(),
87+
};
88+
useProjectStore.setState({ projectId: "project-1" });
89+
snapshot.value = {
90+
config: null,
91+
connectedProviders: [],
92+
availableProviders: [],
93+
credentialSummary: [],
94+
};
95+
sessions.list = [];
96+
chatCompact.mockClear();
97+
});
98+
99+
afterEach(() => {
100+
cleanup();
101+
vi.unstubAllGlobals();
102+
(window as unknown as { electronAPI?: unknown }).electronAPI = undefined;
103+
});
104+
105+
function renderPanel() {
106+
return render(
107+
<TooltipProvider>
108+
<EditorDialogsProvider>
109+
<ChatStripPanel />
110+
</EditorDialogsProvider>
111+
</TooltipProvider>,
112+
);
113+
}
114+
115+
/** Opens a control's tooltip the way the keyboard does (focus opens it at once) and returns what
116+
* it says and whether it shows a shortcut chip. */
117+
async function tooltipOn(control: HTMLElement) {
118+
act(() => control.focus());
119+
const text = (await screen.findByRole("tooltip")).textContent;
120+
const chip = document.querySelector('[data-slot="tooltip-content"] kbd')?.textContent ?? null;
121+
act(() => control.blur());
122+
await waitFor(() => expect(screen.queryByRole("tooltip")).toBeNull());
123+
return { text, chip };
124+
}
125+
126+
describe("ChatStripPanel tooltips", () => {
127+
it("names the header buttons in the shared tooltip, with no native title", async () => {
128+
sessions.list = oneSession;
129+
renderPanel();
130+
await screen.findByRole("button", { name: "chat.deleteConversation" });
131+
132+
for (const name of [
133+
"chat.compactContext",
134+
"chat.aiSettings",
135+
"chat.history",
136+
"chat.newConversation",
137+
"chat.renameConversation",
138+
"chat.deleteConversation",
139+
]) {
140+
const button = screen.getByRole("button", { name });
141+
expect(button).not.toHaveAttribute("title");
142+
expect((await tooltipOn(button)).text).toBe(name);
143+
}
144+
});
145+
146+
// Plain text has no interactive element to hold a tooltip, so its hint stays native.
147+
it("keeps the native title only on plain text", async () => {
148+
sessions.list = oneSession;
149+
renderPanel();
150+
expect(await screen.findByTitle("chat.clickToRename")).toBeInTheDocument();
151+
});
152+
153+
it("says what Rewind does, not only what the button is called", async () => {
154+
sessions.list = oneSession;
155+
renderPanel();
156+
const rewind = await screen.findByRole("button", { name: "chat.rewindToMessage" });
157+
expect(rewind).not.toHaveAttribute("title");
158+
expect((await tooltipOn(rewind)).text).toBe("chat.rewindTip");
159+
});
160+
161+
it("names Copy in the shared tooltip too", async () => {
162+
sessions.list = oneSession;
163+
renderPanel();
164+
const [copy] = await screen.findAllByRole("button", { name: "chat.copyMessage" });
165+
expect(copy).not.toHaveAttribute("title");
166+
expect((await tooltipOn(copy)).text).toBe("chat.copyMessage");
167+
});
168+
169+
// Nothing to talk to yet: Send is greyed, but it is where the reason is said.
170+
it("says why Send is off when no provider is set up, and shows no chip then", async () => {
171+
renderPanel();
172+
const send = await screen.findByRole("button", { name: "chat.send" });
173+
await waitFor(() => expect(send).toHaveAttribute("aria-disabled", "true"));
174+
expect(send).not.toBeDisabled();
175+
expect(send).not.toHaveAttribute("title");
176+
const tip = await tooltipOn(send);
177+
expect(tip.text).toBe("chat.composerDisabledNoProvider");
178+
expect(tip.chip).toBeNull();
179+
});
180+
181+
it("shows Enter as a chip on Send once it can send, never inside the string", async () => {
182+
snapshot.value = {
183+
config: { provider: "openai", model: "gpt-x" },
184+
connectedProviders: ["openai"],
185+
availableProviders: [],
186+
credentialSummary: [],
187+
};
188+
renderPanel();
189+
fireEvent.change(await screen.findByPlaceholderText("chat.composerPlaceholder"), {
190+
target: { value: "Cut the silences" },
191+
});
192+
const send = screen.getByRole("button", { name: "chat.send" });
193+
await waitFor(() => expect(send).not.toHaveAttribute("aria-disabled"));
194+
const tip = await tooltipOn(send);
195+
expect(tip.text).toBe("chat.sendEnter");
196+
expect(tip.chip).toBe("Enter");
197+
});
198+
199+
it("keeps Compact tooltip-able while there is no conversation, and does nothing", async () => {
200+
renderPanel();
201+
const compact = await screen.findByRole("button", { name: "chat.compactContext" });
202+
expect(compact).toHaveAttribute("aria-disabled", "true");
203+
expect(compact).not.toBeDisabled();
204+
expect((await tooltipOn(compact)).text).toBe("chat.compactContext");
205+
fireEvent.click(compact);
206+
expect(chatCompact).not.toHaveBeenCalled();
207+
});
208+
});

0 commit comments

Comments
 (0)