feat(ui): cellule agent stoppé — état explicite + reopen from scratch (#197, QA verte)
Surface UI du LayoutGrid pour un agent de cellule stoppé: - Affichage d'un état explicite quand runtimeState vaut « stopped » ou « failedStart », avec actions refresh / reopen from scratch (freshStart) et état restarting pendant la relance. - theme.css: styles .stopped-agent-cell (boutons primary/secondary, états disabled). Consomme le contrat runtime issu de #196 (AgentRuntimeState stopped/failedStart, freshStart) déjà présent dans develop; aucune modification runtime ici. Tests QA verts: LayoutGrid.test.tsx, LayoutGrid.chat.test.tsx (rejoué aussi: agent_lifecycle launch_fresh_start_ignores_existing_conversation_id_and_mints_new_one).
This commit is contained in:
@ -130,6 +130,7 @@ export function LayoutGrid({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={work.state}
|
||||
workStateBusy={work.busy}
|
||||
refreshWorkState={work.refresh}
|
||||
refitSignal={refitEpoch}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -148,6 +149,7 @@ interface NodeViewProps {
|
||||
projectId: string;
|
||||
layoutId?: string;
|
||||
workState: ProjectWorkState | null;
|
||||
workStateBusy: boolean;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -162,6 +164,7 @@ function NodeView({
|
||||
projectId,
|
||||
layoutId,
|
||||
workState,
|
||||
workStateBusy,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
@ -196,6 +199,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={workState}
|
||||
workStateBusy={workStateBusy}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
/>
|
||||
@ -209,6 +213,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={workState}
|
||||
workStateBusy={workStateBusy}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -224,6 +229,7 @@ function NodeView({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={workState}
|
||||
workStateBusy={workStateBusy}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -244,6 +250,7 @@ interface LeafViewProps {
|
||||
projectId: string;
|
||||
layoutId?: string;
|
||||
workState: ProjectWorkState | null;
|
||||
workStateBusy: boolean;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
}
|
||||
@ -270,6 +277,11 @@ interface TrustedCustomCliBinding {
|
||||
profile: AgentProfile;
|
||||
}
|
||||
|
||||
interface FreshRestartedSession {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export function shouldFallbackCustomCliMode(input: {
|
||||
agentsLoaded: boolean;
|
||||
profilesLoaded: boolean;
|
||||
@ -309,6 +321,64 @@ function goToCell(nodeId: string): void {
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function StoppedAgentCellPanel({
|
||||
agentName,
|
||||
restarting,
|
||||
error,
|
||||
onRestart,
|
||||
onRefresh,
|
||||
}: {
|
||||
agentName: string;
|
||||
restarting: boolean;
|
||||
error: boolean;
|
||||
onRestart: () => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section
|
||||
className="stopped-agent-cell"
|
||||
aria-label={`${agentName} arrêté`}
|
||||
>
|
||||
<div className="stopped-agent-cell__header">
|
||||
<h3 className="stopped-agent-cell__name">{agentName}</h3>
|
||||
<span className="stopped-agent-cell__badge">Arrêté</span>
|
||||
</div>
|
||||
<div className="stopped-agent-cell__content">
|
||||
<p className="stopped-agent-cell__message">
|
||||
{error ? "Erreur de relance" : restarting ? "Relance…" : "Cette session est terminée."}
|
||||
</p>
|
||||
{error ? (
|
||||
<p className="stopped-agent-cell__help">
|
||||
Impossible de relancer l’agent. Réessayez.
|
||||
</p>
|
||||
) : (
|
||||
<p className="stopped-agent-cell__help">
|
||||
Ouvre une nouvelle session pour cet agent.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="stopped-agent-cell__actions">
|
||||
<button
|
||||
type="button"
|
||||
className="stopped-agent-cell__button stopped-agent-cell__button--primary"
|
||||
onClick={onRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? "Relance…" : "Relancer"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="stopped-agent-cell__button"
|
||||
onClick={onRefresh}
|
||||
disabled={restarting}
|
||||
>
|
||||
Actualiser
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Local z-index scale for a single cell (ticket #48). Intra-cell layering only —
|
||||
* unrelated to the global {@link zIndex} scale used for app-level surfaces.
|
||||
@ -348,6 +418,7 @@ function LeafView({
|
||||
projectId,
|
||||
layoutId,
|
||||
workState,
|
||||
workStateBusy,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
}: LeafViewProps) {
|
||||
@ -471,10 +542,15 @@ function LeafView({
|
||||
// backend refuses such a launch (`AGENT_ALREADY_RUNNING`); disabling it here is
|
||||
// the matching UI affordance.
|
||||
const [liveAgents, setLiveAgents] = useState<LiveAgent[]>([]);
|
||||
const refreshLive = () => {
|
||||
if (!agentGateway?.listLiveAgents) return;
|
||||
agentGateway.listLiveAgents(projectId).then(setLiveAgents).catch(() => {
|
||||
const refreshLive = async (): Promise<LiveAgent[]> => {
|
||||
if (!agentGateway?.listLiveAgents) return [];
|
||||
return agentGateway.listLiveAgents(projectId).then((list) => {
|
||||
setLiveAgents(list);
|
||||
return list;
|
||||
}).catch(() => {
|
||||
/* ignore — treat as none live */
|
||||
setLiveAgents([]);
|
||||
return [];
|
||||
});
|
||||
};
|
||||
useEffect(() => {
|
||||
@ -669,6 +745,46 @@ function LeafView({
|
||||
) ?? [];
|
||||
const busyByWorkState = agentWork?.busy.state === "busy" || Boolean(activeTicket);
|
||||
const delegatedTicket = activeTicket?.source.kind === "agent" ? activeTicket : undefined;
|
||||
const [restartPending, setRestartPending] = useState(false);
|
||||
const [restartError, setRestartError] = useState(false);
|
||||
const [freshRestartedSession, setFreshRestartedSession] =
|
||||
useState<FreshRestartedSession | null>(null);
|
||||
const runtimeState = agentWork?.runtimeState;
|
||||
const livePinnedAgent = agentId
|
||||
? liveAgents.find((la) => la.agentId === agentId)
|
||||
: undefined;
|
||||
const hasFreshRestartedLiveSession = Boolean(
|
||||
agentId &&
|
||||
session &&
|
||||
freshRestartedSession?.agentId === agentId &&
|
||||
freshRestartedSession.sessionId === session &&
|
||||
livePinnedAgent?.sessionId === session,
|
||||
);
|
||||
const stoppedAgentCell = Boolean(
|
||||
agentId &&
|
||||
(runtimeState === "stopped" || runtimeState === "failedStart") &&
|
||||
!hasFreshRestartedLiveSession,
|
||||
);
|
||||
const agentCellHydrating = Boolean(
|
||||
agentId &&
|
||||
session &&
|
||||
(!agentsLoaded ||
|
||||
!profilesLoaded ||
|
||||
(workState === null && workStateBusy)),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!freshRestartedSession || !agentId) return;
|
||||
if (freshRestartedSession.agentId !== agentId) {
|
||||
setFreshRestartedSession(null);
|
||||
return;
|
||||
}
|
||||
const live = liveAgents.find((la) => la.agentId === agentId);
|
||||
if (!live || live.sessionId !== freshRestartedSession.sessionId) {
|
||||
setFreshRestartedSession(null);
|
||||
}
|
||||
}, [agentId, freshRestartedSession, liveAgents]);
|
||||
|
||||
async function interruptCurrentTurn(): Promise<void> {
|
||||
if (!agentId || !input) return;
|
||||
try {
|
||||
@ -738,6 +854,74 @@ function LeafView({
|
||||
const liveFor = (candidate: string): LiveAgent | undefined =>
|
||||
liveAgents.find((la) => la.agentId === candidate);
|
||||
|
||||
async function refreshStoppedAgentState(): Promise<void> {
|
||||
setRestartError(false);
|
||||
await Promise.all([refreshWorkState(), refreshLive()]);
|
||||
}
|
||||
|
||||
async function restartStoppedAgent(): Promise<void> {
|
||||
if (!agentId || !agentGateway) return;
|
||||
setRestartPending(true);
|
||||
setRestartError(false);
|
||||
try {
|
||||
await vm.setSession(id, null);
|
||||
await vm.setCellConversation(id, null);
|
||||
if (
|
||||
cellMode === "custom" &&
|
||||
effectiveCustomCliAvailable &&
|
||||
agentGateway.launchAgentChat
|
||||
) {
|
||||
const handle = await agentGateway.launchAgentChat(projectId, agentId, {
|
||||
cwd,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
nodeId: id,
|
||||
freshStart: true,
|
||||
});
|
||||
await setSessionWithInstrumentation("custom", handle.sessionId);
|
||||
await vm.setCellConversation(id, handle.assignedConversationId ?? null);
|
||||
const live = await refreshLive();
|
||||
setFreshRestartedSession({
|
||||
agentId,
|
||||
sessionId:
|
||||
live.find((entry) => entry.agentId === agentId)?.sessionId ??
|
||||
handle.sessionId,
|
||||
});
|
||||
await refreshWorkState();
|
||||
return;
|
||||
}
|
||||
if (!agentGateway.launchAgent) throw new Error("Agent launch unavailable");
|
||||
const handle = await agentGateway.launchAgent(
|
||||
projectId,
|
||||
agentId,
|
||||
{
|
||||
cwd,
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
nodeId: id,
|
||||
freshStart: true,
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
await setSessionWithInstrumentation("plain", handle.sessionId);
|
||||
await vm.setCellConversation(id, handle.assignedConversationId ?? null);
|
||||
handle.detach();
|
||||
const live = await refreshLive();
|
||||
setFreshRestartedSession({
|
||||
agentId,
|
||||
sessionId:
|
||||
live.find((entry) => entry.agentId === agentId)?.sessionId ??
|
||||
handle.sessionId,
|
||||
});
|
||||
await refreshWorkState();
|
||||
} catch {
|
||||
setRestartError(true);
|
||||
setFreshRestartedSession(null);
|
||||
} finally {
|
||||
setRestartPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an error into a {@link CellNotice}. For the singleton-invariant refusal
|
||||
* (`AGENT_ALREADY_RUNNING`) it resolves the host cell from the live-agents set
|
||||
@ -1252,7 +1436,34 @@ function LeafView({
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
||||
{agentId && agentCellHydrating ? (
|
||||
<div
|
||||
data-testid="agent-cell-runtime-loading"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--color-canvas, #0e1116)",
|
||||
color: "var(--color-muted, #9aa7b4)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
Chargement…
|
||||
</div>
|
||||
) : agentId && stoppedAgentCell ? (
|
||||
<StoppedAgentCellPanel
|
||||
agentName={pinnedAgent?.name ?? "Agent"}
|
||||
restarting={restartPending}
|
||||
error={restartError}
|
||||
onRestart={() => void restartStoppedAgent()}
|
||||
onRefresh={() => void refreshStoppedAgentState()}
|
||||
/>
|
||||
) : agentId && cellMode === "custom" && effectiveCustomCliAvailable && customCliAgent && customCliProfile ? (
|
||||
<CustomAgentChatView
|
||||
key={`${id}-${agentId}-custom`}
|
||||
projectId={projectId}
|
||||
@ -1589,6 +1800,7 @@ interface SplitViewProps {
|
||||
projectId: string;
|
||||
layoutId?: string;
|
||||
workState: ProjectWorkState | null;
|
||||
workStateBusy: boolean;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -1602,6 +1814,7 @@ function SplitView({
|
||||
projectId,
|
||||
layoutId,
|
||||
workState,
|
||||
workStateBusy,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
@ -1649,6 +1862,7 @@ function SplitView({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={workState}
|
||||
workStateBusy={workStateBusy}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
@ -1746,6 +1960,7 @@ interface GridViewProps {
|
||||
projectId: string;
|
||||
layoutId?: string;
|
||||
workState: ProjectWorkState | null;
|
||||
workStateBusy: boolean;
|
||||
refreshWorkState: () => Promise<void>;
|
||||
refitSignal: number;
|
||||
onOpenConversation?: (conversationId: string) => void;
|
||||
@ -1759,6 +1974,7 @@ function GridView({
|
||||
projectId,
|
||||
layoutId,
|
||||
workState,
|
||||
workStateBusy,
|
||||
refreshWorkState,
|
||||
refitSignal,
|
||||
onOpenConversation,
|
||||
@ -1802,6 +2018,7 @@ function GridView({
|
||||
projectId={projectId}
|
||||
layoutId={layoutId}
|
||||
workState={workState}
|
||||
workStateBusy={workStateBusy}
|
||||
refreshWorkState={refreshWorkState}
|
||||
refitSignal={refitSignal}
|
||||
onOpenConversation={onOpenConversation}
|
||||
|
||||
Reference in New Issue
Block a user