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:
@ -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 (
|
||||
<AnnouncementsProvider>
|
||||
<div className="flex h-full flex-col bg-canvas text-content">
|
||||
{/* ── Header ── */}
|
||||
<header className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
@ -129,6 +131,7 @@ export function App() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</AnnouncementsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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[] };
|
||||
|
||||
/**
|
||||
|
||||
@ -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. */}
|
||||
<AnnouncementsPreview requester={a.id} />
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* Profile hot-swap selector (Chantier A). Changing the
|
||||
engine abandons the conversation history → confirmation. */}
|
||||
|
||||
61
frontend/src/features/announcements/AnnouncementsPreview.tsx
Normal file
61
frontend/src/features/announcements/AnnouncementsPreview.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
data-testid="announcements-preview"
|
||||
className="flex flex-col gap-1 rounded-md border border-primary/25 bg-primary/5 px-2.5 py-1.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-[0.65rem] font-medium uppercase tracking-wide text-primary">
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-primary"
|
||||
/>
|
||||
En attente d'un agent
|
||||
</span>
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{recent.map((a) => (
|
||||
<li
|
||||
key={a.seq}
|
||||
className="truncate text-xs text-muted"
|
||||
title={a.text}
|
||||
>
|
||||
{a.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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(
|
||||
<DIProvider gateways={gateways}>
|
||||
<AnnouncementsProvider>{node}</AnnouncementsProvider>
|
||||
</DIProvider>,
|
||||
);
|
||||
return { system };
|
||||
}
|
||||
|
||||
type AnnouncementEvent = Extract<DomainEvent, { type: "agentAnnouncement" }>;
|
||||
|
||||
function announcement(over: Partial<AnnouncementEvent> = {}): 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(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
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(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
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(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
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(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
);
|
||||
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(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
|
||||
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(<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />, {
|
||||
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(
|
||||
<TargetAnnouncementsOverlay projectId="p1" agentId="agent-T" />,
|
||||
{ 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(<AnnouncementsPreview requester="agent-A" />);
|
||||
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(<AnnouncementsPreview requester="agent-A" />);
|
||||
await flush();
|
||||
expect(screen.queryByTestId("announcements-preview")).toBeNull();
|
||||
});
|
||||
});
|
||||
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],
|
||||
);
|
||||
}
|
||||
@ -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<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
const el = feedRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [announcements]);
|
||||
|
||||
if (!active) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="target-announcements-overlay"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="pointer-events-none absolute inset-0 z-20 flex flex-col bg-canvas/85 backdrop-blur-sm"
|
||||
>
|
||||
{/* ── Banner ── */}
|
||||
<div className="flex shrink-0 items-center gap-2 border-b border-border/60 bg-surface/80 px-4 py-2">
|
||||
<Spinner size={14} />
|
||||
<span className="text-sm font-medium text-content">
|
||||
Un agent est en train de lui parler…
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* ── Scrolling réflexions ── */}
|
||||
<div
|
||||
ref={feedRef}
|
||||
className="flex flex-1 flex-col justify-end gap-1.5 overflow-hidden px-6 py-4"
|
||||
>
|
||||
{announcements.map((a) => {
|
||||
const time = formatTime(a.atMs);
|
||||
return (
|
||||
<div
|
||||
key={a.seq}
|
||||
className="flex items-baseline gap-2 text-sm text-muted"
|
||||
>
|
||||
{time && <span className="shrink-0 text-xs text-faint">{time}</span>}
|
||||
<span className="whitespace-pre-wrap break-words text-content/90">
|
||||
{a.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
frontend/src/features/announcements/announcementsStore.test.ts
Normal file
169
frontend/src/features/announcements/announcementsStore.test.ts
Normal file
@ -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> = {}): 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([]);
|
||||
});
|
||||
});
|
||||
161
frontend/src/features/announcements/announcementsStore.ts
Normal file
161
frontend/src/features/announcements/announcementsStore.ts
Normal file
@ -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<string, Record<string, Announcement[]>>;
|
||||
/** 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<Announcement, "seq">;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
19
frontend/src/features/announcements/index.ts
Normal file
19
frontend/src/features/announcements/index.ts
Normal file
@ -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";
|
||||
@ -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
|
||||
|
||||
58
frontend/src/features/layout/overlayExclusion.test.ts
Normal file
58
frontend/src/features/layout/overlayExclusion.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user