/** * `AnnouncementsProvider` — the live, app-wide store for inter-agent announcements * (ticket #4, F1 wiring). Subscribes **once** to the domain-event stream via the * {@link SystemGateway} and folds two orthogonal signals: * * 1. **Announcement content** — `agentAnnouncement` events appended to the * bounded `(target, ticketId)` index (the scrolling text of the overlay F3 * and the requester preview F2). * 2. **Overlay lifecycle** — the per-agent **busy/idle** state, the single * authority for mounting/retracting the target overlay (Architect arbitrage * 2026-07-04). Fed live by `agentBusyChanged { agentId, busy }` and hydrated * from the reconciled read-model `ProjectWorkState.agents[].busy` at mount / * reboot (via {@link useHydrateAgentBusy}). * * Why busy — not "there are announcements" — drives F3: a turn can end **without** * any completion event (interruption / error / crash / rate-limit). The mediator's * `agentBusyChanged` `busy:false` falls at idle in *every* case, and the read-model * is reconciled on reboot, so the overlay can never stick. A rate-limited agent * stays `busy:true`, so its overlay persists with no special-casing. * * (On this `develop` base there is no canonical `agentTurnEvent`/`final` signal; * busy is the sole lifecycle authority, which is exactly what we want.) * * Consumers read via {@link useTargetAnnouncements} (F3) and * {@link useRequesterAnnouncements} (F2). The store is never persisted — it is * rebuilt live from the event stream and the read-model snapshot. */ import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode, } from "react"; import { useGateways } from "@/app/di"; import { announcementsForRequester, announcementsForTarget, appendAnnouncement, emptyAnnouncementIndex, purgeAllForTarget, type Announcement, type AnnouncementIndex, } from "./announcementsStore"; /** Per-agent busy map: `true` while the agent owns a turn (drives the overlay). */ type BusyMap = Record; interface AnnouncementsStore { /** Bounded announcement content, indexed by `(target, ticketId)`. */ index: AnnouncementIndex; /** Per-agent busy state — the overlay's lifecycle authority. */ busy: BusyMap; /** * Seeds an agent's busy state from the read-model, but only when no live signal * is yet known for it: live `agentBusyChanged` events always win over a * (possibly staler) hydration snapshot. */ seedBusy: (agentId: string, busy: boolean) => void; } const noopSeed = () => {}; const EMPTY_INDEX = emptyAnnouncementIndex(); const EMPTY_BUSY: BusyMap = {}; /** Store shape read outside a provider — a silent, inert default (never throws). */ const DEFAULT_STORE: AnnouncementsStore = { index: EMPTY_INDEX, busy: EMPTY_BUSY, seedBusy: noopSeed, }; const AnnouncementsContext = createContext(null); export function AnnouncementsProvider({ children }: { children: ReactNode }) { const { system } = useGateways(); const [index, setIndex] = useState(emptyAnnouncementIndex); const [busy, setBusy] = useState({}); // Hydration seed: apply only when the agent is unknown, so a live event that // already landed (or lands before the async read-model resolves) is not clobbered. const seedBusy = useCallback((agentId: string, value: boolean) => { setBusy((prev) => (agentId in prev ? prev : { ...prev, [agentId]: value })); }, []); useEffect(() => { // `system` may be absent in unit tests injecting a partial gateway set. if (!system) return; let unsubscribe: (() => void) | undefined; let cancelled = false; void system .onDomainEvent((event) => { if (event.type === "agentAnnouncement") { // Content of the overlay/preview. setIndex((prev) => appendAnnouncement(prev, { requester: event.requester, target: event.target, ticketId: event.ticketId, text: event.text, atMs: event.atMs, }), ); } else if (event.type === "agentBusyChanged") { // Lifecycle authority. `true` mounts the target overlay; `false` retracts // it (idle) and clears the target's content so a next turn starts fresh — // even when no completion event was ever emitted (the sticking case). const { agentId, busy: isBusy } = event; setBusy((prev) => ({ ...prev, [agentId]: isBusy })); if (!isBusy) setIndex((prev) => purgeAllForTarget(prev, agentId)); } }) .then((un) => { if (cancelled) un(); else unsubscribe = un; }) .catch(() => { // Event relay unavailable in this environment — the store stays empty. }); return () => { cancelled = true; unsubscribe?.(); }; }, [system]); const store = useMemo( () => ({ index, busy, seedBusy }), [index, busy, seedBusy], ); return ( {children} ); } /** The store, or an inert default when read outside a provider. */ function useStore(): AnnouncementsStore { return useContext(AnnouncementsContext) ?? DEFAULT_STORE; } /** Whether a real provider is mounted above (hydration only runs when it is). */ function useWithinProvider(): boolean { return useContext(AnnouncementsContext) !== null; } /** * Announcements destined to `target` (F3): the flattened, time-ordered content * plus `active` — whether the overlay should be mounted. `active` is the target's * **busy** state (the lifecycle authority), NOT the mere presence of announcements. */ export function useTargetAnnouncements(target: string): { announcements: Announcement[]; active: boolean; } { const { index, busy } = useStore(); return useMemo( () => ({ announcements: announcementsForTarget(index, target), active: busy[target] === true, }), [index, busy, target], ); } /** * Hydrates the target's busy state from the reconciled read-model at mount/reboot, * so an overlay mounts for a turn already in flight when its cell appears — no live * `agentBusyChanged` will replay for it. Live events subsequently win (see * `seedBusy`). Guarded: no-op outside a provider or without a `workState` gateway. */ export function useHydrateAgentBusy(projectId: string, agentId: string): void { const { workState } = useGateways(); const { seedBusy } = useStore(); const within = useWithinProvider(); useEffect(() => { if (!within || !workState) return; let cancelled = false; void workState .getProjectWorkState(projectId) .then((state) => { if (cancelled) return; const agent = state.agents.find((a) => a.agentId === agentId); if (agent) seedBusy(agentId, agent.busy.state === "busy"); }) .catch(() => { // Read-model unavailable — fall back to the live event stream only. }); return () => { cancelled = true; }; }, [within, workState, projectId, agentId, seedBusy]); } /** * Announcements the given `requester` is waiting on (F2), filtered by * `requester == self` so a shared target thread never leaks another requester's * announcements. Optionally scoped to a single `ticketId`. */ export function useRequesterAnnouncements( requester: string, ticketId?: string, ): Announcement[] { const { index } = useStore(); return useMemo( () => announcementsForRequester(index, requester, ticketId), [index, requester, ticketId], ); }