diff --git a/frontend/src/features/agents/CustomAgentChatView.tsx b/frontend/src/features/agents/CustomAgentChatView.tsx index 1c691a9d..78aa665b 100644 --- a/frontend/src/features/agents/CustomAgentChatView.tsx +++ b/frontend/src/features/agents/CustomAgentChatView.tsx @@ -31,6 +31,7 @@ import type { SlashCommand, } from "@/domain"; import { useGateways } from "@/app/di"; +import { AnnouncementsPreview } from "@/features/announcements"; import { usePluginRuntime } from "@/features/plugins/PluginRuntimeProvider"; import { markProjectCliInteractionFinished, @@ -127,6 +128,10 @@ interface ChatErrorState { openFailure: boolean; } +interface RequesterDelegationState { + active: boolean; +} + function describe(e: unknown): string { if (e && typeof e === "object" && "message" in e) { return String((e as GatewayError).message); @@ -842,7 +847,7 @@ export function CustomAgentChatView({ onSessionId, onConversationId, }: CustomAgentChatViewProps) { - const { agent, profile: profileGateway, system } = useGateways(); + const { agent, profile: profileGateway, system, workState } = useGateways(); const pluginRuntime = usePluginRuntime(); const initialConversationCacheKey = conversationCacheKey( projectId, @@ -1013,6 +1018,33 @@ export function CustomAgentChatView({ [agentId, conversationId, projectId], ); const displayTurns = turns; + const [requesterDelegation, setRequesterDelegation] = + useState({ active: false }); + + const refreshRequesterDelegation = useCallback(async () => { + if (!workState) { + setRequesterDelegation({ active: false }); + return; + } + try { + const state = await workState.getProjectWorkState(projectId); + const target = state.agents.find((row) => + row.tickets.some( + (ticket) => + ticket.status === "inProgress" && + ticket.source.kind === "agent" && + ticket.source.agentId === agentId, + ), + ); + setRequesterDelegation( + target + ? { active: true } + : { active: false }, + ); + } catch { + setRequesterDelegation({ active: false }); + } + }, [agentId, projectId, workState]); const clearPersistedDraft = useCallback(async () => { if (!draftScope) return; @@ -1034,6 +1066,36 @@ export function CustomAgentChatView({ setDraft(text); }, []); + useEffect(() => { + void refreshRequesterDelegation(); + }, [refreshRequesterDelegation]); + + useEffect(() => { + if (!system?.onDomainEvent) return; + let unsubscribe: (() => void) | undefined; + let cancelled = false; + void system + .onDomainEvent((event) => { + if ( + event.type === "agentBusyChanged" || + event.type === "delegationReady" || + event.type === "orchestratorRequestProcessed" || + event.type === "agentInboxChanged" || + event.type === "agentWakeChanged" + ) { + void refreshRequesterDelegation(); + } + }) + .then((un) => { + if (cancelled) un(); + else unsubscribe = un; + }); + return () => { + cancelled = true; + unsubscribe?.(); + }; + }, [refreshRequesterDelegation, system]); + useEffect(() => { mountedRef.current = true; return () => { @@ -1914,7 +1976,7 @@ export function CustomAgentChatView({
+
+ +
{opening && displayTurns.length === 0 ? (
diff --git a/frontend/src/features/announcements/AnnouncementsPreview.tsx b/frontend/src/features/announcements/AnnouncementsPreview.tsx index b089a8da..7beb07a9 100644 --- a/frontend/src/features/announcements/AnnouncementsPreview.tsx +++ b/frontend/src/features/announcements/AnnouncementsPreview.tsx @@ -20,15 +20,21 @@ export interface AnnouncementsPreviewProps { ticketId?: string; /** How many most-recent announcements to show. */ limit?: number; + /** Read-model lifecycle fallback for a requester waiting without live text yet. */ + fallbackActive?: boolean; + /** Stable copy shown when the fallback is active and no live text is available. */ + fallbackText?: string; } export function AnnouncementsPreview({ requester, ticketId, limit = 3, + fallbackActive = false, + fallbackText = "Délégation en cours.", }: AnnouncementsPreviewProps) { const announcements = useRequesterAnnouncements(requester, ticketId); - if (announcements.length === 0) return null; + if (announcements.length === 0 && !fallbackActive) return null; // Most-recent `limit`, newest last (the list is oldest → newest). const recent = announcements.slice(Math.max(0, announcements.length - limit)); @@ -45,17 +51,23 @@ export function AnnouncementsPreview({ /> En attente d'un agent -
    - {recent.map((a) => ( -
  • - {a.text} -
  • - ))} -
+ {recent.length === 0 ? ( +

+ {fallbackText} +

+ ) : ( +
    + {recent.map((a) => ( +
  • + {a.text} +
  • + ))} +
+ )}
); } diff --git a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx index d4d0d74c..82daa87f 100644 --- a/frontend/src/features/announcements/AnnouncementsProvider.test.tsx +++ b/frontend/src/features/announcements/AnnouncementsProvider.test.tsx @@ -95,6 +95,21 @@ describe("F3 — overlay lifecycle is the target's busy state", () => { expect(overlay()).not.toBeNull(); }); + it("shows a stable busy fallback when no live announcement text exists", async () => { + const { system } = setup( + , + ); + await flush(); + + act(() => system.emit(busy("agent-T", true))); + + expect(screen.getByText("Travaille sur une demande de Main.")).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 @@ -209,6 +224,20 @@ describe("F3 — overlay lifecycle is the target's busy state", () => { }); describe("F2 — AnnouncementsPreview (requester filter)", () => { + it("shows a requester-side fallback while waiting without live text", async () => { + setup( + , + ); + await flush(); + + expect(screen.getByTestId("announcements-preview")).not.toBeNull(); + expect(screen.getByText("Délégation en cours.")).not.toBeNull(); + }); + it("shows the requester's own announcements and hides another's", async () => { const { system } = setup(); await flush(); diff --git a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx index 677cf730..f73c983e 100644 --- a/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx +++ b/frontend/src/features/announcements/TargetAnnouncementsOverlay.tsx @@ -26,6 +26,10 @@ export interface TargetAnnouncementsOverlayProps { projectId: string; /** The agent whose cell this overlay guards (the announcement `target`). */ agentId: string; + /** Reconciled busy state from the caller's read-model, used as lifecycle fallback. */ + busyFallbackActive?: boolean; + /** Stable copy shown while the busy cycle has no live announcement content yet. */ + fallbackText?: string; } function formatTime(atMs: number): string { @@ -40,11 +44,14 @@ function formatTime(atMs: number): string { export function TargetAnnouncementsOverlay({ projectId, agentId, + busyFallbackActive = false, + fallbackText = "Travaille sur une demande en cours.", }: 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); + const effectiveActive = active || busyFallbackActive; // Follow the tail as new announcements scroll in. const feedRef = useRef(null); @@ -53,7 +60,7 @@ export function TargetAnnouncementsOverlay({ if (el) el.scrollTop = el.scrollHeight; }, [announcements]); - if (!active) return null; + if (!effectiveActive) return null; return (
- {announcements.map((a) => { - const time = formatTime(a.atMs); - return ( -
- {time && {time}} - - {a.text} - -
- ); - })} + {announcements.length === 0 ? ( +
+ {fallbackText} +
+ ) : ( + announcements.map((a) => { + const time = formatTime(a.atMs); + return ( +
+ {time && {time}} + + {a.text} + +
+ ); + }) + )}
); diff --git a/frontend/src/features/layout/LayoutGrid.chat.test.tsx b/frontend/src/features/layout/LayoutGrid.chat.test.tsx index eeb2d032..ff41c1dc 100644 --- a/frontend/src/features/layout/LayoutGrid.chat.test.tsx +++ b/frontend/src/features/layout/LayoutGrid.chat.test.tsx @@ -96,6 +96,7 @@ async function seeded(profile: AgentProfile): Promise { const profileGateway = new MockProfileGateway(); const terminal = new MockTerminalGateway(); const system = new MockSystemGateway(); + const workState = new MockWorkStateGateway(); await profileGateway.configureProfiles([profile]); const created = await agent.createAgent("p1", { name: "Worker", @@ -114,6 +115,7 @@ async function seeded(profile: AgentProfile): Promise { profile: profileGateway, terminal, system, + workState, } as unknown as Gateways; } @@ -180,6 +182,98 @@ describe("LayoutGrid custom agent CLI (#147)", () => { expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0); }); + it("keeps the target announcement overlay visible in custom mode while busy (#232)", async () => { + const gateways = await seeded(structuredProfile); + const layout = gateways.layout as MockLayoutGateway; + const workState = gateways.workState as MockWorkStateGateway; + const tree = await layout.loadLayout("p1"); + const leaf = leaves(tree)[0]; + const agentId = leaf.agent; + if (!agentId) throw new Error("expected seeded agent"); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom"); + workState._setProjectWorkState("p1", { + agents: [ + { + agentId, + name: "Worker", + profileId: structuredProfile.id, + busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 }, + tickets: [ + { + ticketId: "ticket-232", + conversationId: "conversation-232", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: "main" }, + requesterLabel: "Main", + taskPreview: "Analyse", + taskLen: 7, + }, + ], + }, + ], + conversations: [], + }); + + renderGrid(gateways); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + expect(await screen.findByTestId("target-announcements-overlay")).toBeTruthy(); + expect(screen.getByText("Travaille sur une demande de Main.")).toBeTruthy(); + }); + + it("renders requester-side delegation fallback in custom mode after remount (#232)", async () => { + const gateways = await seeded(structuredProfile); + const layout = gateways.layout as MockLayoutGateway; + const workState = gateways.workState as MockWorkStateGateway; + const tree = await layout.loadLayout("p1"); + const leaf = leaves(tree)[0]; + const requesterId = leaf.agent; + if (!requesterId) throw new Error("expected seeded requester"); + window.localStorage.setItem(`idea.agent-cell-mode.p1.${leaf.id}`, "custom"); + workState._setProjectWorkState("p1", { + agents: [ + { + agentId: requesterId, + name: "Worker", + profileId: structuredProfile.id, + busy: { state: "idle" }, + tickets: [], + }, + { + agentId: "target-agent", + name: "Architect", + profileId: structuredProfile.id, + busy: { state: "busy", ticket: "ticket-232", sinceMs: 1 }, + tickets: [ + { + ticketId: "ticket-232", + conversationId: "conversation-232", + position: 0, + status: "inProgress", + source: { kind: "agent", agentId: requesterId }, + requesterLabel: "Worker", + taskPreview: "Répondre", + taskLen: 8, + }, + ], + }, + ], + conversations: [], + }); + + const view = renderGrid(gateways); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + expect(await screen.findByText("Délégation en cours.")).toBeTruthy(); + + view.unmount(); + renderGrid(gateways); + + expect(await screen.findByTestId("custom-agent-chat-view")).toBeTruthy(); + expect(await screen.findByText("Délégation en cours.")).toBeTruthy(); + }); + it("persists custom CLI composer height per layout cell", async () => { const rectSpy = vi .spyOn(HTMLElement.prototype, "getBoundingClientRect") diff --git a/frontend/src/features/layout/LayoutGrid.tsx b/frontend/src/features/layout/LayoutGrid.tsx index f63f67d9..196047a5 100644 --- a/frontend/src/features/layout/LayoutGrid.tsx +++ b/frontend/src/features/layout/LayoutGrid.tsx @@ -625,7 +625,7 @@ function LeafView({ // 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 { active: announcementBusyActive } = useTargetAnnouncements(agentId ?? ""); // Ticket #54 — local model-server launch state for this cell's pinned agent. // Correlated `agentId → profileId → opencode.localModelServerId → status` via // the shared hook (same reduction as `useAgents`). While the bound server is @@ -768,6 +768,10 @@ function LeafView({ ) ?? []; const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket); const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined; + const busyActive = announcementBusyActive || busyByWorkState; + const targetAnnouncementsFallback = delegatedTicket?.requesterLabel + ? `Travaille sur une demande de ${delegatedTicket.requesterLabel}.` + : "Travaille sur une demande en cours."; const [restartPending, setRestartPending] = useState(false); const [restartError, setRestartError] = useState(false); const [freshRestartedSession, setFreshRestartedSession] = @@ -1561,8 +1565,13 @@ function LeafView({ 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. */} - {!modelServerOverlay && cellMode !== "custom" && agentId != null && ( - + {!modelServerOverlay && agentId != null && ( + )} {/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay (suppresses the write-portal + F3 veils above): while the pinned