merge(ui): cellule agent stoppé — état explicite + reopen from scratch (#197, QA verte)
This commit is contained in:
@ -51,6 +51,7 @@ import {
|
||||
MockProfileGateway,
|
||||
MockSystemGateway,
|
||||
MockTerminalGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
@ -222,6 +223,75 @@ describe("LayoutGrid custom agent CLI (#147)", () => {
|
||||
expect(screen.queryByText(/déjà actif dans une autre cellule/)).toBeNull();
|
||||
});
|
||||
|
||||
it("relaunches a stopped custom CLI agent from scratch (#197)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const profileGateway = new MockProfileGateway();
|
||||
const terminal = new MockTerminalGateway();
|
||||
const system = new MockSystemGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
await profileGateway.configureProfiles([structuredProfile]);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "Worker",
|
||||
profileId: structuredProfile.id,
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "dead-structured-session",
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellConversation",
|
||||
target: leafId,
|
||||
conversationId: "old-structured-conversation",
|
||||
});
|
||||
window.localStorage.setItem(`idea.agent-cell-mode.p1.${leafId}`, "custom");
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "Worker",
|
||||
profileId: structuredProfile.id,
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
const launchChat = vi.spyOn(agent, "launchAgentChat");
|
||||
|
||||
renderGrid({
|
||||
layout,
|
||||
agent,
|
||||
profile: profileGateway,
|
||||
terminal,
|
||||
system,
|
||||
workState,
|
||||
} as unknown as Gateways);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
|
||||
|
||||
await waitFor(() => expect(launchChat).toHaveBeenCalledTimes(1));
|
||||
expect(launchChat.mock.calls[0]?.[2]).toMatchObject({
|
||||
cwd: "/home/me/proj",
|
||||
nodeId: leafId,
|
||||
freshStart: true,
|
||||
});
|
||||
expect(launchChat.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||
await waitFor(async () => {
|
||||
const updated = await layout.loadLayout("p1");
|
||||
const leaf = leaves(updated)[0];
|
||||
expect(leaf.session).toMatch(/^mock-agent-chat-/);
|
||||
expect(leaf.conversationId).toMatch(/^mock-conversation-/);
|
||||
});
|
||||
});
|
||||
|
||||
it("closes the structured chat session by id before switching back to native TUI (#170)", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
|
||||
@ -12,7 +12,12 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { MockLayoutGateway, MockTerminalGateway } from "@/adapters/mock";
|
||||
import {
|
||||
MockAgentGateway,
|
||||
MockLayoutGateway,
|
||||
MockTerminalGateway,
|
||||
MockWorkStateGateway,
|
||||
} from "@/adapters/mock";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { leaves } from "./layout";
|
||||
import { LayoutGrid } from "./LayoutGrid";
|
||||
@ -181,4 +186,264 @@ describe("LayoutGrid (with MockLayoutGateway)", () => {
|
||||
).not.toThrow();
|
||||
expect(screen.getByTestId("layout-grid")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders an explicit stopped-agent cell without showing it as idle", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "dead-session",
|
||||
});
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText("StoppedAgent arrêté")).toBeTruthy();
|
||||
expect(screen.getByText("Arrêté")).toBeTruthy();
|
||||
expect(screen.getByText("Cette session est terminée.")).toBeTruthy();
|
||||
expect(screen.getByText("Ouvre une nouvelle session pour cet agent.")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Relancer" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Actualiser" })).toBeTruthy();
|
||||
expect(screen.queryByText("Idle")).toBeNull();
|
||||
});
|
||||
|
||||
it("relaunches a stopped agent from scratch in the same cell", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "dead-session",
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellConversation",
|
||||
target: leafId,
|
||||
conversationId: "old-conversation",
|
||||
});
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
|
||||
|
||||
await waitFor(() => expect(launchSpy).toHaveBeenCalledTimes(1));
|
||||
expect(launchSpy.mock.calls[0]?.[0]).toBe("p1");
|
||||
expect(launchSpy.mock.calls[0]?.[1]).toBe(created.id);
|
||||
expect(launchSpy.mock.calls[0]?.[2]).toMatchObject({
|
||||
cwd: "/home/me/proj",
|
||||
nodeId: leafId,
|
||||
freshStart: true,
|
||||
});
|
||||
expect(launchSpy.mock.calls[0]?.[2].conversationId).toBeUndefined();
|
||||
|
||||
await waitFor(async () => {
|
||||
const fresh = await layout.loadLayout("p1");
|
||||
const leaf = leaves(fresh)[0];
|
||||
expect(leaf.agent).toBe(created.id);
|
||||
expect(leaf.session).toMatch(/^mock-agent-session-/);
|
||||
expect(leaf.conversationId).toMatch(/^mock-conversation-/);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes a stopped agent state without relaunching", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
|
||||
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setSession",
|
||||
target: leafId,
|
||||
session: "dead-session",
|
||||
});
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Actualiser" }));
|
||||
|
||||
await waitFor(() => expect(refreshSpy).toHaveBeenCalledTimes(2));
|
||||
expect(launchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the transient restart label while relaunching", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
let resolveLaunch!: (value: Awaited<ReturnType<MockAgentGateway["launchAgent"]>>) => void;
|
||||
const launchPromise = new Promise<Awaited<ReturnType<MockAgentGateway["launchAgent"]>>>(
|
||||
(resolve) => {
|
||||
resolveLaunch = resolve;
|
||||
},
|
||||
);
|
||||
vi.spyOn(agent, "launchAgent").mockReturnValue(launchPromise);
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
|
||||
|
||||
await waitFor(() => expect(screen.getAllByText("Relance…")).toHaveLength(2));
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Relance…" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(true);
|
||||
|
||||
resolveLaunch({
|
||||
sessionId: "mock-agent-session-deferred",
|
||||
assignedConversationId: "mock-conversation-deferred",
|
||||
write: async () => {},
|
||||
resize: async () => {},
|
||||
detach: () => {},
|
||||
close: async () => {},
|
||||
});
|
||||
});
|
||||
|
||||
it("shows the stopped-cell restart error copy when relaunch fails", async () => {
|
||||
const layout = new MockLayoutGateway();
|
||||
const agent = new MockAgentGateway();
|
||||
const workState = new MockWorkStateGateway();
|
||||
const created = await agent.createAgent("p1", {
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
});
|
||||
vi.spyOn(agent, "launchAgent").mockRejectedValue(
|
||||
new Error("launch failed"),
|
||||
);
|
||||
const tree = await layout.loadLayout("p1");
|
||||
const leafId = leaves(tree)[0].id;
|
||||
await layout.mutateLayout("p1", {
|
||||
type: "setCellAgent",
|
||||
target: leafId,
|
||||
agent: created.id,
|
||||
});
|
||||
workState._setProjectWorkState("p1", {
|
||||
agents: [
|
||||
{
|
||||
agentId: created.id,
|
||||
name: "StoppedAgent",
|
||||
profileId: "prof-1",
|
||||
runtimeState: "stopped",
|
||||
busy: { state: "idle" },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
<DIProvider gateways={{ layout, agent, workState } as unknown as Gateways}>
|
||||
<LayoutGrid projectId="p1" cwd="/home/me/proj" />
|
||||
</DIProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Relancer" }));
|
||||
|
||||
expect(await screen.findByText("Erreur de relance")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("Impossible de relancer l’agent. Réessayez."),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -101,3 +101,135 @@
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.stopped-agent-cell {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 28px 12px 12px;
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-content);
|
||||
}
|
||||
|
||||
.stopped-agent-cell__header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__name {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__badge {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-warning);
|
||||
border-radius: 3px;
|
||||
background: rgb(210 153 34 / 16%);
|
||||
color: var(--color-warning);
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__content {
|
||||
min-width: 0;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__message {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__help {
|
||||
margin: 4px 0 0;
|
||||
color: var(--color-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button {
|
||||
min-height: 28px;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 3px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-content);
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button:hover:not(:disabled) {
|
||||
background: var(--color-raised);
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button:disabled {
|
||||
color: var(--color-faint);
|
||||
cursor: default;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button--primary {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-primary);
|
||||
color: var(--color-on-primary);
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button--primary:hover:not(:disabled) {
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stopped-agent-cell {
|
||||
padding: 34px 10px 10px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__header {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__badge {
|
||||
margin-left: 0;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__actions {
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stopped-agent-cell__button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user