@@ -32,6 +33,10 @@
+
+
diff --git a/entrypoints/settings/index.ts b/entrypoints/settings/index.ts
index bae6eddaad..0fd04ac27a 100644
--- a/entrypoints/settings/index.ts
+++ b/entrypoints/settings/index.ts
@@ -7,6 +7,7 @@ import { TabNavigator } from "./components/tabs";
import { GeneralTab } from "./tabs/general";
import { ChatTab } from "./tabs/chat";
import { DictationTab } from "./tabs/dictation";
+import { VoicesTab } from "./tabs/voices";
import { AboutTab } from "./tabs/about";
import { replaceI18n } from "./shared/i18n";
import type { TabController } from "./shared/types";
@@ -69,6 +70,7 @@ class SettingsApp {
this.tabs.set('general', new GeneralTab(document.querySelector('#tab-general')!));
this.tabs.set('chat', new ChatTab(document.querySelector('#tab-chat')!));
this.tabs.set('dictation', new DictationTab(document.querySelector('#tab-dictation')!));
+ this.tabs.set('voices', new VoicesTab(document.querySelector('#tab-voices')!));
this.tabs.set('about', new AboutTab(document.querySelector('#tab-about')!));
// Determine which tab to show initially: a one-shot deep link (set by
diff --git a/entrypoints/settings/tabs/chat/ChatPanel.tsx b/entrypoints/settings/tabs/chat/ChatPanel.tsx
index 9955bcda6b..e5535bfe01 100644
--- a/entrypoints/settings/tabs/chat/ChatPanel.tsx
+++ b/entrypoints/settings/tabs/chat/ChatPanel.tsx
@@ -154,36 +154,6 @@ export function ChatPanel() {
- {/* Full voice catalog — the destination of the in-page menus' "More
- voices…" door. Static skeleton only; VoicesController renders the
- per-host catalog into #voice-catalog after mount. */}
-
-
- Voices
-
-
- Choose the voice that reads replies aloud on each site.
-
-
-
-
-
-
-
>
);
}
diff --git a/entrypoints/settings/tabs/chat/index.ts b/entrypoints/settings/tabs/chat/index.ts
index a34908e99c..beb5bc261b 100644
--- a/entrypoints/settings/tabs/chat/index.ts
+++ b/entrypoints/settings/tabs/chat/index.ts
@@ -3,14 +3,11 @@ import { TabController } from '../../shared/types';
import { getStoredValue, setStoredValue } from '../../shared/storage';
import { sendMessageToActiveTab } from '../../shared/messaging';
import { SubmitModeController } from './submit-mode-controller';
-import { VoicesController } from './voices-controller';
import { mountInto, unmountFrom } from '../../../../src/ui/preact/mount';
import { ChatPanel } from './ChatPanel';
-import './chat.css';
export class ChatTab implements TabController {
private submitModeController: SubmitModeController | null = null;
- private voicesController: VoicesController | null = null;
constructor(public container: HTMLElement) {}
@@ -24,11 +21,6 @@ export class ChatTab implements TabController {
await this.setupInterruptions();
await this.setupAutoReadAloud();
await this.setupSubmitMode();
- // Voice catalog loads over the network — don't block tab init on it.
- this.voicesController = new VoicesController(this.container);
- this.voicesController.init().catch((error) => {
- console.error('Failed to load the voice catalog:', error);
- });
}
/**
@@ -37,7 +29,6 @@ export class ChatTab implements TabController {
*/
destroy(): void {
this.submitModeController = null;
- this.voicesController = null;
unmountFrom(this.container);
}
diff --git a/entrypoints/settings/tabs/voices/VoicesPanel.tsx b/entrypoints/settings/tabs/voices/VoicesPanel.tsx
new file mode 100644
index 0000000000..2c8488ed5f
--- /dev/null
+++ b/entrypoints/settings/tabs/voices/VoicesPanel.tsx
@@ -0,0 +1,46 @@
+/**
+ * Voices-tab panel (Preact).
+ *
+ * Static skeleton only — VoicesController renders the per-host catalog into
+ * #voice-catalog after mount and wires the host pills by id, so the ids and
+ * classes here (`#voices-preference`, `#voice-host-pills`, `#voice-host-pi`,
+ * `#voice-host-claude`, `.voice-host-pill`, `#voice-catalog`) are load-bearing.
+ *
+ * This tab sits alongside Dictation deliberately: Dictation is voice-in,
+ * Voices is voice-out (#471). It is also the destination of the in-page voice
+ * menus' "More voices…" door (openSettings("voices")).
+ */
+export function VoicesPanel() {
+ return (
+ <>
+
+ Voices
+
+
+
+
+ Choose the voice that reads replies aloud on each site.
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/entrypoints/settings/tabs/voices/index.ts b/entrypoints/settings/tabs/voices/index.ts
new file mode 100644
index 0000000000..bba13fec95
--- /dev/null
+++ b/entrypoints/settings/tabs/voices/index.ts
@@ -0,0 +1,32 @@
+import { h } from 'preact';
+import { TabController } from '../../shared/types';
+import { VoicesController } from './voices-controller';
+import { mountInto, unmountFrom } from '../../../../src/ui/preact/mount';
+import { VoicesPanel } from './VoicesPanel';
+import './voices.css';
+
+export class VoicesTab implements TabController {
+ private voicesController: VoicesController | null = null;
+
+ constructor(public container: HTMLElement) {}
+
+ async init(): Promise {
+ // Render the panel with Preact, then let VoicesController fill the
+ // catalog — it loads over the network, so don't block tab init on it.
+ mountInto(this.container, h(VoicesPanel, {}));
+
+ this.voicesController = new VoicesController(this.container);
+ this.voicesController.init().catch((error) => {
+ console.error('Failed to load the voice catalog:', error);
+ });
+ }
+
+ /**
+ * Clean up when the tab is destroyed. Defensive — the orchestrator keeps
+ * tabs mounted (init runs once), but unmount the Preact tree if ever called.
+ */
+ destroy(): void {
+ this.voicesController = null;
+ unmountFrom(this.container);
+ }
+}
diff --git a/entrypoints/settings/tabs/chat/voices-controller.ts b/entrypoints/settings/tabs/voices/voices-controller.ts
similarity index 99%
rename from entrypoints/settings/tabs/chat/voices-controller.ts
rename to entrypoints/settings/tabs/voices/voices-controller.ts
index a50fc9ceaf..f38ee2825e 100644
--- a/entrypoints/settings/tabs/chat/voices-controller.ts
+++ b/entrypoints/settings/tabs/voices/voices-controller.ts
@@ -33,7 +33,7 @@ function defaultDeps(): VoiceCatalogDeps {
}
/**
- * Renders the full per-host voice catalog in the settings AI Chat tab — the
+ * Renders the full per-host voice catalog in the settings Voices tab — the
* "shelf" layer of the voice-selection architecture: HD and Everyday groups,
* every catalog voice listed, selection per host. The in-page menus stay
* capped; this surface absorbs the catalog's growth.
diff --git a/entrypoints/settings/tabs/chat/chat.css b/entrypoints/settings/tabs/voices/voices.css
similarity index 93%
rename from entrypoints/settings/tabs/chat/chat.css
rename to entrypoints/settings/tabs/voices/voices.css
index 43dcb1701d..863d4100d5 100644
--- a/entrypoints/settings/tabs/chat/chat.css
+++ b/entrypoints/settings/tabs/voices/voices.css
@@ -1,6 +1,4 @@
-/* Chat tab specific styles */
-
-/* Voices section — the full per-host catalog behind the in-page menus'
+/* Voices tab — the full per-host catalog behind the in-page menus'
"More voices…" door */
#voice-host-pills {
display: flex;
diff --git a/src/chatbots/ClaudeVoiceMenu.ts b/src/chatbots/ClaudeVoiceMenu.ts
index ddd351ae27..2187f7b541 100644
--- a/src/chatbots/ClaudeVoiceMenu.ts
+++ b/src/chatbots/ClaudeVoiceMenu.ts
@@ -837,7 +837,7 @@ export class ClaudeVoiceMenu extends VoiceSelector {
/**
* The muted final row linking to the full voice catalog in the extension
- * settings (AI Chat tab). Always rendered — it is the menu's path to the
+ * settings (Voices tab). Always rendered — it is the menu's path to the
* catalog, whether or not the shortlist is hiding voices.
*/
private createMoreVoicesItem(): HTMLDivElement {
@@ -858,7 +858,7 @@ export class ClaudeVoiceMenu extends VoiceSelector {
item.textContent = getMessage("moreVoices");
item.addEventListener("click", () => {
this.toggleMenu();
- openSettings("chat");
+ openSettings("voices");
});
return item;
}
diff --git a/src/tts/VoiceMenu.ts b/src/tts/VoiceMenu.ts
index 6978597d1f..3467ca30e9 100644
--- a/src/tts/VoiceMenu.ts
+++ b/src/tts/VoiceMenu.ts
@@ -355,7 +355,7 @@ export abstract class VoiceSelector {
);
door.textContent = getMessage("moreVoices");
door.addEventListener("click", () => {
- openSettings("chat");
+ openSettings("voices");
});
}
const customButtons = voiceSelector.querySelectorAll(
diff --git a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts
index 4e6688f1c4..53ce8a9c05 100644
--- a/test/chatbots/ClaudeVoiceMenu-curation.spec.ts
+++ b/test/chatbots/ClaudeVoiceMenu-curation.spec.ts
@@ -93,7 +93,7 @@ describe("ClaudeVoiceMenu shortlist cap + door (flip-day catalog)", () => {
"[data-action='more-voices']"
) as HTMLElement;
door.click();
- expect(openSettingsMock).toHaveBeenCalled();
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
it("keeps the door row even when the whole catalog fits the cap (#472)", () => {
@@ -198,13 +198,13 @@ describe("ClaudeVoiceMenu current-voice pinning", () => {
expect(rows[0].dataset.voiceName).toBe("Lucy");
});
- it("passes the AI Chat tab as the door's settings destination", () => {
+ it("passes the Voices tab as the door's settings destination (#471)", () => {
const menu = makeMenu();
menu.populateVoices(flipDayCatalog, menu.element);
const door = menu.menuContent.querySelector(
"[data-action='more-voices']"
) as HTMLElement;
door.click();
- expect(openSettingsMock).toHaveBeenCalledWith("chat");
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
});
diff --git a/test/chatbots/PiVoiceMenu-curation.spec.ts b/test/chatbots/PiVoiceMenu-curation.spec.ts
index cbb287b286..0c064b057b 100644
--- a/test/chatbots/PiVoiceMenu-curation.spec.ts
+++ b/test/chatbots/PiVoiceMenu-curation.spec.ts
@@ -104,7 +104,7 @@ describe("PiVoiceMenu shortlist cap + door", () => {
) as HTMLButtonElement;
expect(door).not.toBeNull();
door.click();
- expect(openSettingsMock).toHaveBeenCalled();
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
it("still shows the door when the catalog fits the cap — it's the path to the catalog, not an overflow marker (#472)", () => {
@@ -117,7 +117,7 @@ describe("PiVoiceMenu shortlist cap + door", () => {
) as HTMLButtonElement;
expect(door).not.toBeNull();
door.click();
- expect(openSettingsMock).toHaveBeenCalled();
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
});
@@ -144,7 +144,7 @@ describe("PiVoiceSettings door (#472)", () => {
) as HTMLButtonElement;
expect(door).not.toBeNull();
door.click();
- expect(openSettingsMock).toHaveBeenCalled();
+ expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
it("does not duplicate the door on repeated populates", () => {
diff --git a/test/settings/tabs/ChatPanel.spec.tsx b/test/settings/tabs/ChatPanel.spec.tsx
index 00331df46c..51956ad4a9 100644
--- a/test/settings/tabs/ChatPanel.spec.tsx
+++ b/test/settings/tabs/ChatPanel.spec.tsx
@@ -51,17 +51,9 @@ describe("ChatPanel", () => {
).toBeTruthy();
});
- it("renders the Voices section with host pills and the catalog container VoicesController targets", () => {
+ it("no longer hosts the Voices section — it moved to its own tab (#471)", () => {
const { container } = render();
- expect(container.querySelector("#voices-preference")).toBeTruthy();
- expect(
- container
- .querySelector("#voices-preference [data-i18n='voicesSectionTitle']"),
- ).toBeTruthy();
- const pills = [
- ...container.querySelectorAll("#voice-host-pills button.voice-host-pill"),
- ];
- expect(pills.map((p) => p.id)).toEqual(["voice-host-pi", "voice-host-claude"]);
- expect(container.querySelector("#voice-catalog")).toBeTruthy();
+ expect(container.querySelector("#voices-preference")).toBeNull();
+ expect(container.querySelector("#voice-catalog")).toBeNull();
});
});
diff --git a/test/settings/tabs/VoicesPanel.spec.tsx b/test/settings/tabs/VoicesPanel.spec.tsx
new file mode 100644
index 0000000000..3da23934c3
--- /dev/null
+++ b/test/settings/tabs/VoicesPanel.spec.tsx
@@ -0,0 +1,31 @@
+import { describe, it, expect, afterEach } from "vitest";
+import { render, cleanup } from "@testing-library/preact";
+import { VoicesPanel } from "../../../entrypoints/settings/tabs/voices/VoicesPanel";
+
+afterEach(() => cleanup());
+
+describe("VoicesPanel", () => {
+ it("renders the heading and description with their i18n keys", () => {
+ const { container } = render();
+ expect(
+ container.querySelector(".panel-heading[data-i18n='voicesSectionTitle']"),
+ ).toBeTruthy();
+ expect(
+ container.querySelector(
+ "#voices-preference [data-i18n='voicesSectionDescription']",
+ ),
+ ).toBeTruthy();
+ });
+
+ it("renders the host pills and catalog container VoicesController targets", () => {
+ const { container } = render();
+ const pills = [
+ ...container.querySelectorAll("#voice-host-pills button.voice-host-pill"),
+ ];
+ expect(pills.map((p) => p.id)).toEqual([
+ "voice-host-pi",
+ "voice-host-claude",
+ ]);
+ expect(container.querySelector("#voice-catalog")).toBeTruthy();
+ });
+});
diff --git a/test/settings/tabs/voices-controller.spec.tsx b/test/settings/tabs/voices-controller.spec.tsx
index 4399b5822b..b74e9255f6 100644
--- a/test/settings/tabs/voices-controller.spec.tsx
+++ b/test/settings/tabs/voices-controller.spec.tsx
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, cleanup } from "@testing-library/preact";
-import { ChatPanel } from "../../../entrypoints/settings/tabs/chat/ChatPanel";
-import { VoicesController } from "../../../entrypoints/settings/tabs/chat/voices-controller";
+import { VoicesPanel } from "../../../entrypoints/settings/tabs/voices/VoicesPanel";
+import { VoicesController } from "../../../entrypoints/settings/tabs/voices/voices-controller";
import {
ElevenLabsVoice,
claudeMockVoices,
@@ -28,7 +28,7 @@ function makeDeps(overrides: Partial> = {}) {
}
async function mount(deps = makeDeps()) {
- const { container } = render();
+ const { container } = render();
const controller = new VoicesController(container as HTMLElement, deps as any);
await controller.init();
return { container: container as HTMLElement, controller, deps };