diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx
index 910bb65..3769a88 100644
--- a/frontend/src/app/App.tsx
+++ b/frontend/src/app/App.tsx
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
import type { DomainEvent, HealthReport } from "@/domain";
import { ProjectsView } from "@/features/projects";
import { FirstRunWizard, ProfilesSettings } from "@/features/first-run";
+import { AnnouncementsProvider } from "@/features/announcements";
import { Button, Panel, Spinner, Toolbar } from "@/shared";
import { useGateways, shouldUseMock } from "./di";
@@ -63,6 +64,7 @@ export function App() {
}, [profile]);
return (
+
{/* ── Header ── */}
@@ -129,6 +131,7 @@ export function App() {
)}
+
);
}
diff --git a/frontend/src/domain/index.ts b/frontend/src/domain/index.ts
index dff739e..c344c22 100644
--- a/frontend/src/domain/index.ts
+++ b/frontend/src/domain/index.ts
@@ -165,6 +165,25 @@ export type DomainEvent =
agentId: string;
version: number;
}
+ | {
+ /**
+ * An intermediate assistant announcement emitted **during** an inter-agent
+ * structured turn (ticket #4, "annonces inter-agent"). Mirror of the backend
+ * `DomainEventDto::AgentAnnouncement`. Ephemeral (never persisted): the front
+ * folds these into a live, bounded, per-`(target, ticketId)` index that feeds
+ * the target-cell overlay (F3) and the requester-cell preview (F2).
+ *
+ * `requester` is `"user"` or the requesting agent's id; `target` is the agent
+ * being contacted; `text` is the human-readable réflexion/annonce.
+ */
+ type: "agentAnnouncement";
+ projectId: string;
+ requester: string;
+ target: string;
+ ticketId: string;
+ text: string;
+ atMs: number;
+ }
| { type: "ptyOutput"; sessionId: string; bytes: number[] };
/**
diff --git a/frontend/src/features/agents/AgentsPanel.tsx b/frontend/src/features/agents/AgentsPanel.tsx
index 6e0763e..0416f2e 100644
--- a/frontend/src/features/agents/AgentsPanel.tsx
+++ b/frontend/src/features/agents/AgentsPanel.tsx
@@ -18,6 +18,7 @@ import { useState } from "react";
import { Button, Input, Panel, Spinner, cn } from "@/shared";
import { TerminalView } from "@/features/terminals/TerminalView";
+import { AnnouncementsPreview } from "@/features/announcements";
import { useDrift } from "@/features/templates/useDrift";
import { useGateways } from "@/app/di";
import { useAgents } from "./useAgents";
@@ -387,6 +388,11 @@ export function AgentsPanel({ projectId, projectRoot = "" }: AgentsPanelProps) {
/>
)}
+ {/* F2 (ticket #4): announcements this agent is waiting on while
+ it talks to another agent (requester == this row's id). Sits
+ just above the agent drop-list. */}
+
+
{/* Profile hot-swap selector (Chantier A). Changing the
engine abandons the conversation history → confirmation. */}
diff --git a/frontend/src/features/announcements/AnnouncementsPreview.tsx b/frontend/src/features/announcements/AnnouncementsPreview.tsx
new file mode 100644
index 0000000..b089a8d
--- /dev/null
+++ b/frontend/src/features/announcements/AnnouncementsPreview.tsx
@@ -0,0 +1,61 @@
+/**
+ * `AnnouncementsPreview` — F2 of ticket #4.
+ *
+ * The **requester** side: a small frame, shown near the agent drop-list, that
+ * previews the announcements the given agent is currently waiting on (it has
+ * contacted another agent and is receiving its réflexions live).
+ *
+ * Contract (Architect, point 7): filter on `requester == self`. The target
+ * thread is shared across every requester, so previewing by target alone would
+ * leak another requester's announcements; here `requester` is pinned to this
+ * agent's id. Renders nothing when the agent has no announcements in flight.
+ */
+
+import { useRequesterAnnouncements } from "./AnnouncementsProvider";
+
+export interface AnnouncementsPreviewProps {
+ /** The requesting agent this preview belongs to (the announcement `requester`). */
+ requester: string;
+ /** Optional ticket scope (a single inter-agent turn). */
+ ticketId?: string;
+ /** How many most-recent announcements to show. */
+ limit?: number;
+}
+
+export function AnnouncementsPreview({
+ requester,
+ ticketId,
+ limit = 3,
+}: AnnouncementsPreviewProps) {
+ const announcements = useRequesterAnnouncements(requester, ticketId);
+ if (announcements.length === 0) return null;
+
+ // Most-recent `limit`, newest last (the list is oldest → newest).
+ const recent = announcements.slice(Math.max(0, announcements.length - limit));
+
+ return (
+
+
+
+ En attente d'un agent
+
+
+ {recent.map((a) => (
+ -
+ {a.text}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx
new file mode 100644
index 0000000..d4d0d74
--- /dev/null
+++ b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx
@@ -0,0 +1,232 @@
+/**
+ * F2/F3 integration (ticket #4, develop base — busy is the sole F3 authority):
+ *
+ * - {@link TargetAnnouncementsOverlay} (F3) is driven by the target's **busy**
+ * state, NOT by the presence of announcements. It mounts on
+ * `agentBusyChanged { busy:true }` (or a read-model hydration snapshot) and
+ * retracts on `busy:false`, **including when no completion event is ever
+ * emitted** — the interruption/crash/rate-limit case that used to stick the
+ * overlay. There is no `agentTurnEvent` on this base.
+ * - {@link AnnouncementsPreview} (F2) shows only the caller-requester's
+ * announcements on a shared target thread (`requester == self`).
+ */
+
+import { describe, it, expect } from "vitest";
+import { render, screen, act } from "@testing-library/react";
+
+import type { DomainEvent } from "@/domain";
+import type { Gateways } from "@/ports";
+import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
+import { DIProvider } from "@/app/di";
+import { AnnouncementsProvider } from "./AnnouncementsProvider";
+import { AnnouncementsPreview } from "./AnnouncementsPreview";
+import { TargetAnnouncementsOverlay } from "./TargetAnnouncementsOverlay";
+
+function setup(
+ node: React.ReactNode,
+ opts: { workState?: MockWorkStateGateway } = {},
+) {
+ const system = new MockSystemGateway();
+ const workState = opts.workState;
+ const gateways = { system, workState } as unknown as Gateways;
+ render(
+
+ {node}
+ ,
+ );
+ return { system };
+}
+
+type AnnouncementEvent = Extract
;
+
+function announcement(over: Partial = {}): AnnouncementEvent {
+ return {
+ type: "agentAnnouncement",
+ projectId: "p1",
+ requester: "agent-A",
+ target: "agent-T",
+ ticketId: "t1",
+ text: "je réfléchis…",
+ atMs: 1,
+ ...over,
+ };
+}
+
+function busy(agentId: string, isBusy: boolean): DomainEvent {
+ return { type: "agentBusyChanged", agentId, busy: isBusy };
+}
+
+// The provider subscribes asynchronously (onDomainEvent returns a Promise) and
+// hydration awaits the read-model; flush microtasks before/after emitting.
+async function flush() {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+}
+
+const overlay = () => screen.queryByTestId("target-announcements-overlay");
+
+describe("F3 — overlay lifecycle is the target's busy state", () => {
+ it("mounts on busy:true and shows the streamed announcement as content", async () => {
+ const { system } = setup(
+ ,
+ );
+ await flush();
+ expect(overlay()).toBeNull();
+
+ act(() => {
+ system.emit(busy("agent-T", true));
+ system.emit(announcement({ target: "agent-T", text: "en cours" }));
+ });
+
+ expect(overlay()).not.toBeNull();
+ expect(screen.getByText("en cours")).not.toBeNull();
+ });
+
+ it("mounts on busy:true even before any announcement (banner only)", async () => {
+ const { system } = setup(
+ ,
+ );
+ await flush();
+
+ act(() => system.emit(busy("agent-T", true)));
+
+ expect(overlay()).not.toBeNull();
+ });
+
+ it("RETRACTS on busy:false even when NO completion event is emitted", async () => {
+ // The regression the arbitrage targets: a turn that ends without any
+ // completion signal (interruption / error / crash) must still bring the
+ // overlay down. On develop the only signal is busy — and it suffices.
+ const { system } = setup(
+ ,
+ );
+ await flush();
+
+ act(() => {
+ system.emit(busy("agent-T", true));
+ system.emit(announcement({ target: "agent-T", text: "orphan" }));
+ });
+ expect(overlay()).not.toBeNull();
+
+ act(() => system.emit(busy("agent-T", false)));
+
+ expect(overlay()).toBeNull();
+ });
+
+ it("clears stale content on busy:false so a next turn starts fresh", async () => {
+ const { system } = setup(
+ ,
+ );
+ await flush();
+
+ act(() => {
+ system.emit(busy("agent-T", true));
+ system.emit(announcement({ target: "agent-T", text: "old" }));
+ system.emit(busy("agent-T", false));
+ system.emit(busy("agent-T", true));
+ });
+
+ // New turn: the previous "old" réflexion must not be shown.
+ expect(overlay()).not.toBeNull();
+ expect(screen.queryByText("old")).toBeNull();
+ });
+
+ it("hydrates busy from the reconciled read-model at mount (no live event)", async () => {
+ const workState = new MockWorkStateGateway();
+ workState._setProjectWorkState("p1", {
+ agents: [
+ {
+ agentId: "agent-T",
+ name: "Target",
+ profileId: "prof",
+ busy: { state: "busy", ticket: "t9", sinceMs: 123 },
+ tickets: [],
+ },
+ ],
+ conversations: [],
+ });
+
+ setup(, {
+ workState,
+ });
+ await flush();
+
+ expect(overlay()).not.toBeNull();
+ });
+
+ it("stays down when the read-model reports the agent idle", async () => {
+ const workState = new MockWorkStateGateway();
+ workState._setProjectWorkState("p1", {
+ agents: [
+ {
+ agentId: "agent-T",
+ name: "Target",
+ profileId: "prof",
+ busy: { state: "idle" },
+ tickets: [],
+ },
+ ],
+ conversations: [],
+ });
+
+ setup(, {
+ workState,
+ });
+ await flush();
+
+ expect(overlay()).toBeNull();
+ });
+
+ it("lets a live busy:false win over a stale busy hydration snapshot", async () => {
+ // Hydration seeds only when the agent is unknown; a later live idle event must
+ // still retract (live authority wins).
+ const workState = new MockWorkStateGateway();
+ workState._setProjectWorkState("p1", {
+ agents: [
+ {
+ agentId: "agent-T",
+ name: "Target",
+ profileId: "prof",
+ busy: { state: "busy", ticket: "t9", sinceMs: 1 },
+ tickets: [],
+ },
+ ],
+ conversations: [],
+ });
+
+ const { system } = setup(
+ ,
+ { workState },
+ );
+ await flush();
+ expect(overlay()).not.toBeNull();
+
+ act(() => system.emit(busy("agent-T", false)));
+ expect(overlay()).toBeNull();
+ });
+});
+
+describe("F2 — AnnouncementsPreview (requester filter)", () => {
+ it("shows the requester's own announcements and hides another's", async () => {
+ const { system } = setup();
+ await flush();
+
+ act(() => {
+ system.emit(announcement({ requester: "agent-A", text: "for-A" }));
+ system.emit(
+ announcement({ requester: "agent-B", ticketId: "t2", text: "for-B" }),
+ );
+ });
+
+ expect(screen.getByText("for-A")).not.toBeNull();
+ expect(screen.queryByText("for-B")).toBeNull();
+ });
+
+ it("renders nothing when the requester has no announcements", async () => {
+ setup();
+ await flush();
+ expect(screen.queryByTestId("announcements-preview")).toBeNull();
+ });
+});
diff --git a/frontend/src/features/announcements/AnnouncementsProvider.tsx b/frontend/src/features/announcements/AnnouncementsProvider.tsx
new file mode 100644
index 0000000..acc3371
--- /dev/null
+++ b/frontend/src/features/announcements/AnnouncementsProvider.tsx
@@ -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;
+
+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],
+ );
+}
diff --git a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx
new file mode 100644
index 0000000..ecf0fb8
--- /dev/null
+++ b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx
@@ -0,0 +1,95 @@
+/**
+ * `TargetAnnouncementsOverlay` — F3 of ticket #4.
+ *
+ * Overlays the **target** agent's cell while another agent is talking to it: a
+ * top banner ("un agent est en train de lui parler") and, below, its live
+ * réflexions/annonces scrolling by. Mount/retract is driven by the target's
+ * **busy** state (the single lifecycle authority — `agentBusyChanged` +
+ * read-model hydration), so the overlay comes down at idle even when a turn ends
+ * without any completion event. The announcements are its *content*.
+ *
+ * This is **not** a PTY block: the inter-agent contact runs on a separate
+ * structured session. The overlay is a pure, non-interactive availability veil
+ * (`pointer-events-none`) so it never traps focus or clicks.
+ */
+
+import { useEffect, useRef } from "react";
+
+import { Spinner } from "@/shared";
+import {
+ useHydrateAgentBusy,
+ useTargetAnnouncements,
+} from "./AnnouncementsProvider";
+
+export interface TargetAnnouncementsOverlayProps {
+ /** Project hosting the agent (for read-model busy hydration at mount/reboot). */
+ projectId: string;
+ /** The agent whose cell this overlay guards (the announcement `target`). */
+ agentId: string;
+}
+
+function formatTime(atMs: number): string {
+ if (!Number.isFinite(atMs) || atMs <= 0) return "";
+ try {
+ return new Date(atMs).toLocaleTimeString();
+ } catch {
+ return "";
+ }
+}
+
+export function TargetAnnouncementsOverlay({
+ projectId,
+ agentId,
+}: TargetAnnouncementsOverlayProps) {
+ // Seed busy from the reconciled read-model so a turn already in flight surfaces
+ // when this cell mounts (reboot / late open); live events then take over.
+ useHydrateAgentBusy(projectId, agentId);
+ const { announcements, active } = useTargetAnnouncements(agentId);
+
+ // Follow the tail as new announcements scroll in.
+ const feedRef = useRef(null);
+ useEffect(() => {
+ const el = feedRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ }, [announcements]);
+
+ if (!active) return null;
+
+ return (
+
+ {/* ── Banner ── */}
+
+
+
+ Un agent est en train de lui parler…
+
+
+
+ {/* ── Scrolling réflexions ── */}
+
+ {announcements.map((a) => {
+ const time = formatTime(a.atMs);
+ return (
+
+ {time && {time}}
+
+ {a.text}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/src/features/announcements/announcementsStore.test.ts b/frontend/src/features/announcements/announcementsStore.test.ts
new file mode 100644
index 0000000..8014699
--- /dev/null
+++ b/frontend/src/features/announcements/announcementsStore.test.ts
@@ -0,0 +1,169 @@
+/**
+ * F1 — pure announcements store (ticket #4): indexation, bornage, purge per
+ * busy-cycle, and the requester filter that backs F2 (`requester == self` on a
+ * shared thread).
+ */
+
+import { describe, it, expect } from "vitest";
+
+import {
+ DEFAULT_ANNOUNCEMENT_CAP,
+ announcementsForRequester,
+ announcementsForTarget,
+ appendAnnouncement,
+ emptyAnnouncementIndex,
+ purgeAllForTarget,
+ targetHasActiveAnnouncements,
+ type AnnouncementInput,
+} from "./announcementsStore";
+
+function ann(over: Partial = {}): AnnouncementInput {
+ return {
+ requester: "agent-req",
+ target: "agent-tgt",
+ ticketId: "ticket-1",
+ text: "réflexion",
+ atMs: 1000,
+ ...over,
+ };
+}
+
+describe("appendAnnouncement — indexation", () => {
+ it("indexes by (target, ticketId) and assigns monotonic seq", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(idx, ann({ text: "a", atMs: 1 }));
+ idx = appendAnnouncement(idx, ann({ text: "b", atMs: 2 }));
+
+ const list = announcementsForTarget(idx, "agent-tgt");
+ expect(list.map((a) => a.text)).toEqual(["a", "b"]);
+ expect(list.map((a) => a.seq)).toEqual([1, 2]);
+ expect(idx.nextSeq).toBe(3);
+ });
+
+ it("keeps separate buckets per ticket under the same target", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "x" }));
+ idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "y" }));
+
+ expect(Object.keys(idx.byTarget["agent-tgt"])).toEqual(["t1", "t2"]);
+ expect(announcementsForTarget(idx, "agent-tgt").map((a) => a.text)).toEqual([
+ "x",
+ "y",
+ ]);
+ });
+
+ it("orders a flattened target list by time then seq", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "late", atMs: 30 }));
+ idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "early", atMs: 10 }));
+
+ expect(announcementsForTarget(idx, "agent-tgt").map((a) => a.text)).toEqual([
+ "early",
+ "late",
+ ]);
+ });
+
+ it("does not mutate the previous index (immutability)", () => {
+ const idx0 = emptyAnnouncementIndex();
+ const idx1 = appendAnnouncement(idx0, ann());
+ expect(idx0.byTarget).toEqual({});
+ expect(idx1).not.toBe(idx0);
+ });
+});
+
+describe("appendAnnouncement — bornage", () => {
+ it("bounds a bucket to the cap, dropping the oldest", () => {
+ let idx = emptyAnnouncementIndex();
+ const cap = 3;
+ for (let i = 0; i < 5; i++) {
+ idx = appendAnnouncement(idx, ann({ text: `m${i}`, atMs: i }), cap);
+ }
+ const list = announcementsForTarget(idx, "agent-tgt");
+ expect(list).toHaveLength(cap);
+ // Oldest (m0, m1) dropped; most recent kept.
+ expect(list.map((a) => a.text)).toEqual(["m2", "m3", "m4"]);
+ });
+
+ it("defaults to DEFAULT_ANNOUNCEMENT_CAP", () => {
+ let idx = emptyAnnouncementIndex();
+ for (let i = 0; i < DEFAULT_ANNOUNCEMENT_CAP + 10; i++) {
+ idx = appendAnnouncement(idx, ann({ text: `m${i}`, atMs: i }));
+ }
+ expect(announcementsForTarget(idx, "agent-tgt")).toHaveLength(
+ DEFAULT_ANNOUNCEMENT_CAP,
+ );
+ });
+});
+
+describe("purgeAllForTarget — retract au busy:false (idle, sans final)", () => {
+ it("drops every ticket of the target at once", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(idx, ann({ ticketId: "t1", text: "a" }));
+ idx = appendAnnouncement(idx, ann({ ticketId: "t2", text: "b" }));
+
+ idx = purgeAllForTarget(idx, "agent-tgt");
+
+ expect(targetHasActiveAnnouncements(idx, "agent-tgt")).toBe(false);
+ expect(idx.byTarget["agent-tgt"]).toBeUndefined();
+ });
+
+ it("leaves other targets untouched", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(idx, ann({ target: "T1", text: "x" }));
+ idx = appendAnnouncement(idx, ann({ target: "T2", text: "y" }));
+
+ idx = purgeAllForTarget(idx, "T1");
+
+ expect(targetHasActiveAnnouncements(idx, "T1")).toBe(false);
+ expect(announcementsForTarget(idx, "T2").map((a) => a.text)).toEqual(["y"]);
+ });
+
+ it("is a no-op (stable reference) for an unknown target", () => {
+ const idx = appendAnnouncement(emptyAnnouncementIndex(), ann());
+ expect(purgeAllForTarget(idx, "unknown")).toBe(idx);
+ });
+});
+
+describe("announcementsForRequester — F2 filter (requester == self)", () => {
+ it("returns only the caller's announcements on a shared target thread", () => {
+ // Two requesters (A, B) both talk to the same target on distinct tickets:
+ // the target thread is shared, so filtering must be by requester.
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(
+ idx,
+ ann({ requester: "A", ticketId: "ta", text: "for-A" }),
+ );
+ idx = appendAnnouncement(
+ idx,
+ ann({ requester: "B", ticketId: "tb", text: "for-B" }),
+ );
+
+ expect(announcementsForRequester(idx, "A").map((a) => a.text)).toEqual([
+ "for-A",
+ ]);
+ expect(announcementsForRequester(idx, "B").map((a) => a.text)).toEqual([
+ "for-B",
+ ]);
+ });
+
+ it("scopes to a single ticket when ticketId is given", () => {
+ let idx = emptyAnnouncementIndex();
+ idx = appendAnnouncement(
+ idx,
+ ann({ requester: "A", ticketId: "ta", text: "ta-msg", atMs: 1 }),
+ );
+ idx = appendAnnouncement(
+ idx,
+ ann({ requester: "A", ticketId: "tb", text: "tb-msg", atMs: 2 }),
+ );
+
+ expect(
+ announcementsForRequester(idx, "A", "ta").map((a) => a.text),
+ ).toEqual(["ta-msg"]);
+ });
+
+ it("returns nothing for a requester with no announcements", () => {
+ const idx = appendAnnouncement(emptyAnnouncementIndex(), ann({ requester: "A" }));
+ expect(announcementsForRequester(idx, "someone-else")).toEqual([]);
+ });
+});
diff --git a/frontend/src/features/announcements/announcementsStore.ts b/frontend/src/features/announcements/announcementsStore.ts
new file mode 100644
index 0000000..21f36d7
--- /dev/null
+++ b/frontend/src/features/announcements/announcementsStore.ts
@@ -0,0 +1,161 @@
+/**
+ * Pure store logic for the **inter-agent announcements** feature (ticket #4, F1).
+ *
+ * When an agent contacts another via `idea_ask_agent`, the backend streams the
+ * target's intermediate réflexions as `agentAnnouncement` domain events. These
+ * are **ephemeral** live signals — never persisted — folded here into a bounded
+ * index keyed by `(target, ticketId)`:
+ *
+ * - the **target cell** overlays the announcements destined to it (F3);
+ * - the **requester cell** previews the announcements it is waiting on (F2),
+ * filtered by `requester == self` — the target thread is shared across
+ * requesters, so without that filter one requester would see another's
+ * announcements (Architect contract, point 7).
+ *
+ * The overlay's mount/retract is driven by the target's **busy/idle** state (the
+ * single lifecycle authority — see `AnnouncementsProvider`), not by this index;
+ * the index only carries the scrolling *content*. It is retracted per busy-cycle
+ * (`purgeAllForTarget` on `busy:false`) so a fresh turn starts from a clean feed,
+ * and each bucket is bounded (oldest dropped first) so a long turn cannot grow it
+ * without limit.
+ *
+ * This module is intentionally free of React/Tauri: it is a set of immutable
+ * reducers + selectors, unit-tested in isolation (`announcementsStore.test.ts`).
+ */
+
+/** A single live announcement folded from an `agentAnnouncement` event. */
+export interface Announcement {
+ /** Requesting party: `"user"` or the requesting agent's id. */
+ requester: string;
+ /** Target agent being contacted. */
+ target: string;
+ /** FIFO ticket correlating the inter-agent turn. */
+ ticketId: string;
+ /** Human-readable réflexion/annonce text. */
+ text: string;
+ /** Wall-clock timestamp (epoch-ms) as emitted by the backend. */
+ atMs: number;
+ /**
+ * Monotonic local sequence assigned on ingest. Provides a stable React key and
+ * a total order even when two announcements share the same `atMs`.
+ */
+ seq: number;
+}
+
+/**
+ * Immutable announcement index. `byTarget[target][ticketId]` is a bounded list of
+ * announcements ordered oldest → newest. Empty ticket/target buckets are pruned,
+ * so `byTarget[target]` existing implies the target has ≥1 active ticket.
+ */
+export interface AnnouncementIndex {
+ byTarget: Record>;
+ /** Next sequence number to assign (monotonic, never reused). */
+ nextSeq: number;
+}
+
+/** Default per-`(target, ticketId)` bucket cap (Architect: ~20–50). */
+export const DEFAULT_ANNOUNCEMENT_CAP = 50;
+
+/** An empty index. */
+export function emptyAnnouncementIndex(): AnnouncementIndex {
+ return { byTarget: {}, nextSeq: 1 };
+}
+
+/** Fields carried by an `agentAnnouncement` event (sans the local `seq`). */
+export type AnnouncementInput = Omit;
+
+/**
+ * Appends one announcement to its `(target, ticketId)` bucket, assigning the next
+ * sequence and trimming the bucket to `cap` (dropping the oldest). Returns a new
+ * index; the input is never mutated.
+ */
+export function appendAnnouncement(
+ index: AnnouncementIndex,
+ input: AnnouncementInput,
+ cap: number = DEFAULT_ANNOUNCEMENT_CAP,
+): AnnouncementIndex {
+ const { target, ticketId } = input;
+ const announcement: Announcement = { ...input, seq: index.nextSeq };
+
+ const targetBuckets = index.byTarget[target] ?? {};
+ const prevBucket = targetBuckets[ticketId] ?? [];
+ const grown = [...prevBucket, announcement];
+ // Keep only the last `cap` (bound the live index; oldest fall off first).
+ const bounded = grown.length > cap ? grown.slice(grown.length - cap) : grown;
+
+ return {
+ byTarget: {
+ ...index.byTarget,
+ [target]: { ...targetBuckets, [ticketId]: bounded },
+ },
+ nextSeq: index.nextSeq + 1,
+ };
+}
+
+/**
+ * Retracts **all** tickets of `target` at once — called when the target goes idle
+ * (`agentBusyChanged` `busy:false`), which has no ticket to scope by. Keeps the
+ * overlay content ephemeral per busy-cycle: a fresh turn starts from a clean feed
+ * rather than showing the previous turn's stale réflexions. No-op (stable
+ * reference) when the target has nothing indexed.
+ */
+export function purgeAllForTarget(
+ index: AnnouncementIndex,
+ target: string,
+): AnnouncementIndex {
+ if (!(target in index.byTarget)) return index;
+ const { [target]: _dropped, ...rest } = index.byTarget;
+ return { byTarget: rest, nextSeq: index.nextSeq };
+}
+
+/** Orders two announcements oldest → newest (by `atMs`, tie-broken by `seq`). */
+function byTime(a: Announcement, b: Announcement): number {
+ return a.atMs - b.atMs || a.seq - b.seq;
+}
+
+/**
+ * All active announcements destined to `target`, flattened across its tickets and
+ * ordered oldest → newest. Empty when the target has no active turn (F3 source).
+ */
+export function announcementsForTarget(
+ index: AnnouncementIndex,
+ target: string,
+): Announcement[] {
+ const buckets = index.byTarget[target];
+ if (!buckets) return [];
+ return Object.values(buckets).flat().sort(byTime);
+}
+
+/** Whether `target` has ≥1 active announcement (drives the overlay's mount). */
+export function targetHasActiveAnnouncements(
+ index: AnnouncementIndex,
+ target: string,
+): boolean {
+ const buckets = index.byTarget[target];
+ if (!buckets) return false;
+ return Object.values(buckets).some((bucket) => bucket.length > 0);
+}
+
+/**
+ * All active announcements whose `requester === requester`, across every target
+ * and ticket, ordered oldest → newest. This is the F2 filter: because the target
+ * thread is **shared** across requesters, filtering by `requester` prevents one
+ * requester from previewing another's announcements. When `ticketId` is given the
+ * result is further scoped to that ticket.
+ */
+export function announcementsForRequester(
+ index: AnnouncementIndex,
+ requester: string,
+ ticketId?: string,
+): Announcement[] {
+ const out: Announcement[] = [];
+ for (const buckets of Object.values(index.byTarget)) {
+ for (const [tid, bucket] of Object.entries(buckets)) {
+ if (ticketId !== undefined && tid !== ticketId) continue;
+ for (const ann of bucket) {
+ if (ann.requester === requester) out.push(ann);
+ }
+ }
+ }
+ return out.sort(byTime);
+}
diff --git a/frontend/src/features/announcements/index.ts b/frontend/src/features/announcements/index.ts
new file mode 100644
index 0000000..869e466
--- /dev/null
+++ b/frontend/src/features/announcements/index.ts
@@ -0,0 +1,19 @@
+/**
+ * Inter-agent announcements feature (ticket #4) — live, ephemeral overlay/preview
+ * of the réflexions streamed while one agent talks to another.
+ */
+
+export {
+ AnnouncementsProvider,
+ useTargetAnnouncements,
+ useRequesterAnnouncements,
+} from "./AnnouncementsProvider";
+export {
+ TargetAnnouncementsOverlay,
+ type TargetAnnouncementsOverlayProps,
+} from "./TargetAnnouncementsOverlay";
+export {
+ AnnouncementsPreview,
+ type AnnouncementsPreviewProps,
+} from "./AnnouncementsPreview";
+export * from "./announcementsStore";
diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx
index 2637afa..c9ee6f6 100644
--- a/frontend/src/features/layout/LayoutGrid.tsx
+++ b/frontend/src/features/layout/LayoutGrid.tsx
@@ -34,9 +34,37 @@ import {
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { useProjectWorkState } from "@/features/workstate/useProjectWorkState";
+import {
+ TargetAnnouncementsOverlay,
+ useTargetAnnouncements,
+} from "@/features/announcements";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
+/**
+ * Full-cell veil exclusion (ticket #4, Architect arbitrage): the write-portal
+ * veil (delegation injection into the PTY) and the F3 target overlay (another
+ * agent is talking to this one) share the same relative container and both cover
+ * `inset:0`. On the inter-agent injection path the write-portal `overlay` flag and
+ * the target's `busy` state rise *together*, which would stack two veils with
+ * near-duplicate banners.
+ *
+ * Rule: exactly one veil at a time, F3 has priority. So the write-portal veil is
+ * shown only for a pinned agent, when the portal asks for it, **and** the target
+ * is not busy-active (F3 owns the cell then). When busy is not yet set — the brief
+ * window before the mediator marks it — the write-portal veil is the fallback.
+ *
+ * Purely visual: this gates rendering only. The keystroke suspension during
+ * injection (`portal.isSuspended()` in `TerminalView`) is untouched.
+ */
+export function shouldShowWritePortalVeil(
+ agentPinned: boolean,
+ writePortalOverlay: boolean,
+ busyActive: boolean,
+): boolean {
+ return agentPinned && writePortalOverlay && !busyActive;
+}
+
interface LayoutGridProps {
/** Project whose layout to render. */
projectId: string;
@@ -313,6 +341,10 @@ function LeafView({
// Build the terminal opener based on whether an agent is pinned.
const agentId = agent ?? null;
+ // F3 lifecycle state (busy) for this cell's agent — shared here so the
+ // write-portal veil can be mutually excluded with the F3 overlay (exactly one
+ // full-cell veil at a time, F3 first). Same source the overlay itself reads.
+ const { active: busyActive } = useTargetAnnouncements(agentId ?? "");
const agentWork = agentId
? workState?.agents.find((row) => row.agentId === agentId)
: undefined;
@@ -836,8 +868,9 @@ function LeafView({
/>
{/* Write-portal overlay (ARCHITECTURE §20.3 step b/e): while a delegation
is being injected into the agent's PTY, a grey veil with a centred
- message sits above the terminal. Only ever shown for an agent cell. */}
- {agentId != null && overlay && (
+ message sits above the terminal. Only ever shown for an agent cell —
+ and never together with the F3 overlay (exactly one veil, F3 first). */}
+ {shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
)}
+ {/* F3 (ticket #4): while another agent is talking to THIS agent (target),
+ an availability veil showing its live réflexions. Driven by the target's
+ busy state (agentBusyChanged + read-model hydration), never by the raw
+ PTY — it retracts at idle even when a turn ends without a completion
+ event. Self-guards on `active` (busy) and renders null otherwise. */}
+ {agentId != null && (
+
+ )}
{busyNotice && (
+ agentPinned && busyActive;
+
+describe("shouldShowWritePortalVeil — veil exclusion", () => {
+ it("hides the write-portal veil while F3 is active (busy), whatever the portal says", () => {
+ // The nominal inter-agent injection path: overlay AND busy rise together.
+ expect(shouldShowWritePortalVeil(true, true, true)).toBe(false);
+ // Busy without a portal request: still no write-portal veil.
+ expect(shouldShowWritePortalVeil(true, false, true)).toBe(false);
+ });
+
+ it("shows the write-portal veil as the fallback when busy is not (yet) set", () => {
+ expect(shouldShowWritePortalVeil(true, true, false)).toBe(true);
+ });
+
+ it("shows no write-portal veil when the portal is idle", () => {
+ expect(shouldShowWritePortalVeil(true, false, false)).toBe(false);
+ });
+
+ it("never shows the write-portal veil for a plain (agent-less) cell", () => {
+ expect(shouldShowWritePortalVeil(false, true, true)).toBe(false);
+ expect(shouldShowWritePortalVeil(false, true, false)).toBe(false);
+ });
+
+ it("NEVER renders both veils simultaneously (exhaustive truth table)", () => {
+ for (const agentPinned of [false, true]) {
+ for (const overlay of [false, true]) {
+ for (const busyActive of [false, true]) {
+ const writePortal = shouldShowWritePortalVeil(
+ agentPinned,
+ overlay,
+ busyActive,
+ );
+ const f3 = showsF3(agentPinned, busyActive);
+ // The safety invariant: the two full-cell veils are mutually exclusive.
+ expect(writePortal && f3).toBe(false);
+ // And F3 has priority: whenever it is up, the write-portal veil is down.
+ if (f3) expect(writePortal).toBe(false);
+ }
+ }
+ }
+ });
+});