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:
parent
307c095bb6
commit
a1bf392298
@ -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) =>
|
||||
|
||||
86
apps/web/src/pages/shell/use-browser-notifications.ts
Normal file
86
apps/web/src/pages/shell/use-browser-notifications.ts
Normal 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 };
|
||||
}
|
||||
45
apps/web/src/pages/shell/use-heute-action-count.ts
Normal file
45
apps/web/src/pages/shell/use-heute-action-count.ts
Normal 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;
|
||||
}
|
||||
99
apps/web/src/pages/shell/use-shell-sounds.ts
Normal file
99
apps/web/src/pages/shell/use-shell-sounds.ts
Normal 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]);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user