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:
2026-07-04 19:50:13 +02:00
parent aa5a4f30ae
commit 50b2adfde3
12 changed files with 1083 additions and 2 deletions

View File

@ -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) && (
<div
data-testid="write-portal-overlay"
role="status"
@ -868,6 +901,14 @@ function LeafView({
</span>
</div>
)}
{/* 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 && (
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
)}
</div>
{busyNotice && (
<div

View File

@ -0,0 +1,58 @@
/**
* Ticket #4 — mutual exclusion of the two full-cell veils in a `LeafView`
* (Architect arbitrage `ticket4-overlay-composition-leafview`).
*
* `shouldShowWritePortalVeil` is the exact predicate the JSX uses to gate the
* write-portal veil; the F3 target overlay self-gates on the *same* `busyActive`
* source (`useTargetAnnouncements(agentId).active`). Testing this predicate
* therefore proves the on-screen invariant: exactly one veil at a time, F3 first.
*/
import { describe, it, expect } from "vitest";
import { shouldShowWritePortalVeil } from "./LayoutGrid";
/** Mirrors the F3 overlay's own render guard (`active` ⇔ busy). */
const showsF3 = (agentPinned: boolean, busyActive: boolean) =>
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);
}
}
}
});
});