feat(chat): livre la CLI custom de chat agent (#147) et corrige Cancel
Implémente la vue chat structurée par cellule agent (toggle TUI/CLI custom, préférence persistée `preferred_view`, reattach live, composer + pièces jointes) avec le socle backend AgentSession/ChatBridge (UserPrompt, cancel_current_turn, routage interrupt_agent, commande cancel_agent_chat). Corrige le bug bloquant relevé par QA : le bouton Cancel de CustomAgentChatView interrompait tout le tour via closeAgentChat au lieu de n'annuler que le tour courant via cancelAgentChat, ce qui tuait la session contrairement au contrat produit validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
97
frontend/src/features/agents/CustomAgentChatView.test.tsx
Normal file
97
frontend/src/features/agents/CustomAgentChatView.test.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DIProvider } from "@/app/di";
|
||||
import type { AgentProfile } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { CustomAgentChatView } from "./CustomAgentChatView";
|
||||
|
||||
const profile: AgentProfile = {
|
||||
id: "structured",
|
||||
name: "Structured Codex",
|
||||
command: "codex",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
};
|
||||
|
||||
describe("CustomAgentChatView", () => {
|
||||
it("cancels only the current turn and keeps the structured session alive", async () => {
|
||||
const agent = {
|
||||
launchAgentChat: vi.fn(),
|
||||
reattachAgentChat: vi.fn(async (sessionId: string) => ({
|
||||
sessionId,
|
||||
scrollback: [],
|
||||
})),
|
||||
sendAgentChat: vi.fn(() => new Promise<void>(() => {})),
|
||||
cancelAgentChat: vi.fn(async () => {}),
|
||||
closeAgentChat: vi.fn(async () => {}),
|
||||
};
|
||||
const onSessionId = vi.fn();
|
||||
|
||||
render(
|
||||
<DIProvider
|
||||
gateways={{
|
||||
agent,
|
||||
system: { pickFile: vi.fn(async () => null) },
|
||||
} as unknown as Gateways}
|
||||
>
|
||||
<CustomAgentChatView
|
||||
projectId="project-1"
|
||||
agentId="agent-1"
|
||||
agentName="Worker"
|
||||
profile={profile}
|
||||
cwd="/repo"
|
||||
nodeId="node-1"
|
||||
sessionId="chat-session-1"
|
||||
conversationId="conversation-1"
|
||||
onSessionId={onSessionId}
|
||||
onConversationId={vi.fn()}
|
||||
/>
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(agent.reattachAgentChat).toHaveBeenCalledWith(
|
||||
"chat-session-1",
|
||||
expect.any(Function),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
target: { value: "first turn" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(agent.sendAgentChat).toHaveBeenCalledWith(
|
||||
"chat-session-1",
|
||||
"first turn",
|
||||
expect.any(Function),
|
||||
),
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(agent.cancelAgentChat).toHaveBeenCalledWith("chat-session-1"),
|
||||
);
|
||||
expect(agent.closeAgentChat).not.toHaveBeenCalled();
|
||||
expect(onSessionId).not.toHaveBeenCalledWith(null);
|
||||
expect(screen.getByText("Tour interrompu.")).toBeTruthy();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
target: { value: "second turn" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Envoyer" }));
|
||||
|
||||
await waitFor(() => expect(agent.sendAgentChat).toHaveBeenCalledTimes(2));
|
||||
expect(agent.sendAgentChat).toHaveBeenLastCalledWith(
|
||||
"chat-session-1",
|
||||
"second turn",
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
388
frontend/src/features/agents/CustomAgentChatView.tsx
Normal file
388
frontend/src/features/agents/CustomAgentChatView.tsx
Normal file
@ -0,0 +1,388 @@
|
||||
/**
|
||||
* Custom agent CLI for structured/headless profiles (#147).
|
||||
*
|
||||
* This is an alternative human view for an agent cell, not a replacement for
|
||||
* the native TUI. It uses the structured chat commands when available and
|
||||
* deliberately does not try to parse PTY bytes.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { AgentProfile, GatewayError, ReplyChunk } from "@/domain";
|
||||
import { useGateways } from "@/app/di";
|
||||
import { Button, Spinner, cn } from "@/shared";
|
||||
|
||||
export interface CustomAgentChatViewProps {
|
||||
projectId: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
profile: AgentProfile;
|
||||
cwd: string;
|
||||
nodeId: string;
|
||||
sessionId: string | null;
|
||||
conversationId: string | null;
|
||||
onSessionId: (sessionId: string | null) => void;
|
||||
onConversationId: (conversationId: string | null) => void;
|
||||
}
|
||||
|
||||
type ChatTurn =
|
||||
| { role: "user"; text: string; attachment?: string }
|
||||
| { role: "agent"; text: string; pending?: boolean }
|
||||
| { role: "tool"; label: string }
|
||||
| { role: "final"; text: string }
|
||||
| { role: "error"; text: string }
|
||||
| { role: "unknown"; text: string };
|
||||
|
||||
function describe(e: unknown): string {
|
||||
if (e && typeof e === "object" && "message" in e) {
|
||||
return String((e as GatewayError).message);
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
function unknownChunkLabel(chunk: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(chunk);
|
||||
} catch {
|
||||
return String(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
function isReplyRecord(chunk: unknown): chunk is Record<string, unknown> {
|
||||
return Boolean(chunk && typeof chunk === "object" && "kind" in chunk);
|
||||
}
|
||||
|
||||
function appendAgentDelta(turns: ChatTurn[], text: string): ChatTurn[] {
|
||||
const next = [...turns];
|
||||
const last = next[next.length - 1];
|
||||
if (last?.role === "agent") {
|
||||
next[next.length - 1] = {
|
||||
role: "agent",
|
||||
text: last.text + text,
|
||||
pending: true,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
next.push({ role: "agent", text, pending: true });
|
||||
return next;
|
||||
}
|
||||
|
||||
function foldChunk(turns: ChatTurn[], raw: unknown): ChatTurn[] {
|
||||
if (!isReplyRecord(raw)) {
|
||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||
}
|
||||
switch (raw.kind) {
|
||||
case "textDelta":
|
||||
return appendAgentDelta(turns, String(raw.text ?? ""));
|
||||
case "toolActivity":
|
||||
return [...turns, { role: "tool", label: String(raw.label ?? "Activité") }];
|
||||
case "final": {
|
||||
const content = String(raw.content ?? "");
|
||||
const next = [...turns];
|
||||
const last = next[next.length - 1];
|
||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||
next.push({ role: "final", text: content });
|
||||
return next;
|
||||
}
|
||||
case "error": {
|
||||
const next = [...turns];
|
||||
const last = next[next.length - 1];
|
||||
if (last?.role === "agent") next[next.length - 1] = { ...last, pending: false };
|
||||
next.push({ role: "error", text: String(raw.message ?? "Erreur agent") });
|
||||
return next;
|
||||
}
|
||||
case "userPrompt":
|
||||
case "UserPrompt":
|
||||
return [...turns, { role: "user", text: String(raw.text ?? raw.prompt ?? "") }];
|
||||
default:
|
||||
return [...turns, { role: "unknown", text: unknownChunkLabel(raw) }];
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomAgentChatView({
|
||||
projectId,
|
||||
agentId,
|
||||
agentName,
|
||||
profile,
|
||||
cwd,
|
||||
nodeId,
|
||||
sessionId,
|
||||
conversationId,
|
||||
onSessionId,
|
||||
onConversationId,
|
||||
}: CustomAgentChatViewProps) {
|
||||
const { agent, system } = useGateways();
|
||||
const [turns, setTurns] = useState<ChatTurn[]>([]);
|
||||
const [currentSession, setCurrentSession] = useState(sessionId);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [attachment, setAttachment] = useState<string | null>(null);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const sessionRef = useRef<string | null>(sessionId);
|
||||
sessionRef.current = currentSession;
|
||||
const onSessionIdRef = useRef(onSessionId);
|
||||
onSessionIdRef.current = onSessionId;
|
||||
const onConversationIdRef = useRef(onConversationId);
|
||||
onConversationIdRef.current = onConversationId;
|
||||
|
||||
const supported = Boolean(
|
||||
profile.structuredAdapter &&
|
||||
agent.launchAgentChat &&
|
||||
agent.reattachAgentChat &&
|
||||
agent.sendAgentChat &&
|
||||
agent.cancelAgentChat &&
|
||||
agent.closeAgentChat,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [turns]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!supported) return;
|
||||
let cancelled = false;
|
||||
const receive = (chunk: ReplyChunk) => {
|
||||
setTurns((prev) => foldChunk(prev, chunk));
|
||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
||||
};
|
||||
|
||||
async function openOrAttach() {
|
||||
setOpening(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (sessionId) {
|
||||
const reattached = await agent.reattachAgentChat!(sessionId, receive);
|
||||
if (cancelled) return;
|
||||
setCurrentSession(reattached.sessionId);
|
||||
setTurns(reattached.scrollback.reduce(foldChunk, [] as ChatTurn[]));
|
||||
return;
|
||||
}
|
||||
const launched = await agent.launchAgentChat!(projectId, agentId, {
|
||||
cwd,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
conversationId: conversationId ?? undefined,
|
||||
nodeId,
|
||||
});
|
||||
if (cancelled) return;
|
||||
setCurrentSession(launched.sessionId);
|
||||
onSessionIdRef.current(launched.sessionId);
|
||||
if (launched.assignedConversationId) {
|
||||
onConversationIdRef.current(launched.assignedConversationId);
|
||||
}
|
||||
// Attach the view so any in-flight chunks can be replayed after launch.
|
||||
await agent.reattachAgentChat!(launched.sessionId, receive).catch(() => {});
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(describe(e));
|
||||
} finally {
|
||||
if (!cancelled) setOpening(false);
|
||||
}
|
||||
}
|
||||
|
||||
void openOrAttach();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
supported,
|
||||
agent,
|
||||
projectId,
|
||||
agentId,
|
||||
cwd,
|
||||
nodeId,
|
||||
sessionId,
|
||||
conversationId,
|
||||
]);
|
||||
|
||||
const canSend = useMemo(
|
||||
() =>
|
||||
supported &&
|
||||
Boolean(currentSession) &&
|
||||
Boolean(draft.trim()) &&
|
||||
!busy &&
|
||||
!opening,
|
||||
[supported, currentSession, draft, busy, opening],
|
||||
);
|
||||
|
||||
async function pickAttachment() {
|
||||
const path = await system.pickFile();
|
||||
if (path) setAttachment(path);
|
||||
}
|
||||
|
||||
async function send() {
|
||||
const text = draft.trim();
|
||||
if (!canSend || !currentSession || !agent.sendAgentChat) return;
|
||||
const prompt = attachment ? `${text}\n\n[Fichier joint: ${attachment}]` : text;
|
||||
setDraft("");
|
||||
setAttachment(null);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setTurns((prev) => [...prev, { role: "user", text, attachment: attachment ?? undefined }]);
|
||||
try {
|
||||
await agent.sendAgentChat(currentSession, prompt, (chunk) => {
|
||||
setTurns((prev) => foldChunk(prev, chunk));
|
||||
if (chunk.kind === "final" || chunk.kind === "error") setBusy(false);
|
||||
});
|
||||
} catch (e) {
|
||||
setBusy(false);
|
||||
setError(describe(e));
|
||||
setTurns((prev) => [...prev, { role: "error", text: describe(e) }]);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
const sid = sessionRef.current;
|
||||
if (!sid || !agent.cancelAgentChat) return;
|
||||
setBusy(false);
|
||||
setOpening(false);
|
||||
setError(null);
|
||||
try {
|
||||
await agent.cancelAgentChat(sid);
|
||||
setTurns((prev) => [...prev, { role: "tool", label: "Tour interrompu." }]);
|
||||
} catch (e) {
|
||||
setError(describe(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="custom-agent-chat-view"
|
||||
className="flex h-full min-h-0 flex-col bg-surface text-content"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-border px-3 py-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{agentName}</div>
|
||||
<div className="truncate text-xs text-muted">
|
||||
CLI custom · {profile.name}
|
||||
</div>
|
||||
</div>
|
||||
{(opening || busy) && (
|
||||
<Button size="sm" variant="danger" onClick={() => void cancel()}>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!supported && (
|
||||
<p role="alert" className="m-3 rounded-md border border-warning/40 bg-warning/10 p-2 text-xs text-warning">
|
||||
CLI custom indisponible pour ce profil ou ce transport. Utilisez la TUI native.
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="m-3 rounded-md border border-danger/40 bg-danger/10 p-2 text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div ref={scrollRef} className="flex min-h-0 flex-1 flex-col gap-2 overflow-auto px-3 py-3">
|
||||
{opening && turns.length === 0 ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted">
|
||||
<Spinner size={14} />
|
||||
<span>Ouverture de la session structurée…</span>
|
||||
</div>
|
||||
) : turns.length === 0 ? (
|
||||
<p className="m-auto text-xs text-muted">
|
||||
Envoyez un message pour démarrer la conversation structurée.
|
||||
</p>
|
||||
) : (
|
||||
turns.map((turn, index) => <ChatBubble key={index} turn={turn} />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 border-t border-border bg-raised/40 p-2">
|
||||
{attachment && (
|
||||
<div className="flex items-center justify-between gap-2 rounded-md border border-border bg-surface px-2 py-1 text-xs text-muted">
|
||||
<span className="truncate">Fichier joint: {attachment}</span>
|
||||
<button type="button" className="text-content" onClick={() => setAttachment(null)}>
|
||||
Retirer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
aria-label={`message CLI custom ${nodeId}`}
|
||||
className={cn(
|
||||
"min-h-10 flex-1 resize-none rounded-md border border-border bg-surface p-2 text-sm text-content outline-none",
|
||||
"focus:border-primary disabled:cursor-not-allowed disabled:opacity-50",
|
||||
)}
|
||||
rows={2}
|
||||
value={draft}
|
||||
disabled={!supported || opening || busy}
|
||||
placeholder="Message à l'agent…"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" disabled={!supported || opening || busy} onClick={() => void pickAttachment()}>
|
||||
Joindre
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSend} loading={busy} onClick={() => void send()}>
|
||||
Envoyer
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatBubble({ turn }: { turn: ChatTurn }) {
|
||||
if (turn.role === "tool") {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<span className="max-w-[80%] rounded-md border border-border bg-raised px-2 py-1 text-xs text-muted">
|
||||
{turn.label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (turn.role === "final") {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[86%] rounded-md border border-success/50 bg-success/10 px-3 py-2 text-sm text-content">
|
||||
<div className="mb-1 text-xs font-semibold text-success">Task Complete</div>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (turn.role === "error" || turn.role === "unknown") {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<div className="max-w-[86%] rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-xs text-danger">
|
||||
{turn.role === "unknown" ? "Chunk inconnu reçu: " : ""}
|
||||
<span className="whitespace-pre-wrap break-words">{turn.text}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const user = turn.role === "user";
|
||||
return (
|
||||
<div className={cn("flex", user ? "justify-start" : "justify-end")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[82%] rounded-lg border px-3 py-2 text-sm text-content",
|
||||
user ? "border-border bg-raised" : "border-primary/25 bg-primary/10",
|
||||
)}
|
||||
>
|
||||
<p className="whitespace-pre-wrap break-words">{turn.text}</p>
|
||||
{user && turn.attachment && (
|
||||
<p className="mt-1 truncate text-xs text-muted">Fichier: {turn.attachment}</p>
|
||||
)}
|
||||
{!user && turn.pending && (
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-xs text-muted">
|
||||
<Spinner size={12} />
|
||||
Réponse en cours
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -23,3 +23,5 @@ export { ResumeProjectPanel } from "./ResumeProjectPanel";
|
||||
export type { ResumeProjectPanelProps } from "./ResumeProjectPanel";
|
||||
export { useResumeProject } from "./useResumeProject";
|
||||
export type { ResumeProjectViewModel } from "./useResumeProject";
|
||||
export { CustomAgentChatView } from "./CustomAgentChatView";
|
||||
export type { CustomAgentChatViewProps } from "./CustomAgentChatView";
|
||||
|
||||
@ -1,27 +1,13 @@
|
||||
/**
|
||||
* F-1 — `LayoutGrid` cell routing (Option 1, Terminal + MCP): **every** agent
|
||||
* cell renders the raw {@link TerminalView}; no structured chat view is ever
|
||||
* mounted. This replaces the former §17.6 `cellKind:"chat"` routing — the human
|
||||
* view is now the native interactive PTY, and cross-model delegation flows
|
||||
* through MCP tools, not a chat view. Wired through the real {@link DIProvider}
|
||||
* with the in-memory mocks, exactly like `LayoutGrid.test.tsx`.
|
||||
* Ticket #147 — custom CLI mode for structured/headless agent cells.
|
||||
*
|
||||
* The decisive case: an agent cell always renders the terminal and never swaps
|
||||
* to a chat view (the structured chat surface was removed in the F-2 cleanup).
|
||||
*
|
||||
* Under jsdom xterm's `open` may bail, so the opener that triggers the launch
|
||||
* might not run; we therefore stub xterm (as in the original test) so the launch
|
||||
* does fire and we genuinely exercise the post-launch routing — which must stay
|
||||
* on the terminal regardless of the reported kind.
|
||||
* Plain cells and PTY-only profiles stay on the native TUI. Structured profiles
|
||||
* get a per-cell toggle; switching a live session requires confirmation, then
|
||||
* the custom chat view drives `ReplyChunk` streams defensively.
|
||||
*/
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor as rtlWaitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
// Make xterm "wire up" under jsdom: the real `Terminal.open` throws without a
|
||||
// layout engine, which makes `TerminalView`'s effect bail before it ever calls
|
||||
// the opener — so the launch would never fire. A minimal stub lets `term.open`
|
||||
// succeed and the opener run, so the launch (and any routing it could trigger)
|
||||
// is genuinely exercised. We do NOT stub the routing — only xterm.
|
||||
vi.mock("@xterm/xterm", () => ({
|
||||
Terminal: class {
|
||||
loadAddon() {}
|
||||
@ -49,7 +35,6 @@ vi.mock("@xterm/addon-fit", () => ({
|
||||
}));
|
||||
vi.mock("@xterm/xterm/css/xterm.css", () => ({}));
|
||||
|
||||
// jsdom has no ResizeObserver; TerminalView installs one after `term.open`.
|
||||
if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
@ -58,32 +43,69 @@ if (typeof globalThis.ResizeObserver === "undefined") {
|
||||
} as unknown as typeof ResizeObserver;
|
||||
}
|
||||
|
||||
import type { AgentProfile } from "@/domain";
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockAgentGateway, MockLayoutGateway, MockSystemGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockLayoutGateway,
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTerminalGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
|
||||
/** Seeds an agent in the gateway and pins it onto the (single) leaf cell. */
|
||||
async function seedPinnedAgent(): Promise<{
|
||||
gateways: Gateways;
|
||||
layout: MockLayoutGateway;
|
||||
agentGateway: MockAgentGateway;
|
||||
}> {
|
||||
const structuredProfile: AgentProfile = {
|
||||
id: "mock-structured",
|
||||
name: "Structured Codex",
|
||||
command: "codex",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
structuredAdapter: "codex",
|
||||
};
|
||||
|
||||
const ptyProfile: AgentProfile = {
|
||||
id: "mock-pty",
|
||||
name: "Plain PTY",
|
||||
command: "bash",
|
||||
args: [],
|
||||
contextInjection: { strategy: "conventionFile", target: "AGENTS.md" },
|
||||
detect: null,
|
||||
cwdTemplate: "{projectRoot}",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
async function seeded(profile: AgentProfile): Promise<Gateways> {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
|
||||
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
|
||||
|
||||
// Pin the agent onto the single leaf.
|
||||
await profileGateway.configureProfiles([profile]);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: profile.id,
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
|
||||
|
||||
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
|
||||
return { gateways, layout, agentGateway };
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
return {
|
||||
layout,
|
||||
agent,
|
||||
profile: profileGateway,
|
||||
terminal,
|
||||
system,
|
||||
} as unknown as Gateways;
|
||||
}
|
||||
|
||||
function renderGrid(gateways: Gateways) {
|
||||
@ -94,62 +116,79 @@ function renderGrid(gateways: Gateways) {
|
||||
);
|
||||
}
|
||||
|
||||
describe("LayoutGrid cell routing (F-1, Terminal + MCP)", () => {
|
||||
it("a plain (agent-less) cell renders the terminal view, never a chat view", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
it("does not show the custom CLI toggle in a plain cell", async () => {
|
||||
const gateways = {
|
||||
layout,
|
||||
layout: new MockLayoutGateway(),
|
||||
agent: new MockAgentGateway(),
|
||||
profile: new MockProfileGateway(),
|
||||
terminal: new MockTerminalGateway(),
|
||||
system: new MockSystemGateway(),
|
||||
} as unknown as Gateways;
|
||||
|
||||
renderGrid(gateways);
|
||||
|
||||
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.queryByText("CLI custom")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("a pty agent cell renders the terminal view, never a chat view", async () => {
|
||||
const { gateways } = await seedPinnedAgent();
|
||||
renderGrid(gateways);
|
||||
it("does not show the custom CLI toggle for a PTY-only profile", async () => {
|
||||
renderGrid(await seeded(ptyProfile));
|
||||
|
||||
await rtlWaitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
await waitFor(() => expect(screen.getByTestId("layout-leaf")).toBeTruthy());
|
||||
expect(screen.queryByText("CLI custom")).toBeNull();
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-mounting a known agent cell (persisted session) repaints as a terminal, never chat", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agentGateway = new MockAgentGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
it("shows the custom CLI toggle for structured profiles and streams a final callout", async () => {
|
||||
renderGrid(await seeded(structuredProfile));
|
||||
|
||||
const agent = await agentGateway.createAgent("p1", { name: "Worker", profileId: "claude" });
|
||||
// Seed a persisted session on the leaf — the pre-F-1 path would have re-mounted
|
||||
// such a known agent cell as a chat view; now it must always be a terminal.
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", { type: "setCellAgent", target: leafId, agent: agent.id });
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "running-session",
|
||||
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText("CLI custom"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(screen.getByText("Arrêter et relancer"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Joindre"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/mock-attachment.txt/)).toBeTruthy(),
|
||||
);
|
||||
fireEvent.change(screen.getByLabelText(/message CLI custom/), {
|
||||
target: { value: "hello there" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Envoyer"));
|
||||
|
||||
const gateways = { layout, agent: agentGateway, terminal, system } as unknown as Gateways;
|
||||
await waitFor(() => expect(screen.getByText("Task Complete")).toBeTruthy());
|
||||
expect(screen.getAllByText(/Agent: reçu/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/hello there/).length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText(/mock-attachment.txt/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// First mount.
|
||||
const { unmount } = renderGrid(gateways);
|
||||
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
unmount();
|
||||
it("requires confirmation before switching a live native TUI session", async () => {
|
||||
renderGrid(await seeded(structuredProfile));
|
||||
|
||||
// Re-mount (as after a tab/layout navigation): the known agent cell with its
|
||||
// persisted session must repaint as a terminal — never a chat view.
|
||||
renderGrid(gateways);
|
||||
await rtlWaitFor(() => expect(screen.getByTestId("terminal-view")).toBeTruthy());
|
||||
expect(screen.queryByTestId("agent-chat-view")).toBeNull();
|
||||
await waitFor(() => expect(screen.getByText("CLI custom")).toBeTruthy());
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("terminal-view")).toBeTruthy(),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("CLI custom"));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alertdialog", { name: "Confirmer le changement de CLI" })).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText(/Une reprise est possible/)).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByText("Arrêter et relancer"));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("custom-agent-chat-view")).toBeTruthy(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -16,9 +16,9 @@
|
||||
* {@link normalizeWeights} function, kept out of the render for testability.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import type { Agent } from "@/domain";
|
||||
import type { Agent, AgentProfile } from "@/domain";
|
||||
import type { LayoutNode } from "@/domain";
|
||||
import type { ProjectWorkState } from "@/domain";
|
||||
import type {
|
||||
@ -40,6 +40,7 @@ import {
|
||||
} from "@/features/announcements";
|
||||
import { PluginLayoutCellView } from "@/features/plugins";
|
||||
import {
|
||||
CustomAgentChatView,
|
||||
modelServerOverlayText,
|
||||
describeModelServerDownload,
|
||||
useModelServerLaunchState,
|
||||
@ -273,6 +274,12 @@ interface CellNotice {
|
||||
goToNodeId?: string;
|
||||
}
|
||||
|
||||
type AgentCellMode = "tui" | "custom";
|
||||
|
||||
interface PendingModeSwitch {
|
||||
target: AgentCellMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Focuses the layout leaf with the given node id: scrolls it into view and
|
||||
* flashes a brief outline so the user sees where the agent already lives. Works
|
||||
@ -338,7 +345,13 @@ function LeafView({
|
||||
// the wrong terminal. The root cell (no parent split) cannot be closed.
|
||||
const canClose = parentSplit !== null && parentSplit.siblings === 2;
|
||||
const siblingIndex = parentSplit ? (parentSplit.index === 0 ? 1 : 0) : 0;
|
||||
const { agent: agentGateway, input, system } = useGateways();
|
||||
const {
|
||||
agent: agentGateway,
|
||||
input,
|
||||
profile: profileGateway,
|
||||
system,
|
||||
terminal,
|
||||
} = useGateways();
|
||||
|
||||
// The single write-portal of this cell (ARCHITECTURE §20). It owns the human
|
||||
// line counter, the local delegation FIFO, the handshake (b→e) and the overlay
|
||||
@ -358,6 +371,47 @@ function LeafView({
|
||||
return () => { cancelled = true; };
|
||||
}, [agentGateway, projectId]);
|
||||
|
||||
const [profiles, setProfiles] = useState<AgentProfile[]>([]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
profileGateway
|
||||
?.listProfiles()
|
||||
.then((list) => {
|
||||
if (!cancelled) setProfiles(list);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setProfiles([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [profileGateway]);
|
||||
const cellModeStorageKey = `idea.agent-cell-mode.${projectId}.${id}`;
|
||||
const [cellMode, setCellModeState] = useState<AgentCellMode>(() => {
|
||||
if (typeof window === "undefined") return "tui";
|
||||
try {
|
||||
return window.localStorage.getItem(cellModeStorageKey) === "custom"
|
||||
? "custom"
|
||||
: "tui";
|
||||
} catch {
|
||||
return "tui";
|
||||
}
|
||||
});
|
||||
const setCellMode = useCallback(
|
||||
(mode: AgentCellMode) => {
|
||||
setCellModeState(mode);
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(cellModeStorageKey, mode);
|
||||
} catch {
|
||||
/* local preference only */
|
||||
}
|
||||
},
|
||||
[cellModeStorageKey],
|
||||
);
|
||||
const [pendingModeSwitch, setPendingModeSwitch] =
|
||||
useState<PendingModeSwitch | null>(null);
|
||||
|
||||
// Load the agents currently running (and where), so the dropdown can disable an
|
||||
// agent already live in another cell — it cannot run in two cells at once. The
|
||||
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
|
||||
@ -419,6 +473,21 @@ function LeafView({
|
||||
const pinnedAgent = agentId
|
||||
? agents.find((a) => a.id === agentId)
|
||||
: undefined;
|
||||
const pinnedProfile = pinnedAgent
|
||||
? profiles.find((p) => p.id === pinnedAgent.profileId)
|
||||
: undefined;
|
||||
const customCliAvailable = Boolean(
|
||||
agentId &&
|
||||
pinnedProfile?.structuredAdapter &&
|
||||
agentGateway?.launchAgentChat &&
|
||||
agentGateway?.reattachAgentChat &&
|
||||
agentGateway?.sendAgentChat &&
|
||||
agentGateway?.cancelAgentChat &&
|
||||
agentGateway?.closeAgentChat,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!customCliAvailable && cellMode !== "tui") setCellMode("tui");
|
||||
}, [cellMode, customCliAvailable]);
|
||||
const modelServerStatus = statusForAgent(pinnedAgent);
|
||||
const modelServerOverlay = modelServerOverlayText(modelServerStatus);
|
||||
// F2 — download progress (bar/%/bytes/source) when the status carries it; null
|
||||
@ -454,6 +523,44 @@ function LeafView({
|
||||
}
|
||||
}
|
||||
|
||||
function requestMode(target: AgentCellMode): void {
|
||||
if (!customCliAvailable || target === cellMode) return;
|
||||
if (session) setPendingModeSwitch({ target });
|
||||
else setCellMode(target);
|
||||
}
|
||||
|
||||
async function stopCurrentSessionForSwitch(): Promise<void> {
|
||||
if (!session) return;
|
||||
if (agentId && agentGateway?.stopLiveAgent) {
|
||||
await agentGateway.stopLiveAgent(projectId, agentId).catch(async () => {
|
||||
if (cellMode === "custom" && agentGateway.closeAgentChat) {
|
||||
await agentGateway.closeAgentChat(session);
|
||||
return;
|
||||
}
|
||||
await terminal?.closeTerminal(session);
|
||||
});
|
||||
} else if (cellMode === "custom" && agentGateway?.closeAgentChat) {
|
||||
await agentGateway.closeAgentChat(session);
|
||||
} else {
|
||||
await terminal?.closeTerminal(session);
|
||||
}
|
||||
await vm.setSession(id, null);
|
||||
refreshLive();
|
||||
}
|
||||
|
||||
async function confirmModeSwitch(): Promise<void> {
|
||||
const target = pendingModeSwitch?.target;
|
||||
if (!target) return;
|
||||
setBusyNotice(null);
|
||||
try {
|
||||
await stopCurrentSessionForSwitch();
|
||||
setCellMode(target);
|
||||
setPendingModeSwitch(null);
|
||||
} catch (err) {
|
||||
setBusyNotice({ message: describeNotice(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/** The live session for `candidate`, if any. */
|
||||
const liveFor = (candidate: string): LiveAgent | undefined =>
|
||||
liveAgents.find((la) => la.agentId === candidate);
|
||||
@ -710,6 +817,7 @@ function LeafView({
|
||||
const val = e.target.value;
|
||||
if (val === "") {
|
||||
setBusyNotice(null);
|
||||
setCellMode("tui");
|
||||
void vm.setCellAgent(id, null);
|
||||
return;
|
||||
}
|
||||
@ -740,12 +848,14 @@ function LeafView({
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
setCellMode("tui");
|
||||
const attached = await agentGateway.attachLiveAgent(projectId, val, id);
|
||||
await vm.attachLiveAgentToCell(id, val, attached.sessionId ?? live.sessionId);
|
||||
refreshLive();
|
||||
return;
|
||||
}
|
||||
setBusyNotice(null);
|
||||
setCellMode("tui");
|
||||
await vm.setCellAgent(id, val);
|
||||
})().catch(async (err: unknown) =>
|
||||
setBusyNotice(await noticeFromError(err, val)),
|
||||
@ -774,6 +884,66 @@ function LeafView({
|
||||
})}
|
||||
</select>
|
||||
|
||||
{customCliAvailable && (
|
||||
<div
|
||||
role="group"
|
||||
aria-label={`mode CLI agent ${id}`}
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
overflow: "hidden",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 3,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={cellMode === "tui"}
|
||||
title="TUI native"
|
||||
onClick={() => requestMode("tui")}
|
||||
style={{
|
||||
border: 0,
|
||||
borderRight: "1px solid var(--color-border, #3a3a3a)",
|
||||
background:
|
||||
cellMode === "tui"
|
||||
? "var(--color-primary, #5b9bd5)"
|
||||
: "transparent",
|
||||
color:
|
||||
cellMode === "tui"
|
||||
? "var(--color-on-primary, #ffffff)"
|
||||
: "var(--color-content, #e0e0e0)",
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
TUI native
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={cellMode === "custom"}
|
||||
title="CLI custom"
|
||||
onClick={() => requestMode("custom")}
|
||||
style={{
|
||||
border: 0,
|
||||
background:
|
||||
cellMode === "custom"
|
||||
? "var(--color-primary, #5b9bd5)"
|
||||
: "transparent",
|
||||
color:
|
||||
cellMode === "custom"
|
||||
? "var(--color-on-primary, #ffffff)"
|
||||
: "var(--color-content, #e0e0e0)",
|
||||
fontSize: 11,
|
||||
padding: "1px 6px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
CLI custom
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
title="Split into columns"
|
||||
@ -954,22 +1124,39 @@ function LeafView({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
||||
cwd={cwd}
|
||||
open={terminalOpener}
|
||||
reattach={reattachOpener}
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
agentMode={agentId != null}
|
||||
portal={agentId != null ? portal : undefined}
|
||||
refitSignal={refitSignal}
|
||||
/>
|
||||
{agentId && cellMode === "custom" && customCliAvailable && pinnedAgent && pinnedProfile ? (
|
||||
<CustomAgentChatView
|
||||
key={`${id}-${agentId}-custom`}
|
||||
projectId={projectId}
|
||||
agentId={agentId}
|
||||
agentName={pinnedAgent.name}
|
||||
profile={pinnedProfile}
|
||||
cwd={cwd}
|
||||
nodeId={id}
|
||||
sessionId={session}
|
||||
conversationId={conversationId}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
onConversationId={(cid) => void vm.setCellConversation(id, cid)}
|
||||
/>
|
||||
) : (
|
||||
<TerminalView
|
||||
key={`${id}-${agentId ?? "plain"}-${attachGen}`}
|
||||
cwd={cwd}
|
||||
open={terminalOpener}
|
||||
reattach={reattachOpener}
|
||||
sessionId={session}
|
||||
onSessionId={(sid) => void vm.setSession(id, sid)}
|
||||
agentMode={agentId != null}
|
||||
portal={agentId != null ? portal : undefined}
|
||||
refitSignal={refitSignal}
|
||||
/>
|
||||
)}
|
||||
{/* 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 —
|
||||
and never together with the F3 overlay (exactly one veil, F3 first). */}
|
||||
{!modelServerOverlay &&
|
||||
cellMode !== "custom" &&
|
||||
shouldShowWritePortalVeil(agentId != null, Boolean(overlay), busyActive) && (
|
||||
<div
|
||||
data-testid="write-portal-overlay"
|
||||
@ -1007,7 +1194,7 @@ 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 && agentId != null && (
|
||||
{!modelServerOverlay && cellMode !== "custom" && agentId != null && (
|
||||
<TargetAnnouncementsOverlay projectId={projectId} agentId={agentId} />
|
||||
)}
|
||||
{/* Ticket #54 — model-server launch veil. Top-priority full-cell overlay
|
||||
@ -1179,6 +1366,89 @@ function LeafView({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{pendingModeSwitch && (
|
||||
<div
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-label="Confirmer le changement de CLI"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: CELL_Z.controls + 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "rgba(0, 0, 0, 0.62)",
|
||||
padding: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
maxWidth: "100%",
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 6,
|
||||
background: "var(--color-surface, #1e1e1e)",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
padding: 12,
|
||||
boxShadow: "0 12px 36px rgba(0,0,0,0.35)",
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 14 }}>
|
||||
Changer de CLI agent
|
||||
</h2>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-content-muted, #9a9a9a)" }}>
|
||||
La session courante va être arrêtée avant de relancer{" "}
|
||||
{pendingModeSwitch.target === "custom"
|
||||
? "la CLI custom"
|
||||
: "la TUI native"}
|
||||
.
|
||||
</p>
|
||||
<p style={{ margin: "8px 0 0", fontSize: 12, color: "var(--color-warning, #d49b3a)" }}>
|
||||
{conversationId
|
||||
? "Une reprise est possible si le profil et le backend conservent cette conversation."
|
||||
: "Aucune conversation reprenable n'est enregistrée pour cette cellule; la relance repartira à neuf."}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingModeSwitch(null)}
|
||||
style={{
|
||||
border: "1px solid var(--color-border, #3a3a3a)",
|
||||
borderRadius: 4,
|
||||
background: "transparent",
|
||||
color: "var(--color-content, #e0e0e0)",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void confirmModeSwitch()}
|
||||
style={{
|
||||
border: "1px solid var(--color-danger, #d45a5a)",
|
||||
borderRadius: 4,
|
||||
background: "rgba(212, 90, 90, 0.18)",
|
||||
color: "var(--color-danger, #d45a5a)",
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Arrêter et relancer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingResume && (
|
||||
<ResumeConversationPopup
|
||||
agentWasRunning={agentWasRunning}
|
||||
|
||||
Reference in New Issue
Block a user