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:
@ -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