feat(agent): conversation par paire + entrée médiée + pivot terminal/MCP

Coeur inter-agents consolidé et surface front réalignée sur la décision
"terminal natif PTY, pas d'UI chat" (Option 1).

Domaine
- nouveaux modules conversation, mailbox, input, fileguard (ports + types)
- orchestrator/profile/events étendus (conversation par paire, FIFO)

Application / Infrastructure
- orchestrator/service + context_guard : sérialisation FIFO par agent,
  garde RW mémoire/contexte, dispatch ask/reply
- adapters in-memory conversation / mailbox / input / fileguard
- registry session + lifecycle agent durcis (1 agent = 1 session vivante)
- outils MCP idea_* alignés sur le nouveau dispatch

Frontend
- MediatedInput + useAgentBusy : entrée utilisateur médiée par IdeA,
  terminal = vue sortie inchangée
- suppression de la vue chat structurée (AgentChatView) — abandonnée
- adapter input + ports mis à jour

Divers
- .ideai/ : mémoire projet + briefs de cadrage versionnés ;
  requests/ runtime ignoré ; agents projet réels (DevBackend/DevFrontend/QA)

Tests : Rust (domain/application/infrastructure/app-tauri) + front (346) verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 07:33:04 +02:00
parent 5f45c22941
commit eca2ba95c4
61 changed files with 9491 additions and 2512 deletions

View File

@ -18,7 +18,7 @@
import { useEffect, useRef, useState } from "react";
import type { Agent, CellKind } from "@/domain";
import type { Agent } from "@/domain";
import type { LayoutNode } from "@/domain";
import type {
ConversationDetails,
@ -26,8 +26,12 @@ import type {
OpenTerminalOptions,
TerminalHandle,
} from "@/ports";
import { ResumeConversationPopup, TerminalView } from "@/features/terminals";
import { AgentChatView } from "@/features/chat";
import {
MediatedInput,
ResumeConversationPopup,
TerminalView,
useAgentBusy,
} from "@/features/terminals";
import { useGateways } from "@/app/di";
import { leaves, normalizeWeights, resizeAdjacent } from "./layout";
import { useLayout, type LayoutViewModel } from "./useLayout";
@ -144,17 +148,6 @@ interface LeafViewProps {
visibleNodeIds: Set<string>;
}
/**
* Remembers each live session's {@link CellKind} (`"pty"` | `"chat"`) by session
* id, so that after a navigation/layout change re-mounts a leaf — when only the
* persisted session id is known and a re-attach (not a re-launch) is due — the
* grid can still route to the right view (`AgentChatView` vs `TerminalView`)
* without re-spawning. Populated at launch from the handle's derived `cellKind`
* (ARCHITECTURE §17.6). Module-scoped (survives unmount); a closed session's
* id is never reused, so stale entries are harmless.
*/
const cellKindBySession = new Map<string, CellKind>();
/**
* A launch deferred by the resume popup (T7): TerminalView asked the opener to
* launch (fresh open or reattach-failed fallback) for a cell that carries a
@ -210,6 +203,11 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
const { agent: agentGateway, system } = useGateways();
// Per-agent busy map (lot F1) feeds the mediated input strip: while the agent
// is processing a turn, "Envoyer" is dimmed (but the enqueue stays open) and
// "Interrompre" is active. Absent key ⇒ idle.
const busyMap = useAgentBusy();
// Load the project's agents for the dropdown.
const [agents, setAgents] = useState<Agent[]>([]);
useEffect(() => {
@ -330,35 +328,13 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
const [pendingResume, setPendingResume] = useState<PendingResume | null>(null);
const [resumeDetails, setResumeDetails] = useState<ConversationDetails | null>(null);
// ── Cell kind routing (§17.6) ─────────────────────────────────────────────
// Which view this agent cell renders: a structured chat (`AgentChatView`) or a
// raw terminal (`TerminalView`). `null` until known. It is derived from the
// launched session's `cellKind`; on a re-mount with a persisted session we
// recover it from the module-level cache so a chat cell repaints as chat
// (re-attach, not re-spawn). Plain (agent-less) cells are always terminals.
const [cellKind, setCellKind] = useState<CellKind | null>(() =>
agent && session ? (cellKindBySession.get(session) ?? null) : null,
);
// The chat session id once a chat launch resolved — passed to `AgentChatView`
// immediately on the view swap, independent of the layout-persistence round
// trip (so the chat re-attaches to the just-spawned session, never re-spawns).
const [chatSessionId, setChatSessionId] = useState<string | null>(
agent && session && cellKindBySession.get(session) === "chat"
? session
: null,
);
// Reset the derived routing when the pinned agent changes (a different agent —
// or unpinning to a plain cell — may be a different cell kind). We re-seed from
// the persisted session's cached kind so a remount of a known chat cell stays a
// chat (re-attach, not re-spawn); otherwise routing falls back to a terminal
// until the next launch reports the kind.
useEffect(() => {
const known = agent && session ? cellKindBySession.get(session) : undefined;
setCellKind(known ?? null);
setChatSessionId(known === "chat" ? session : null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent]);
// ── Cell routing (Option 1 — Terminal + MCP) ──────────────────────────────
// Every agent cell renders the raw {@link TerminalView}: the human view is the
// native interactive PTY (live reasoning + Échap are native CLI behaviours,
// zero model parsing), and cross-model delegation flows through MCP tools, not
// a structured chat view. The former `AgentChatView`/`cellKind:"chat"` routing
// has been removed (F-2 cleanup); any `cellKind` field still emitted by the
// backend on the wire is simply ignored.
/** Performs the actual launch and persists any assigned id (T4b loop). */
const doLaunch = async (
@ -398,12 +374,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
if (handle.assignedConversationId) {
void vm.setCellConversation(id, handle.assignedConversationId);
}
// Record the derived cell kind so re-mounts (after navigation) route to the
// right view without re-spawning (§17.6).
const kind = handle.cellKind ?? "pty";
cellKindBySession.set(handle.sessionId, kind);
if (kind === "chat") setChatSessionId(handle.sessionId);
setCellKind(kind);
return handle;
};
@ -455,21 +425,6 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
agentGateway.reattach(sessionId, onData)
: undefined;
// ── Chat cell callbacks (§17.6) ───────────────────────────────────────────
// Only used when this cell renders as a structured chat. `launch` spawns the
// session (reusing the same `doLaunch` as the terminal path, so the singleton
// invariant and conversation persistence are shared); `reattach`/`send` map to
// the chat-specific gateway methods.
const chatLaunch = (): Promise<string> =>
// A chat launch produces no PTY output; the byte sink is a no-op.
doLaunch({ cwd, rows: 0, cols: 0 }, () => {}, conversationId ?? undefined).then(
(handle) => {
setChatSessionId(handle.sessionId);
void vm.setSession(id, handle.sessionId);
return handle.sessionId;
},
);
return (
<div
data-testid="layout-leaf"
@ -596,35 +551,47 @@ function LeafView({ id, session, agent, conversationId, agentWasRunning, cwd, vm
</button>
)}
</div>
{/* Route by derived cell kind (§17.6): a structured AI cell renders the
chat view, every other cell (plain terminal or a TUI/PTY agent) keeps
the unchanged xterm path. Re-key on the agent so the right view/opener
is captured at mount; the persisted session drives re-attach (never a
re-spawn) when navigating. */}
{agentGateway && agentId && cellKind === "chat" ? (
<AgentChatView
key={`${id}-${agentId}-chat`}
launch={chatLaunch}
reattach={(sid, onReply) => agentGateway.reattachChat(sid, onReply)}
send={(sid, prompt, onReply) =>
agentGateway.sendPrompt(sid, prompt, onReply)
}
sessionId={chatSessionId ?? session}
onSessionId={(sid) => {
setChatSessionId(sid);
void vm.setSession(id, sid);
}}
/>
) : (
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
/>
)}
{/* Option 1 (Terminal + MCP): every cell — plain or agent — renders the
raw xterm {@link TerminalView}. Agent cells are interactive PTYs; their
cross-model delegation happens through MCP tools, not a chat view.
Re-key on the agent so the right opener is captured at mount; the
persisted session drives re-attach (never a re-spawn) when navigating.
Lot F2 (cadrage §4.2): in an **agent** cell the terminal is output-only
for the human (`agentMode`) and the {@link MediatedInput} strip is
mounted **under** it — keystrokes route through IdeA, not the PTY. A
**plain** cell keeps the raw-shell behaviour (keystrokes → PTY) and has
no input strip. We stack terminal + strip in a column so the strip sits
beneath the live output. */}
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
minHeight: 0,
minWidth: 0,
}}
>
<div style={{ flex: "1 1 auto", minHeight: 0, minWidth: 0 }}>
<TerminalView
key={`${id}-${agentId ?? "plain"}`}
cwd={cwd}
open={terminalOpener}
reattach={reattachOpener}
sessionId={session}
onSessionId={(sid) => void vm.setSession(id, sid)}
agentMode={agentId != null}
/>
</div>
{agentId != null && (
<MediatedInput
projectId={projectId}
agentId={agentId}
busy={busyMap[agentId] ?? false}
/>
)}
</div>
{busyNotice && (
<div
role="status"