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
4 changes: 4 additions & 0 deletions _locales/en/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -1580,6 +1580,10 @@
"message": "Dictation",
"description": "Tab label for the dictation settings section."
},
"tabVoices": {
"message": "Voices",
"description": "Tab label for the voices settings section (per-site voice catalog)."
},
"tabAbout": {
"message": "About",
"description": "Tab label for the about section."
Expand Down
2 changes: 1 addition & 1 deletion e2e/specs/settings.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { test, expect } from "../fixtures/extension";
* gate — see e2e/README.md).
*/

const TABS = ["general", "chat", "dictation", "about"] as const;
const TABS = ["general", "chat", "dictation", "voices", "about"] as const;

test.describe("settings page (Preact migration)", () => {
test("bootstraps the header and every tab panel without uncaught errors", async ({
Expand Down
1 change: 1 addition & 0 deletions entrypoints/settings/components/tabs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export class TabNavigator {
chat: 'bot-message-square',
general: 'settings',
dictation: 'mic',
voices: 'audio-lines',
about: 'info'
};

Expand Down
5 changes: 5 additions & 0 deletions entrypoints/settings/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<button class="tab-button" data-tab="dictation" data-i18n="tabDictation">
Dictation
</button>
<button class="tab-button" data-tab="voices" data-i18n="tabVoices">Voices</button>
<button class="tab-button" data-tab="about" data-i18n="tabAbout">About</button>
</nav>
<main class="content">
Expand All @@ -32,6 +33,10 @@
<!-- Content loaded by DictationTab controller -->
</section>

<section id="tab-voices" class="tab-panel hidden">
<!-- Content loaded by VoicesTab controller -->
</section>

<section id="tab-about" class="tab-panel hidden">
<!-- Content loaded by AboutTab controller -->
</section>
Expand Down
2 changes: 2 additions & 0 deletions entrypoints/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down
30 changes: 0 additions & 30 deletions entrypoints/settings/tabs/chat/ChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -154,36 +154,6 @@ export function ChatPanel() {
</div>
</div>

{/* 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. */}
<div class="user-preference-item w-full max-w-lg" id="voices-preference">
<span class="label-text" data-i18n="voicesSectionTitle">
Voices
</span>
<div class="description" data-i18n="voicesSectionDescription">
Choose the voice that reads replies aloud on each site.
</div>
<div id="voice-host-pills" role="tablist">
<button
type="button"
id="voice-host-pi"
class="voice-host-pill"
role="tab"
>
Pi
</button>
<button
type="button"
id="voice-host-claude"
class="voice-host-pill"
role="tab"
>
Claude
</button>
</div>
<div id="voice-catalog" aria-live="polite"></div>
</div>
</>
);
}
9 changes: 0 additions & 9 deletions entrypoints/settings/tabs/chat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {}

Expand All @@ -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);
});
}

/**
Expand All @@ -37,7 +29,6 @@ export class ChatTab implements TabController {
*/
destroy(): void {
this.submitModeController = null;
this.voicesController = null;
unmountFrom(this.container);
}

Expand Down
46 changes: 46 additions & 0 deletions entrypoints/settings/tabs/voices/VoicesPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<h2 class="panel-heading" data-i18n="voicesSectionTitle">
Voices
</h2>

<div class="user-preference-item w-full max-w-lg" id="voices-preference">
<div class="description" data-i18n="voicesSectionDescription">
Choose the voice that reads replies aloud on each site.
</div>
<div id="voice-host-pills" role="tablist">
<button
type="button"
id="voice-host-pi"
class="voice-host-pill"
role="tab"
>
Pi
</button>
<button
type="button"
id="voice-host-claude"
class="voice-host-pill"
role="tab"
>
Claude
</button>
</div>
<div id="voice-catalog" aria-live="polite"></div>
</div>
</>
);
}
32 changes: 32 additions & 0 deletions entrypoints/settings/tabs/voices/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
// 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/chatbots/ClaudeVoiceMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -858,7 +858,7 @@ export class ClaudeVoiceMenu extends VoiceSelector {
item.textContent = getMessage("moreVoices");
item.addEventListener("click", () => {
this.toggleMenu();
openSettings("chat");
openSettings("voices");
});
return item;
}
Expand Down
2 changes: 1 addition & 1 deletion src/tts/VoiceMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export abstract class VoiceSelector {
);
door.textContent = getMessage("moreVoices");
door.addEventListener("click", () => {
openSettings("chat");
openSettings("voices");
});
}
const customButtons = voiceSelector.querySelectorAll(
Expand Down
6 changes: 3 additions & 3 deletions test/chatbots/ClaudeVoiceMenu-curation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand Down Expand Up @@ -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");
});
});
6 changes: 3 additions & 3 deletions test/chatbots/PiVoiceMenu-curation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)", () => {
Expand All @@ -117,7 +117,7 @@ describe("PiVoiceMenu shortlist cap + door", () => {
) as HTMLButtonElement;
expect(door).not.toBeNull();
door.click();
expect(openSettingsMock).toHaveBeenCalled();
expect(openSettingsMock).toHaveBeenCalledWith("voices");
});
});

Expand All @@ -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", () => {
Expand Down
14 changes: 3 additions & 11 deletions test/settings/tabs/ChatPanel.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ChatPanel />);
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();
});
});
31 changes: 31 additions & 0 deletions test/settings/tabs/VoicesPanel.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<VoicesPanel />);
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(<VoicesPanel />);
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();
});
});
6 changes: 3 additions & 3 deletions test/settings/tabs/voices-controller.spec.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -28,7 +28,7 @@ function makeDeps(overrides: Partial<Record<string, any>> = {}) {
}

async function mount(deps = makeDeps()) {
const { container } = render(<ChatPanel />);
const { container } = render(<VoicesPanel />);
const controller = new VoicesController(container as HTMLElement, deps as any);
await controller.init();
return { container: container as HTMLElement, controller, deps };
Expand Down
Loading