Files
IdeaSDK/frontend/src/features/announcements/AnnouncementsProvider.tsx
Blomios 50b2adfde3 feat(inter-agent): surface frontend des annonces live sur les cellules (F1-F3)
Affiche les annonces d'agent en direct au-dessus des cellules, sur la base
develop :

- announcements: nouveau feature module — store réactif, provider
  d'abonnement aux événements, overlay par cible et aperçu, avec tests
  (announcementsStore + provider).
- App.tsx: montage du provider dans l'arbre applicatif.
- domain/index.ts: types partagés de l'événement d'annonce côté front.
- layout: composition de l'overlay dans LayoutGrid + règle d'exclusion
  couverte par overlayExclusion.test.ts.
- AgentsPanel: intègre l'aperçu des annonces dans la surface existante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 19:50:13 +02:00

218 lines
7.6 KiB
TypeScript

/**
* `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<string, boolean>;
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<AnnouncementsStore | null>(null);
export function AnnouncementsProvider({ children }: { children: ReactNode }) {
const { system } = useGateways();
const [index, setIndex] = useState<AnnouncementIndex>(emptyAnnouncementIndex);
const [busy, setBusy] = useState<BusyMap>({});
// 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<AnnouncementsStore>(
() => ({ index, busy, seedBusy }),
[index, busy, seedBusy],
);
return (
<AnnouncementsContext.Provider value={store}>
{children}
</AnnouncementsContext.Provider>
);
}
/** 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],
);
}