From e462136df249458438ba5c1ceec86dadbdfc78dc Mon Sep 17 00:00:00 2001 From: Blomios Date: Sat, 20 Jun 2026 08:56:26 +0200 Subject: [PATCH] =?UTF-8?q?feat(terminals):=20durcit=20le=20portail=20d'?= =?UTF-8?q?=C3=A9criture=20de=20d=C3=A9l=C3=A9gation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fiabilise la livraison des délégations inter-agents dans le terminal : écriture par chunks UTF-8 bornés (512 o, délai 8 ms) pour éviter les comportements de paste/drop des TUI sur agents froids, et réconciliation de l'attachement front (frontAttachedAgentRef / reconcileFrontAttachment) pour ne reporter « front attaché » qu'une fois les DelegationReady réellement consommés. Tests vitest associés. Co-Authored-By: Claude Opus 4.8 --- .../src/features/projects/ProjectsView.tsx | 24 ++-- .../terminals/useWritePortal.test.tsx | 39 ++++++ .../src/features/terminals/useWritePortal.ts | 112 ++++++++++++++++-- 3 files changed, 149 insertions(+), 26 deletions(-) diff --git a/frontend/src/features/projects/ProjectsView.tsx b/frontend/src/features/projects/ProjectsView.tsx index 4fd30ef..4993699 100644 --- a/frontend/src/features/projects/ProjectsView.tsx +++ b/frontend/src/features/projects/ProjectsView.tsx @@ -28,11 +28,11 @@ * - getByRole("alert") — error display */ -import { useState } from "react"; +import { useEffect, useState } from "react"; import type { LayoutInfo } from "@/domain"; import { LayoutGrid, LayoutTabs } from "@/features/layout"; -import { AgentsPanel, ResumeProjectPanel } from "@/features/agents"; +import { AgentsPanel } from "@/features/agents"; import { TemplatesPanel } from "@/features/templates"; import { SkillsPanel } from "@/features/skills"; import { MemoryPanel } from "@/features/memory"; @@ -79,6 +79,15 @@ export function ProjectsView() { const active = vm.openTabs.find((t) => t.id === vm.activeTabId) ?? null; + // Reset the active layout whenever the active project changes. `activeLayout` + // is only repopulated asynchronously by `LayoutTabs` (which re-fetches the new + // project's layouts). Without this reset, the stale id of the *previous* + // project would be handed to `LayoutGrid`/`GitGraphView` during the gap, and + // loading it against the new project's store fails with "not found: layout X". + useEffect(() => { + setActiveLayout(null); + }, [active?.id]); + const activeLayoutKind = activeLayout?.kind ?? "terminal"; const canCreate = name.trim().length > 0 && root.trim().length > 0 && !vm.busy; @@ -355,17 +364,6 @@ export function ProjectsView() { )} - - {/* ── Reopen resume panel (§15.2) ── - Mounted per active project (keyed by id) so it re-pulls the resumable - inventory on every open/switch; it renders nothing when empty. */} - {active && ( - - )} ); } diff --git a/frontend/src/features/terminals/useWritePortal.test.tsx b/frontend/src/features/terminals/useWritePortal.test.tsx index 7d4eecf..3cdbc20 100644 --- a/frontend/src/features/terminals/useWritePortal.test.tsx +++ b/frontend/src/features/terminals/useWritePortal.test.tsx @@ -120,6 +120,25 @@ describe("useWritePortal (§20)", () => { ]); }); + it("chunks long delegated text before submit and acks after the submit", async () => { + const { system, input, view } = setup(); + const { handle, writes } = makeHandle(); + act(() => view.result.current.portal.bindHandle(handle)); + const text = `début ${"x".repeat(1200)} fin`; + + await act(async () => { + system.emit(PROFILELESS("t1", text)); + await vi.runAllTimersAsync(); + }); + + expect(writes.length).toBeGreaterThan(2); + expect(writes[writes.length - 1]).toBe("\r"); + expect(writes.slice(0, -1).join("")).toBe(text); + expect(input.delivered).toEqual([ + { projectId: "p1", agentId: "ag1", ticket: "t1" }, + ]); + }); + it("K=2 race → exactly two \\x7f backspaces before the text (never Ctrl-U)", async () => { const { system, view } = setup(); const { handle, writes } = makeHandle(); @@ -255,6 +274,26 @@ describe("useWritePortal (§20)", () => { expect(input.delivered.length).toBe(1); }); + it("reports front attachment only after the delegation subscription is ready", async () => { + const { input, view } = setup(); + const { handle } = makeHandle(); + + act(() => view.result.current.portal.bindHandle(handle)); + expect(input.frontAttached).toEqual([]); + + await act(async () => { + await Promise.resolve(); + }); + + expect(input.frontAttached).toEqual([{ agentId: "ag1", attached: true }]); + + act(() => view.result.current.portal.unbindHandle()); + expect(input.frontAttached).toEqual([ + { agentId: "ag1", attached: true }, + { agentId: "ag1", attached: false }, + ]); + }); + it("is inert for a plain (agent-less) cell", async () => { const { system, view } = setup(null); const { handle, writes } = makeHandle(); diff --git a/frontend/src/features/terminals/useWritePortal.ts b/frontend/src/features/terminals/useWritePortal.ts index 616bf26..aeb049f 100644 --- a/frontend/src/features/terminals/useWritePortal.ts +++ b/frontend/src/features/terminals/useWritePortal.ts @@ -44,10 +44,19 @@ import { useGateways } from "@/app/di"; /** Default submit sequence when the profile omits one (paste-detection esquive). */ const DEFAULT_SUBMIT_SEQUENCE = "\r"; -/** Default delay (ms) between text and submit-sequence writes. */ -const DEFAULT_SUBMIT_DELAY_MS = 60; +/** + * Default delay (ms) between text and submit-sequence writes. + * + * Keep this conservative: Codex's interactive TUI can otherwise keep a delegated + * prompt in the input editor until the user presses Enter manually. + */ +const DEFAULT_SUBMIT_DELAY_MS = 350; /** Anti-flash overlay floor (ms) from step (b). */ const OVERLAY_FLOOR_MS = 2000; +/** Max UTF-8 bytes per delegation write chunk. */ +const DELEGATION_WRITE_CHUNK_BYTES = 512; +/** Delay between delegation chunks, to avoid TUI paste/drop behaviour on cold agents. */ +const DELEGATION_WRITE_CHUNK_DELAY_MS = 8; /** A pending delegation kept in the local FIFO. */ interface PendingDelegation { @@ -99,6 +108,8 @@ export function useWritePortal( const handleRef = useRef(null); const suspendedRef = useRef(false); // relay suspended while injecting const injectingRef = useRef(false); // a handshake is in flight + const subscribedAgentRef = useRef(null); + const frontAttachedAgentRef = useRef(null); // Stable refs to the gateways used inside the (non-reactive) handshake. const inputRef = useRef(input); @@ -110,6 +121,28 @@ export function useWritePortal( const projectIdRef = useRef(projectId); projectIdRef.current = projectId; + // Backend routing must only switch to the frontend path once this cell can + // actually consume DelegationReady events. If we report "front attached" + // immediately at bindHandle time, a delegation emitted before the async event + // subscription is installed is lost: the backend publishes an event nobody is + // listening to, and it also skips the headless PTY write fallback. + const reconcileFrontAttachment = useRef<() => void>(() => {}); + reconcileFrontAttachment.current = () => { + const desired = handleRef.current && subscribedAgentRef.current + ? subscribedAgentRef.current + : null; + const previous = frontAttachedAgentRef.current; + if (previous === desired) return; + + if (previous) { + void inputRef.current?.setFrontAttached(previous, false).catch(() => {}); + } + if (desired) { + void inputRef.current?.setFrontAttached(desired, true).catch(() => {}); + } + frontAttachedAgentRef.current = desired; + }; + // ── (3)+(4): the handshake, attempted whenever a boundary may have opened ── const tryInject = useRef<() => void>(() => {}); tryInject.current = () => { @@ -147,7 +180,10 @@ export function useWritePortal( // (d) write the text WITHOUT a trailing newline, then the submit // sequence after the profile's delay (esquive de la paste-detection). - await handle.write(encoder.encode(head.text)); + // Long delegated prompts are chunked: Codex's TUI can otherwise treat a + // large single write like a paste and occasionally keep/drop the tail on + // cold-started agents before Enter is received. + await writeDelegationText(handle, head.text); const delay = head.submitDelayMs ?? DEFAULT_SUBMIT_DELAY_MS; await sleep(delay); const submit = head.submitSequence ?? DEFAULT_SUBMIT_SEQUENCE; @@ -176,14 +212,19 @@ export function useWritePortal( // ── (2): subscribe to delegationReady for THIS agent and enqueue ─────────── useEffect(() => { - if (!system || !agentId) return; + if (!system || !agentId) { + subscribedAgentRef.current = null; + reconcileFrontAttachment.current(); + return; + } let unsubscribe: (() => void) | undefined; let cancelled = false; + const subscribedAgent = agentId; void system .onDomainEvent((event: DomainEvent) => { if (event.type !== "delegationReady") return; - if (event.agentId !== agentId) return; + if (event.agentId !== subscribedAgent) return; queueRef.current.push({ ticket: event.ticket, text: event.text, @@ -195,12 +236,25 @@ export function useWritePortal( }) .then((un) => { if (cancelled) un(); - else unsubscribe = un; + else { + unsubscribe = un; + subscribedAgentRef.current = subscribedAgent; + reconcileFrontAttachment.current(); + } }); return () => { cancelled = true; unsubscribe?.(); + if (subscribedAgentRef.current === subscribedAgent) { + subscribedAgentRef.current = null; + } + if (frontAttachedAgentRef.current === subscribedAgent) { + void inputRef.current?.setFrontAttached(subscribedAgent, false).catch(() => {}); + frontAttachedAgentRef.current = null; + } else { + reconcileFrontAttachment.current(); + } }; }, [system, agentId]); @@ -225,11 +279,10 @@ export function useWritePortal( }, bindHandle(handle: TerminalHandle) { handleRef.current = handle; - // Tell the backend a frontend cell is now mounted for this agent, so the - // mediator routes its turns through `delegationReady` (this portal writes) - // rather than writing the PTY itself (the headless path for cell-less agents). - const agent = agentIdRef.current; - if (agent) void inputRef.current.setFrontAttached(agent, true).catch(() => {}); + // Tell the backend a frontend cell is mounted only after the event + // subscription is ready. Until then, the backend keeps the headless PTY + // write fallback, so a delegation cannot disappear during mount. + reconcileFrontAttachment.current(); // The PTY just became available — a queued delegation may be injectable. tryInject.current(); }, @@ -237,8 +290,7 @@ export function useWritePortal( handleRef.current = null; // The cell is gone — let the backend fall back to headless delivery so a // delegation arriving while this agent has no live cell is not lost. - const agent = agentIdRef.current; - if (agent) void inputRef.current.setFrontAttached(agent, false).catch(() => {}); + reconcileFrontAttachment.current(); }, }), [], @@ -251,3 +303,37 @@ export function useWritePortal( function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } + +async function writeDelegationText( + handle: TerminalHandle, + text: string, +): Promise { + const chunks = splitUtf8Chunks(text, DELEGATION_WRITE_CHUNK_BYTES); + for (let i = 0; i < chunks.length; i += 1) { + await handle.write(encoder.encode(chunks[i])); + if (i + 1 < chunks.length) { + await sleep(DELEGATION_WRITE_CHUNK_DELAY_MS); + } + } +} + +function splitUtf8Chunks(text: string, maxBytes: number): string[] { + if (text.length === 0) return [""]; + const chunks: string[] = []; + let current = ""; + let currentBytes = 0; + + for (const ch of text) { + const bytes = encoder.encode(ch).byteLength; + if (current.length > 0 && currentBytes + bytes > maxBytes) { + chunks.push(current); + current = ""; + currentBytes = 0; + } + current += ch; + currentBytes += bytes; + } + + if (current.length > 0) chunks.push(current); + return chunks; +}