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;
+}