refactor(web): extract sound, Today count and browser notification hooks

Behaviour-preserving move of ShellPage side effects into named hooks:

- useShellSounds: audio ducking, the app-start sound and the sounds for
  bots that need the user, finished work, run errors, handoffs and group
  sparks.
- useHeuteActionCount: the 15-second Today brief poll behind the sidebar
  badge.
- useBrowserNotifications: pending and delivered browser notification
  bookkeeping plus notifyBrowserForEvent / flushPendingBrowserNotifications.

The hooks run where the code was. The Today poll effect sat between the
app-start sound and the bot status sound; it now runs just before the sound
hook, ahead of the two mount-only audio effects it does not interact with.
Effect bodies, refs and dependency arrays move verbatim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-04 10:01:09 +00:00
parent 307c095bb6
commit a1bf392298
No known key found for this signature in database
4 changed files with 245 additions and 192 deletions

View File

@ -18,7 +18,6 @@ import type {
Group,
MainConversationRef,
Me,
ProductEvent,
Routine,
Space,
SpaceMemoryConfig,
@ -141,11 +140,7 @@ import {
readBotsSidebarCollapsed,
writeBotsSidebarCollapsed,
} from "../lib/bots-sidebar-pref";
import {
deliverBrowserNotification as deliverNativeBrowserNotification,
requestBrowserNotificationPermission,
shouldNotifyBrowser,
} from "../lib/browser-notifications";
import { requestBrowserNotificationPermission } from "../lib/browser-notifications";
import { loadComputerScreen } from "../lib/computer-screen";
import { desktopBridge } from "../lib/desktop";
import { scheduleFocusPrompt } from "../lib/focus-prompt";
@ -155,14 +150,11 @@ import {
resolveActiveGroupSpeaker,
resolveWorkingGroupBots,
} from "../lib/group-stage";
import { buildHeuteBrief } from "../lib/heute-brief";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { localTimezone } from "../lib/local-timezone";
import { localizedPreview } from "../lib/onboarding-copy";
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
import { ooSound } from "../lib/oo-sound";
import { ooVocals } from "../lib/oo-vocals";
import { ooVoiceMark } from "../lib/oo-voice-mark";
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
import { markAfterPaint, markOnce } from "../lib/performance";
import { isEmptyProjectSpace } from "../lib/project-empty-state";
@ -248,6 +240,9 @@ import {
threadSnapshotSignal,
} from "./shell/thread-state";
import { Transcript } from "./shell/transcript";
import { useBrowserNotifications } from "./shell/use-browser-notifications";
import { useHeuteActionCount } from "./shell/use-heute-action-count";
import { useShellSounds } from "./shell/use-shell-sounds";
import { useSidebarModel } from "./shell/use-sidebar-model";
import { useSpaceSearch } from "./shell/use-space-search";
import { WindowChrome } from "./WindowChrome";
@ -287,13 +282,6 @@ type Panel =
| "artifact"
| null;
type PendingBrowserNotification = {
event: Pick<ProductEvent, "id" | "type" | "threadId" | "botId" | "payload">;
botId: string;
botName: string;
groupNotification: boolean;
};
/** Bound Settings leave so a hung voice status refresh cannot block dismissal. */
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
@ -380,7 +368,6 @@ export function ShellPage() {
"rakazo:workspace-pane-width",
);
const [heuteActionCount, setHeuteActionCount] = useState(0);
const [archivedOpen, setArchivedOpen] = useState(false);
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
@ -699,8 +686,6 @@ export function ShellPage() {
} | null>(null);
const manuallyUnread = useRef(new Set<string>());
const readVisibleGroups = useRef(new Set<string>());
const notifiedBrowserEvents = useRef(new Set<string>());
const pendingBrowserNotifications = useRef(new Map<string, PendingBrowserNotification>());
const computerVisible = useRef(false);
computerVisible.current = panel === "computer" || computerOpen;
const autoSpoken = useRef<string | null>(null);
@ -787,71 +772,8 @@ export function ShellPage() {
},
[markBotRead],
);
const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => {
const currentBot = botsRef.current.find((bot) => bot.id === pending.botId);
if (!currentBot || typeof Notification === "undefined") return true;
const result = deliverNativeBrowserNotification(
pending.event,
currentBot.name || pending.botName,
{
enabled: pending.groupNotification || currentBot.notifyOnFinish,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: Notification.permission,
notifiedEventIds: notifiedBrowserEvents.current,
show: (title, body, tag) => new Notification(title, { body, tag }),
},
);
return result !== "pending";
}, []);
const flushPendingBrowserNotifications = useCallback(() => {
for (const [threadId, pending] of pendingBrowserNotifications.current) {
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(threadId);
}
}
}, [deliverBrowserNotification]);
const notifyBrowserForEvent = useCallback(
(
event: Pick<ProductEvent, "id" | "type" | "threadId" | "seq" | "botId" | "payload">,
subscribedThreadId: string | undefined,
initialCursor: number,
streamReady: boolean,
botName: string,
enabled: boolean,
groupNotification: boolean,
) => {
const botId = event.botId;
if (typeof botId !== "string") return;
const eligible = shouldNotifyBrowser(event, {
subscribedThreadId: subscribedThreadId ?? "",
initialCursor,
streamReady,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: "granted",
notifiedEventIds: notifiedBrowserEvents.current,
});
if (!eligible || !enabled) return;
const pending = {
event,
botId,
botName,
groupNotification,
} satisfies PendingBrowserNotification;
if (typeof Notification === "undefined" || Notification.permission === "denied") return;
if (Notification.permission === "default") {
pendingBrowserNotifications.current.set(event.threadId, pending);
return;
}
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(event.threadId);
} else {
pendingBrowserNotifications.current.set(event.threadId, pending);
}
},
[deliverBrowserNotification],
);
const { flushPendingBrowserNotifications, notifyBrowserForEvent } =
useBrowserNotifications(botsRef);
const refreshBots = useCallback(
async (includeArchived = false, replaceBotOrder = false) => {
@ -1966,114 +1888,15 @@ export function ShellPage() {
openSettings("voice");
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
const prevTranscriptRunning = useRef(false);
const prevRunErrorId = useRef<string | null>(null);
const seenHandoffKeys = useRef(new Set<string>());
const appSoundStarted = useRef(false);
const prevBotStatuses = useRef(new Map<string, string>());
useEffect(() => wireOoAudioVoiceDucking(), []);
useEffect(() => {
if (appSoundStarted.current) return;
appSoundStarted.current = true;
ooSound.play("app-start");
window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280);
}, []);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
if (cancelled) return;
const brief = buildHeuteBrief({
bots: botsRef.current,
groups,
activeRuns: active.runs,
recentRuns: recent.runs,
});
setHeuteActionCount(brief.actionCount);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [groups]);
useEffect(() => {
for (const bot of bots) {
const prev = prevBotStatuses.current.get(bot.id);
if (
prev !== bot.status &&
(bot.status === "waiting_input" || bot.status === "waiting_takeover")
) {
ooSound.play("need-you");
ooVocals.reactForEvent("need-you");
}
prevBotStatuses.current.set(bot.id, bot.status);
}
}, [bots]);
useEffect(() => {
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
ooSound.play("success-resolve");
ooVocals.reactForEvent("success");
}
prevTranscriptRunning.current = transcriptRunning;
}, [transcriptRunning, callOpen]);
useEffect(() => {
const runId = displayedRunErrorId;
if (runId && runId !== prevRunErrorId.current) {
ooSound.play("attention");
}
prevRunErrorId.current = runId;
}, [displayedRunErrorId]);
const seenGroupSparkKeys = useRef(new Set<string>());
useEffect(() => {
for (const message of activeSnapshot?.messages ?? []) {
for (const block of message.blocks) {
if (block.kind === "handoff") {
const key = `${message.id}:handoff:${block.toBotId}`;
if (seenHandoffKeys.current.has(key)) continue;
seenHandoffKeys.current.add(key);
if (seenHandoffKeys.current.size > 80) {
const oldest = seenHandoffKeys.current.values().next().value;
if (oldest !== undefined) seenHandoffKeys.current.delete(oldest);
}
ooSound.play("handoff");
continue;
}
if (inGroup && block.kind === "bot_message_received") {
const key = `${message.id}:peer:${block.fromBotId}`;
if (seenGroupSparkKeys.current.has(key)) continue;
seenGroupSparkKeys.current.add(key);
if (seenGroupSparkKeys.current.size > 80) {
const oldest = seenGroupSparkKeys.current.values().next().value;
if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest);
}
ooSound.play("group-spark");
}
}
}
}, [activeSnapshot?.messages, inGroup]);
const heuteActionCount = useHeuteActionCount(groups, botsRef);
useShellSounds({
bots,
transcriptRunning,
callOpen,
displayedRunErrorId,
activeSnapshot,
inGroup,
});
const resolveTranscriptMemberName = useCallback(
(botId: string | undefined) =>

View File

@ -0,0 +1,86 @@
import type { Bot, ProductEvent } from "@rakazo/contracts";
import type { RefObject } from "react";
import { useCallback, useRef } from "react";
import {
deliverBrowserNotification as deliverNativeBrowserNotification,
shouldNotifyBrowser,
} from "../../lib/browser-notifications";
type PendingBrowserNotification = {
event: Pick<ProductEvent, "id" | "type" | "threadId" | "botId" | "payload">;
botId: string;
botName: string;
groupNotification: boolean;
};
export function useBrowserNotifications(botsRef: RefObject<Bot[]>) {
const notifiedBrowserEvents = useRef(new Set<string>());
const pendingBrowserNotifications = useRef(new Map<string, PendingBrowserNotification>());
const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => {
const currentBot = botsRef.current.find((bot) => bot.id === pending.botId);
if (!currentBot || typeof Notification === "undefined") return true;
const result = deliverNativeBrowserNotification(
pending.event,
currentBot.name || pending.botName,
{
enabled: pending.groupNotification || currentBot.notifyOnFinish,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: Notification.permission,
notifiedEventIds: notifiedBrowserEvents.current,
show: (title, body, tag) => new Notification(title, { body, tag }),
},
);
return result !== "pending";
}, []);
const flushPendingBrowserNotifications = useCallback(() => {
for (const [threadId, pending] of pendingBrowserNotifications.current) {
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(threadId);
}
}
}, [deliverBrowserNotification]);
const notifyBrowserForEvent = useCallback(
(
event: Pick<ProductEvent, "id" | "type" | "threadId" | "seq" | "botId" | "payload">,
subscribedThreadId: string | undefined,
initialCursor: number,
streamReady: boolean,
botName: string,
enabled: boolean,
groupNotification: boolean,
) => {
const botId = event.botId;
if (typeof botId !== "string") return;
const eligible = shouldNotifyBrowser(event, {
subscribedThreadId: subscribedThreadId ?? "",
initialCursor,
streamReady,
pageVisible: document.visibilityState === "visible",
windowFocused: document.hasFocus(),
permission: "granted",
notifiedEventIds: notifiedBrowserEvents.current,
});
if (!eligible || !enabled) return;
const pending = {
event,
botId,
botName,
groupNotification,
} satisfies PendingBrowserNotification;
if (typeof Notification === "undefined" || Notification.permission === "denied") return;
if (Notification.permission === "default") {
pendingBrowserNotifications.current.set(event.threadId, pending);
return;
}
if (deliverBrowserNotification(pending)) {
pendingBrowserNotifications.current.delete(event.threadId);
} else {
pendingBrowserNotifications.current.set(event.threadId, pending);
}
},
[deliverBrowserNotification],
);
return { flushPendingBrowserNotifications, notifyBrowserForEvent };
}

View File

@ -0,0 +1,45 @@
import type { Bot, Group } from "@rakazo/contracts";
import type { RefObject } from "react";
import { useEffect, useState } from "react";
import { buildHeuteBrief } from "../../lib/heute-brief";
import { rpc } from "../../lib/rpc";
export function useHeuteActionCount(groups: Group[], botsRef: RefObject<Bot[]>): number {
const [heuteActionCount, setHeuteActionCount] = useState(0);
useEffect(() => {
let cancelled = false;
let timer: number | undefined;
const tick = async () => {
try {
const [active, recent] = await Promise.all([
rpc.runs.list({ filter: "active" }),
rpc.runs.list({ filter: "recent" }),
]);
if (cancelled) return;
const brief = buildHeuteBrief({
bots: botsRef.current,
groups,
activeRuns: active.runs,
recentRuns: recent.runs,
});
setHeuteActionCount(brief.actionCount);
} catch {
if (cancelled) return;
} finally {
if (!cancelled) {
timer = window.setTimeout(() => void tick(), 15_000);
}
}
};
void tick();
return () => {
cancelled = true;
if (timer !== undefined) window.clearTimeout(timer);
};
}, [groups]);
return heuteActionCount;
}

View File

@ -0,0 +1,99 @@
import type { Bot, ThreadSnapshot } from "@rakazo/contracts";
import { useEffect, useRef } from "react";
import { wireOoAudioVoiceDucking } from "../../lib/oo-audio-voice-wire";
import { ooSound } from "../../lib/oo-sound";
import { ooVocals } from "../../lib/oo-vocals";
import { ooVoiceMark } from "../../lib/oo-voice-mark";
export type ShellSoundsInput = {
bots: Bot[];
transcriptRunning: boolean;
callOpen: boolean;
displayedRunErrorId: string | null;
activeSnapshot: ThreadSnapshot | null;
inGroup: boolean;
};
export function useShellSounds({
bots,
transcriptRunning,
callOpen,
displayedRunErrorId,
activeSnapshot,
inGroup,
}: ShellSoundsInput): void {
const prevTranscriptRunning = useRef(false);
const prevRunErrorId = useRef<string | null>(null);
const seenHandoffKeys = useRef(new Set<string>());
const appSoundStarted = useRef(false);
const prevBotStatuses = useRef(new Map<string, string>());
useEffect(() => wireOoAudioVoiceDucking(), []);
useEffect(() => {
if (appSoundStarted.current) return;
appSoundStarted.current = true;
ooSound.play("app-start");
window.setTimeout(() => ooVoiceMark.maybePlayColdOpen(), 280);
}, []);
useEffect(() => {
for (const bot of bots) {
const prev = prevBotStatuses.current.get(bot.id);
if (
prev !== bot.status &&
(bot.status === "waiting_input" || bot.status === "waiting_takeover")
) {
ooSound.play("need-you");
ooVocals.reactForEvent("need-you");
}
prevBotStatuses.current.set(bot.id, bot.status);
}
}, [bots]);
useEffect(() => {
if (prevTranscriptRunning.current && !transcriptRunning && !callOpen) {
ooSound.play("success-resolve");
ooVocals.reactForEvent("success");
}
prevTranscriptRunning.current = transcriptRunning;
}, [transcriptRunning, callOpen]);
useEffect(() => {
const runId = displayedRunErrorId;
if (runId && runId !== prevRunErrorId.current) {
ooSound.play("attention");
}
prevRunErrorId.current = runId;
}, [displayedRunErrorId]);
const seenGroupSparkKeys = useRef(new Set<string>());
useEffect(() => {
for (const message of activeSnapshot?.messages ?? []) {
for (const block of message.blocks) {
if (block.kind === "handoff") {
const key = `${message.id}:handoff:${block.toBotId}`;
if (seenHandoffKeys.current.has(key)) continue;
seenHandoffKeys.current.add(key);
if (seenHandoffKeys.current.size > 80) {
const oldest = seenHandoffKeys.current.values().next().value;
if (oldest !== undefined) seenHandoffKeys.current.delete(oldest);
}
ooSound.play("handoff");
continue;
}
if (inGroup && block.kind === "bot_message_received") {
const key = `${message.id}:peer:${block.fromBotId}`;
if (seenGroupSparkKeys.current.has(key)) continue;
seenGroupSparkKeys.current.add(key);
if (seenGroupSparkKeys.current.size > 80) {
const oldest = seenGroupSparkKeys.current.values().next().value;
if (oldest !== undefined) seenGroupSparkKeys.current.delete(oldest);
}
ooSound.play("group-spark");
}
}
}
}, [activeSnapshot?.messages, inGroup]);
}