From a1bf3922988c85f4dd34e0019d9b761366915cdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 10:01:09 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --- apps/web/src/pages/Shell.tsx | 207 ++---------------- .../pages/shell/use-browser-notifications.ts | 86 ++++++++ .../src/pages/shell/use-heute-action-count.ts | 45 ++++ apps/web/src/pages/shell/use-shell-sounds.ts | 99 +++++++++ 4 files changed, 245 insertions(+), 192 deletions(-) create mode 100644 apps/web/src/pages/shell/use-browser-notifications.ts create mode 100644 apps/web/src/pages/shell/use-heute-action-count.ts create mode 100644 apps/web/src/pages/shell/use-shell-sounds.ts diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index ab905ee..9665eb2 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -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; - 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>({}); const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set()); @@ -699,8 +686,6 @@ export function ShellPage() { } | null>(null); const manuallyUnread = useRef(new Set()); const readVisibleGroups = useRef(new Set()); - const notifiedBrowserEvents = useRef(new Set()); - const pendingBrowserNotifications = useRef(new Map()); const computerVisible = useRef(false); computerVisible.current = panel === "computer" || computerOpen; const autoSpoken = useRef(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, - 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(null); - const seenHandoffKeys = useRef(new Set()); - const appSoundStarted = useRef(false); - const prevBotStatuses = useRef(new Map()); - - 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()); - - 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) => diff --git a/apps/web/src/pages/shell/use-browser-notifications.ts b/apps/web/src/pages/shell/use-browser-notifications.ts new file mode 100644 index 0000000..497ab89 --- /dev/null +++ b/apps/web/src/pages/shell/use-browser-notifications.ts @@ -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; + botId: string; + botName: string; + groupNotification: boolean; +}; + +export function useBrowserNotifications(botsRef: RefObject) { + const notifiedBrowserEvents = useRef(new Set()); + const pendingBrowserNotifications = useRef(new Map()); + 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, + 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 }; +} diff --git a/apps/web/src/pages/shell/use-heute-action-count.ts b/apps/web/src/pages/shell/use-heute-action-count.ts new file mode 100644 index 0000000..1ea9f43 --- /dev/null +++ b/apps/web/src/pages/shell/use-heute-action-count.ts @@ -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): 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; +} diff --git a/apps/web/src/pages/shell/use-shell-sounds.ts b/apps/web/src/pages/shell/use-shell-sounds.ts new file mode 100644 index 0000000..66e89ec --- /dev/null +++ b/apps/web/src/pages/shell/use-shell-sounds.ts @@ -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(null); + const seenHandoffKeys = useRef(new Set()); + const appSoundStarted = useRef(false); + const prevBotStatuses = useRef(new Map()); + + 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()); + + 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]); +}