| 1 | import { runtimeStateStore, type RuntimeSession } from "./runtimeStateStore"; |
| 2 | import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, playSuccessChime, shouldPlayAttentionChimeForEvent, type AttentionChimeEvent } from "./sound"; |
| 3 | import type { Translator } from "./i18n"; |
| 4 | import type { ToastContextValue } from "./toast"; |
| 5 | |
| 6 | export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string }; |
| 7 | type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] }; |
| 8 | |
| 9 | /** One owner deduplicates view events and authoritative background snapshots. */ |
| 10 | export function createRuntimeNotifications(readPorts: () => NotificationPorts | undefined) { |
| 11 | const seen = new Set<string>(); |
| 12 | // Live requests cannot be evicted with historical replay keys. Replace this |
| 13 | // set on each trusted snapshot so resolved requests do not accumulate. |
| 14 | let pendingSeen = new Set<string>(); |
| 15 | const handleAttention = (event: AttentionChimeEvent, source?: RuntimeSession) => { |
| 16 | const ports = readPorts(); |
| 17 | const snapshot = runtimeStateStore.getSnapshot(); |
| 18 | const turnId = event.ask?.turnId || event.approval?.turnId || event.turnId; |
| 19 | const candidates = snapshot?.sessions.filter(item => (!event.hostId || (item.hostId || "local") === event.hostId) |
| 20 | && (!turnId || item.state.turnId === turnId)) ?? []; |
| 21 | const session = source ?? candidates.find(item => item.tabId === event.tabId) |
| 22 | ?? (turnId && candidates.length === 1 ? candidates[0] : undefined); |
| 23 | event = { ...event, hostId: session?.hostId || session?.state.hostId || event.hostId }; |
| 24 | const key = attentionChimeEventKey(event); |
| 25 | if (!ports || !key || pendingSeen.has(key) || !shouldPlayAttentionChimeForEvent(event, seen)) return; |
| 26 | playAttentionChime(); |
| 27 | const { activeTabId, t, showToast } = ports; |
| 28 | const background = session ? !session.open || session.tabId !== activeTabId : Boolean(event.tabId && event.tabId !== activeTabId); |
| 29 | if (!background) return; |
| 30 | const topic = session ? snapshot?.topics.find(item => item.scope === session.scope |
| 31 | && (session.scope !== "project" || (item.workspaceRoot ?? "") === session.workspaceRoot) |
| 32 | && (session.sessionId ? (item.node.session?.sessionId || item.node.remoteSession?.sessionId) === session.sessionId |
| 33 | && (item.node.session?.hostId || item.node.remoteSession?.hostId || "local") === (session.hostId || "local") |
| 34 | : session.sessionPath && item.node.sessionPath === session.sessionPath |
| 35 | && (item.node.remoteSession?.hostId || item.node.session?.hostId || "local") === (session.hostId || "local"))) : undefined; |
| 36 | const child = session?.sessionPath ? topic?.node.children?.find(node => node.sessionPath === session.sessionPath) : undefined; |
| 37 | const title = child?.label || topic?.node.label || t("runtime.otherConversation"); |
| 38 | showToast(t(event.kind === "ask_request" ? "runtime.backgroundQuestion" : "runtime.backgroundApproval", { title }), "info", { durationMs: 8000 }); |
| 39 | }; |
| 40 | const handleSnapshot = () => { |
| 41 | if (runtimeStateStore.getFailed()) return; |
| 42 | const nextPending = new Set<string>(); |
| 43 | const visit = (event: AttentionChimeEvent, session: RuntimeSession) => { |
| 44 | event = { ...event, hostId: session.hostId || session.state.hostId }; |
| 45 | const key = attentionChimeEventKey(event); |
| 46 | if (!key || nextPending.has(key)) return; |
| 47 | if (session.freshness !== "synced") { |
| 48 | // Disconnects retain known prompts but cannot announce unseen ones. |
| 49 | if (pendingSeen.has(key)) nextPending.add(key); |
| 50 | return; |
| 51 | } |
| 52 | handleAttention(event, session); |
| 53 | nextPending.add(key); |
| 54 | }; |
| 55 | for (const session of runtimeStateStore.getSnapshot()?.sessions ?? []) { |
| 56 | if (!session.state.pendingPrompt) continue; |
| 57 | for (const prompt of session.state.pendingInteractions ?? []) { |
| 58 | const identity = { id: prompt.requestId, turnId: prompt.turnId || session.state.turnId }; |
| 59 | // Plan/recovery decisions use the same approval card/event path. |
| 60 | if (prompt.kind === "ask") visit({ kind: "ask_request", tabId: session.tabId, ask: identity }, session); |
| 61 | else if (["approval", "plan", "recovery"].includes(prompt.kind)) { |
| 62 | visit({ kind: "approval_request", tabId: session.tabId, approval: identity }, session); |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | pendingSeen = nextPending; |
| 67 | }; |
| 68 | let stop: (() => void) | undefined; |
| 69 | return { |
| 70 | accept(operation: NotificationOperation) { |
| 71 | if (!("event" in operation)) { |
| 72 | clearAttentionChimeKeys(seen, operation.resetTabId); |
| 73 | clearAttentionChimeKeys(pendingSeen, operation.resetTabId); |
| 74 | } else if (operation.event.kind === "turn_done") { |
| 75 | if (readPorts() && !operation.event.err) playSuccessChime(); |
| 76 | } else handleAttention(operation.event); |
| 77 | }, |
| 78 | start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); }, |
| 79 | dispose() { stop?.(); }, |
| 80 | }; |
| 81 | } |
| 82 |