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>
This commit is contained in:
217
frontend/src/features/announcements/AnnouncementsProvider.tsx
Normal file
217
frontend/src/features/announcements/AnnouncementsProvider.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
/**
|
||||
* `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],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user