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>
218 lines
7.6 KiB
TypeScript
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],
|
|
);
|
|
}
|