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,
|
Group,
|
||||||
MainConversationRef,
|
MainConversationRef,
|
||||||
Me,
|
Me,
|
||||||
ProductEvent,
|
|
||||||
Routine,
|
Routine,
|
||||||
Space,
|
Space,
|
||||||
SpaceMemoryConfig,
|
SpaceMemoryConfig,
|
||||||
@ -141,11 +140,7 @@ import {
|
|||||||
readBotsSidebarCollapsed,
|
readBotsSidebarCollapsed,
|
||||||
writeBotsSidebarCollapsed,
|
writeBotsSidebarCollapsed,
|
||||||
} from "../lib/bots-sidebar-pref";
|
} from "../lib/bots-sidebar-pref";
|
||||||
import {
|
import { requestBrowserNotificationPermission } from "../lib/browser-notifications";
|
||||||
deliverBrowserNotification as deliverNativeBrowserNotification,
|
|
||||||
requestBrowserNotificationPermission,
|
|
||||||
shouldNotifyBrowser,
|
|
||||||
} from "../lib/browser-notifications";
|
|
||||||
import { loadComputerScreen } from "../lib/computer-screen";
|
import { loadComputerScreen } from "../lib/computer-screen";
|
||||||
import { desktopBridge } from "../lib/desktop";
|
import { desktopBridge } from "../lib/desktop";
|
||||||
import { scheduleFocusPrompt } from "../lib/focus-prompt";
|
import { scheduleFocusPrompt } from "../lib/focus-prompt";
|
||||||
@ -155,14 +150,11 @@ import {
|
|||||||
resolveActiveGroupSpeaker,
|
resolveActiveGroupSpeaker,
|
||||||
resolveWorkingGroupBots,
|
resolveWorkingGroupBots,
|
||||||
} from "../lib/group-stage";
|
} from "../lib/group-stage";
|
||||||
import { buildHeuteBrief } from "../lib/heute-brief";
|
|
||||||
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
import { lazyWithRecovery } from "../lib/lazy-recovery";
|
||||||
import { localTimezone } from "../lib/local-timezone";
|
import { localTimezone } from "../lib/local-timezone";
|
||||||
import { localizedPreview } from "../lib/onboarding-copy";
|
import { localizedPreview } from "../lib/onboarding-copy";
|
||||||
import { wireOoAudioVoiceDucking } from "../lib/oo-audio-voice-wire";
|
|
||||||
import { ooSound } from "../lib/oo-sound";
|
import { ooSound } from "../lib/oo-sound";
|
||||||
import { ooVocals } from "../lib/oo-vocals";
|
import { ooVocals } from "../lib/oo-vocals";
|
||||||
import { ooVoiceMark } from "../lib/oo-voice-mark";
|
|
||||||
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
|
import { revokePendingAttachmentPreviews } from "../lib/pending-attachments";
|
||||||
import { markAfterPaint, markOnce } from "../lib/performance";
|
import { markAfterPaint, markOnce } from "../lib/performance";
|
||||||
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
import { isEmptyProjectSpace } from "../lib/project-empty-state";
|
||||||
@ -248,6 +240,9 @@ import {
|
|||||||
threadSnapshotSignal,
|
threadSnapshotSignal,
|
||||||
} from "./shell/thread-state";
|
} from "./shell/thread-state";
|
||||||
import { Transcript } from "./shell/transcript";
|
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 { useSidebarModel } from "./shell/use-sidebar-model";
|
||||||
import { useSpaceSearch } from "./shell/use-space-search";
|
import { useSpaceSearch } from "./shell/use-space-search";
|
||||||
import { WindowChrome } from "./WindowChrome";
|
import { WindowChrome } from "./WindowChrome";
|
||||||
@ -287,13 +282,6 @@ type Panel =
|
|||||||
| "artifact"
|
| "artifact"
|
||||||
| null;
|
| 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. */
|
/** Bound Settings leave so a hung voice status refresh cannot block dismissal. */
|
||||||
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
const VOICE_STATUS_REFRESH_TIMEOUT_MS = 10_000;
|
||||||
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
const MOBILE_SIDEBAR_SWIPE_EDGE_PX = 32;
|
||||||
@ -380,7 +368,6 @@ export function ShellPage() {
|
|||||||
"rakazo:workspace-pane-width",
|
"rakazo:workspace-pane-width",
|
||||||
);
|
);
|
||||||
|
|
||||||
const [heuteActionCount, setHeuteActionCount] = useState(0);
|
|
||||||
const [archivedOpen, setArchivedOpen] = useState(false);
|
const [archivedOpen, setArchivedOpen] = useState(false);
|
||||||
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
const [expandedTeamGroups, setExpandedTeamGroups] = useState<Record<string, boolean>>({});
|
||||||
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
const [collapsedSidebarSections, setCollapsedSidebarSections] = useState(() => new Set<string>());
|
||||||
@ -699,8 +686,6 @@ export function ShellPage() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const manuallyUnread = useRef(new Set<string>());
|
const manuallyUnread = useRef(new Set<string>());
|
||||||
const readVisibleGroups = 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);
|
const computerVisible = useRef(false);
|
||||||
computerVisible.current = panel === "computer" || computerOpen;
|
computerVisible.current = panel === "computer" || computerOpen;
|
||||||
const autoSpoken = useRef<string | null>(null);
|
const autoSpoken = useRef<string | null>(null);
|
||||||
@ -787,71 +772,8 @@ export function ShellPage() {
|
|||||||
},
|
},
|
||||||
[markBotRead],
|
[markBotRead],
|
||||||
);
|
);
|
||||||
const deliverBrowserNotification = useCallback((pending: PendingBrowserNotification): boolean => {
|
const { flushPendingBrowserNotifications, notifyBrowserForEvent } =
|
||||||
const currentBot = botsRef.current.find((bot) => bot.id === pending.botId);
|
useBrowserNotifications(botsRef);
|
||||||
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 refreshBots = useCallback(
|
const refreshBots = useCallback(
|
||||||
async (includeArchived = false, replaceBotOrder = false) => {
|
async (includeArchived = false, replaceBotOrder = false) => {
|
||||||
@ -1966,114 +1888,15 @@ export function ShellPage() {
|
|||||||
openSettings("voice");
|
openSettings("voice");
|
||||||
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
|
}, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]);
|
||||||
|
|
||||||
const prevTranscriptRunning = useRef(false);
|
const heuteActionCount = useHeuteActionCount(groups, botsRef);
|
||||||
const prevRunErrorId = useRef<string | null>(null);
|
useShellSounds({
|
||||||
const seenHandoffKeys = useRef(new Set<string>());
|
bots,
|
||||||
const appSoundStarted = useRef(false);
|
transcriptRunning,
|
||||||
const prevBotStatuses = useRef(new Map<string, string>());
|
callOpen,
|
||||||
|
displayedRunErrorId,
|
||||||
useEffect(() => wireOoAudioVoiceDucking(), []);
|
activeSnapshot,
|
||||||
|
inGroup,
|
||||||
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 resolveTranscriptMemberName = useCallback(
|
const resolveTranscriptMemberName = useCallback(
|
||||||
(botId: string | undefined) =>
|
(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