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
148 changes: 94 additions & 54 deletions src/chatbots/PiVoiceMenu.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Observation } from "../dom/Observation";
import EventBus from "../events/EventBus";
import getMessage from "../i18n";
import { openSettings } from "../popup/popupopener";
import { audioProviders } from "../tts/SpeechModel";
import { GridVoiceSelector } from "../tts/GridVoiceSelector";
import { PI_MENU_CAP } from "../tts/VoiceCuration";
Expand All @@ -16,8 +18,7 @@ export class PiVoiceMenu extends GridVoiceSelector {

this.addIdVoiceMenu(element);
this.restyleVoiceMenuControls(element);
this.addVoiceMenuExpansionListener();
this.addVoiceButtonAdditionListener(element);
this.observeForMoreVoicesDoor();
}

getId(): string {
Expand Down Expand Up @@ -103,64 +104,103 @@ export class PiVoiceMenu extends GridVoiceSelector {
return Observation.foundAndDecorated(obs);
}

addVoiceMenuExpansionListener(): Observation {
/**
* Keep a "More voices" door present in Pi's live in-chat voice menu (#491,
* door-first). Pi redesigned the menu into a popover it LAZILY BUILDS on
* expand and tears down on collapse (verified live 2026-07-04, Layer-4 CDP):
* collapsed there is only a pill; expanded, Pi swaps in a `rounded-xl` card
* holding the voice list (`div.flex.flex-col.gap-1` of `div.cursor-pointer`
* rows — no longer `<button>`s). The old firstChild-`<button>` expansion
* heuristic watched the wrong depth (a direct-child childList) and matched a
* shape Pi no longer produces, so it never fired and the SayPi block/door
* stayed dark. We instead observe the audio-controls SUBTREE and, whenever
* the menu is open, ensure the door is the last row of Pi's list. Inline
* SayPi voice rows are deferred; the door reaches the full catalog + ▶
* previews in the extension's Voices settings.
*/
observeForMoreVoicesDoor(): Observation {
const className = "saypi-audio-controls";
const audioControlsContainer = document.querySelector("." + className) as HTMLElement;
const voiceMenu = document.getElementById(this.getId());

if (!audioControlsContainer || !voiceMenu) {
const audioControls = document.querySelector(
"." + className
) as HTMLElement | null;
if (!audioControls) {
return Observation.notFound(className);
}
let foundAudioCtrls = Observation.foundUndecorated(
className,
audioControlsContainer
// The menu may already be open when we attach (re-decoration mid-open).
this.ensureMoreVoicesDoor(audioControls);
const observer = new MutationObserver(() =>
this.ensureMoreVoicesDoor(audioControls)
);
// Pi swaps the pill↔card DEEP in the subtree, not as a direct child of the
// audio-controls container, so the old direct-child childList observer
// never saw it — subtree is required.
observer.observe(audioControls, { childList: true, subtree: true });
return Observation.foundAndDecorated(
Observation.foundUndecorated(className, audioControls)
);
}

const observerCallback = async (
mutationsList: MutationRecord[],
observer: MutationObserver
) => {
for (let mutation of mutationsList) {
if (mutation.type === "childList") {
for (let node of mutation.addedNodes) {
// the addition of a button with an aria-label giving instructions to "close the menu", indicates the voice menu is expanded
if (
node instanceof HTMLElement &&
node.nodeName === "BUTTON" &&
node.getAttribute("aria-label") &&
node === audioControlsContainer.firstChild
) {
voiceMenu.classList.add("expanded");
// Pi recreates its native menu on every expand: re-adopt the
// fresh host rows and re-render SayPi's block from state. When
// TTS is off we still adopt + top up Pi's extras (and the
// door), just with no SayPi catalog rows.
this.userPreferences.getTextToSpeechEnabled().then((enabled) => {
if (enabled) {
this.refreshMenu();
} else {
this.renderMenu([], null);
}
});
}
}
for (let node of mutation.removedNodes) {
if (
node instanceof HTMLElement &&
node.nodeName === "BUTTON" &&
node.getAttribute("aria-label")
) {
voiceMenu.classList.remove("expanded");
return;
}
}
}
}
};
/**
* Inject the door into Pi's live voice list when the menu is open and it is
* not already there. Idempotent (guarded on `.saypi-more-voices`), so it is
* safe to call on every subtree mutation and re-injects after Pi rebuilds the
* list on the next expand.
*/
ensureMoreVoicesDoor(audioControls: HTMLElement): void {
const list = this.findLiveVoiceList(audioControls);
if (!list) return; // collapsed / no list to host the door
if (list.querySelector(".saypi-more-voices")) return; // already present
list.appendChild(this.createLiveMenuDoor(list));
}

const observer = new MutationObserver(observerCallback);
observer.observe(audioControlsContainer, { childList: true });
return Observation.foundAndDecorated(foundAudioCtrls); // Assuming listener doesn't require further checks
/**
* Locate Pi's live voice-list container. Anchors on the stable "Toggle voice
* menu" aria-label (present in both states) → the expanded `rounded-xl` card
* (the collapsed pill is `rounded-[100px]`, so this is null when collapsed) →
* the row list. Positional class-literal chains would be brittle against Pi's
* churn; these three anchors are the load-bearing, most-stable ones.
*/
private findLiveVoiceList(audioControls: HTMLElement): HTMLElement | null {
const toggle = audioControls.querySelector(
'button[aria-label="Toggle voice menu"]'
);
if (!toggle) return null;
const card = toggle.closest(".rounded-xl");
if (!card) return null; // collapsed — the pill, not the card
return card.querySelector<HTMLElement>(".flex.flex-col.gap-1");
}

/**
* Build the door as a Pi-native-looking row by CLONING a native row's class
* list (Pi's own utilities are already compiled, so its arbitrary classes —
* `min-h-11`, `!bg-secondary-default` — apply to the clone; SayPi can't author
* those itself, per the host-injected-arbitrary-Tailwind constraint). Pi's
* rows are role-less `<div>` clickables, so we match with a keyboard-operable
* div (marginally better a11y than Pi's own rows).
*/
private createLiveMenuDoor(list: HTMLElement): HTMLElement {
const template = list.firstElementChild as HTMLElement | null;
const door = document.createElement("div");
if (template) door.className = template.className;
door.classList.add("saypi-more-voices");
door.setAttribute("role", "button");
door.tabIndex = 0;

const label = document.createElement("span");
const templateSpan = template?.querySelector("span");
if (templateSpan) label.className = templateSpan.className;
label.textContent = getMessage("moreVoices");
door.appendChild(label);

const open = () => openSettings("voices");
door.addEventListener("click", open);
door.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
open();
}
});
return door;
}
}

Expand Down
Loading
Loading