feat(workstate): UI des actions contrôlées (Lot D frontend)
Câble les actions contrôlées dans ProjectWorkStatePanel : port et adaptateur agent étendus aux nouvelles commandes, adaptateur mock aligné. Tests verts : workstate + projects (25), agent (8), singletonAgent + agentAlreadyRunning (9), tsc --noEmit OK. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -61,13 +61,34 @@ describe("TauriAgentGateway invoke payloads", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
|
it("attach_live_agent wraps project, agent and target node in the request DTO", async () => {
|
||||||
invoke.mockResolvedValueOnce([
|
invoke.mockResolvedValueOnce({
|
||||||
{ agentId: "agent-2", nodeId: "node-3", sessionId: "session-4" },
|
agentId: "agent-2",
|
||||||
]);
|
nodeId: "node-3",
|
||||||
await new TauriAgentGateway().attachLiveAgent("proj-1", "agent-2", "node-3");
|
sessionId: "session-4",
|
||||||
|
kind: "pty",
|
||||||
|
});
|
||||||
|
const out = await new TauriAgentGateway().attachLiveAgent(
|
||||||
|
"proj-1",
|
||||||
|
"agent-2",
|
||||||
|
"node-3",
|
||||||
|
);
|
||||||
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
|
expect(invoke).toHaveBeenCalledWith("attach_live_agent", {
|
||||||
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
|
request: { projectId: "proj-1", agentId: "agent-2", nodeId: "node-3" },
|
||||||
});
|
});
|
||||||
|
expect(out.sessionId).toBe("session-4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stop_live_agent wraps project and agent in the request DTO", async () => {
|
||||||
|
invoke.mockResolvedValueOnce({
|
||||||
|
agentId: "agent-2",
|
||||||
|
sessionId: "session-4",
|
||||||
|
kind: "pty",
|
||||||
|
});
|
||||||
|
const out = await new TauriAgentGateway().stopLiveAgent("proj-1", "agent-2");
|
||||||
|
expect(invoke).toHaveBeenCalledWith("stop_live_agent", {
|
||||||
|
request: { projectId: "proj-1", agentId: "agent-2" },
|
||||||
|
});
|
||||||
|
expect(out.sessionId).toBe("session-4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {
|
it("change_agent_profile wraps all five fields in the request DTO (camelCase)", async () => {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import type {
|
|||||||
LiveAgent,
|
LiveAgent,
|
||||||
OpenTerminalOptions,
|
OpenTerminalOptions,
|
||||||
ReattachResult,
|
ReattachResult,
|
||||||
|
StoppedLiveAgent,
|
||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
import { makeTerminalHandle } from "./terminal";
|
import { makeTerminalHandle } from "./terminal";
|
||||||
@ -63,14 +64,14 @@ export class TauriAgentGateway implements AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): Promise<LiveAgent> {
|
): Promise<LiveAgent> {
|
||||||
return invoke<LiveAgent[]>("attach_live_agent", {
|
return invoke<LiveAgent>("attach_live_agent", {
|
||||||
request: { projectId, agentId, nodeId },
|
request: { projectId, agentId, nodeId },
|
||||||
}).then((list) => {
|
});
|
||||||
const attached = list[0];
|
}
|
||||||
if (!attached) {
|
|
||||||
throw new Error(`running session for agent ${agentId} was not returned`);
|
stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||||
}
|
return invoke<StoppedLiveAgent>("stop_live_agent", {
|
||||||
return attached;
|
request: { projectId, agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -61,6 +61,7 @@ import type {
|
|||||||
ReattachResult,
|
ReattachResult,
|
||||||
RemoteGateway,
|
RemoteGateway,
|
||||||
SkillGateway,
|
SkillGateway,
|
||||||
|
StoppedLiveAgent,
|
||||||
SystemGateway,
|
SystemGateway,
|
||||||
TemplateGateway,
|
TemplateGateway,
|
||||||
TerminalGateway,
|
TerminalGateway,
|
||||||
@ -215,7 +216,8 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
.map(([agentId, nodeId]) => ({
|
.map(([agentId, nodeId]) => ({
|
||||||
agentId,
|
agentId,
|
||||||
nodeId,
|
nodeId,
|
||||||
sessionId: this.liveSessionByAgent.get(agentId),
|
sessionId: this.liveSessionByAgent.get(agentId)!,
|
||||||
|
kind: "pty" as const,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -256,7 +258,32 @@ export class MockAgentGateway implements AgentGateway {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
this.liveByAgent.set(agentId, nodeId);
|
this.liveByAgent.set(agentId, nodeId);
|
||||||
return { agentId, nodeId, sessionId };
|
return { agentId, nodeId, sessionId, kind: "pty" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopLiveAgent(projectId: string, agentId: string): Promise<StoppedLiveAgent> {
|
||||||
|
const list = this.getAgents(projectId);
|
||||||
|
if (!list.some((a) => a.id === agentId)) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} not found in project ${projectId}`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const sessionId = this.liveSessionByAgent.get(agentId);
|
||||||
|
if (!sessionId) {
|
||||||
|
const err: GatewayError = {
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: `agent ${agentId} has no live session`,
|
||||||
|
};
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const session = this.sessions.get(sessionId);
|
||||||
|
if (session) session.closed = true;
|
||||||
|
this.sessions.delete(sessionId);
|
||||||
|
this.liveSessionByAgent.delete(agentId);
|
||||||
|
this.liveByAgent.delete(agentId);
|
||||||
|
return { agentId, sessionId, kind: "pty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
async createAgent(projectId: string, input: CreateAgentInput): Promise<Agent> {
|
||||||
|
|||||||
@ -103,7 +103,7 @@ describe("R0d — launch refused with AGENT_ALREADY_RUNNING", () => {
|
|||||||
|
|
||||||
// The live set reports the agent already hosted in cell B (the host).
|
// The live set reports the agent already hosted in cell B (the host).
|
||||||
vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([
|
vi.spyOn(agentGateway, "listLiveAgents").mockResolvedValue([
|
||||||
{ agentId: agent.id, nodeId: bId, sessionId: "s-1" },
|
{ agentId: agent.id, nodeId: bId, sessionId: "s-1", kind: "pty" },
|
||||||
]);
|
]);
|
||||||
// The launch from cell A is refused by the backend (singleton invariant).
|
// The launch from cell A is refused by the backend (singleton invariant).
|
||||||
vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({
|
vi.spyOn(agentGateway, "launchAgent").mockRejectedValue({
|
||||||
|
|||||||
@ -70,6 +70,7 @@ describe("singleton agent — mock gateway guard (T5)", () => {
|
|||||||
agentId: a.id,
|
agentId: a.id,
|
||||||
nodeId: "node-A",
|
nodeId: "node-A",
|
||||||
sessionId: "mock-agent-session-1",
|
sessionId: "mock-agent-session-1",
|
||||||
|
kind: "pty",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -146,6 +147,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
agentId: a.id,
|
agentId: a.id,
|
||||||
nodeId: leafId,
|
nodeId: leafId,
|
||||||
sessionId: "mock-agent-session-1",
|
sessionId: "mock-agent-session-1",
|
||||||
|
kind: "pty",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@ -156,7 +158,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
const a = await agent.createAgent("p1", { name: "Busy", profileId: "p" });
|
||||||
|
|
||||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||||
{ agentId: a.id, nodeId: "some-other-node" },
|
{ agentId: a.id, nodeId: "some-other-node" } as LiveAgent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
@ -190,7 +192,7 @@ describe("singleton agent — dropdown guard (T6)", () => {
|
|||||||
|
|
||||||
// The agent is live in THIS very cell (same node id as the leaf).
|
// The agent is live in THIS very cell (same node id as the leaf).
|
||||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||||
{ agentId: a.id, nodeId: leafId },
|
{ agentId: a.id, nodeId: leafId, sessionId: "s-own", kind: "pty" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
@ -239,7 +241,7 @@ describe("R0d — dropdown disables agents live elsewhere + go-to-cell", () => {
|
|||||||
|
|
||||||
// The agent is live in cell B (a currently-visible leaf).
|
// The agent is live in cell B (a currently-visible leaf).
|
||||||
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
vi.spyOn(agent, "listLiveAgents").mockResolvedValue([
|
||||||
{ agentId: ag.id, nodeId: b, sessionId: "s-1" },
|
{ agentId: ag.id, nodeId: b, sessionId: "s-1", kind: "pty" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
renderGrid(layout, agent);
|
renderGrid(layout, agent);
|
||||||
|
|||||||
@ -3,12 +3,19 @@
|
|||||||
* and idle/busy state from the backend read-model, plus the current input queue.
|
* and idle/busy state from the backend read-model, plus the current input queue.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentTicketState,
|
AgentTicketState,
|
||||||
AgentWorkState,
|
AgentWorkState,
|
||||||
ConversationPreviewStatus,
|
ConversationPreviewStatus,
|
||||||
ConversationWorkSummary,
|
ConversationWorkSummary,
|
||||||
|
LeafCell,
|
||||||
} from "@/domain";
|
} from "@/domain";
|
||||||
|
import { useGateways } from "@/app/di";
|
||||||
|
import { leaves } from "@/features/layout/layout";
|
||||||
|
import { useLayout } from "@/features/layout/useLayout";
|
||||||
|
import type { LayoutViewModel } from "@/features/layout/useLayout";
|
||||||
import { Button, Panel, Spinner, cn } from "@/shared";
|
import { Button, Panel, Spinner, cn } from "@/shared";
|
||||||
import { useProjectWorkState } from "./useProjectWorkState";
|
import { useProjectWorkState } from "./useProjectWorkState";
|
||||||
|
|
||||||
@ -52,26 +59,99 @@ function summaryText(summary: ConversationWorkSummary): string | null {
|
|||||||
return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
|
return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goToCell(nodeId: string): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const el = document.querySelector<HTMLElement>(`[data-node-id="${nodeId}"]`);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||||
|
const previous = el.style.outline;
|
||||||
|
el.style.outline = "2px solid var(--color-primary, #5b9bd5)";
|
||||||
|
window.setTimeout(() => {
|
||||||
|
el.style.outline = previous;
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard(text: string): Promise<void> {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("Clipboard unavailable");
|
||||||
|
}
|
||||||
|
|
||||||
function ConversationSummaryLine({
|
function ConversationSummaryLine({
|
||||||
summary,
|
summary,
|
||||||
}: {
|
}: {
|
||||||
summary: ConversationWorkSummary;
|
summary: ConversationWorkSummary;
|
||||||
}) {
|
}) {
|
||||||
const text = summaryText(summary);
|
const text = summaryText(summary);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
return (
|
return (
|
||||||
<div className="mt-1 flex min-w-0 items-start gap-2 pl-1 text-xs text-muted">
|
<div className="mt-1 min-w-0 pl-1 text-xs text-muted">
|
||||||
<span
|
<div className="flex min-w-0 items-start gap-2">
|
||||||
className={cn(
|
<span
|
||||||
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
className={cn(
|
||||||
summary.status === "ready"
|
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
||||||
? "bg-success/10 text-success"
|
summary.status === "ready"
|
||||||
: "bg-raised text-muted",
|
? "bg-success/10 text-success"
|
||||||
)}
|
: "bg-raised text-muted",
|
||||||
>
|
)}
|
||||||
{summaryLabel(summary.status)}
|
>
|
||||||
</span>
|
{summaryLabel(summary.status)}
|
||||||
<span className="min-w-0 break-words">{text}</span>
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 break-words">{text}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : "View"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shrink-0 rounded px-1 py-0.5 text-[11px] text-muted hover:bg-raised hover:text-content disabled:opacity-50"
|
||||||
|
disabled={!text}
|
||||||
|
onClick={() => {
|
||||||
|
void copyToClipboard(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{expanded && (
|
||||||
|
<div
|
||||||
|
aria-label={`conversation ${summary.conversationId} details`}
|
||||||
|
className="mt-1 space-y-1 border-l border-border pl-2"
|
||||||
|
>
|
||||||
|
{summary.objectivePreview && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-content">Goal:</span>{" "}
|
||||||
|
{summary.objectivePreview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{summary.summaryPreview && (
|
||||||
|
<p>
|
||||||
|
<span className="font-medium text-content">Summary:</span>{" "}
|
||||||
|
{summary.summaryPreview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{summary.recentTurns.length > 0 && (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{summary.recentTurns.map((turn) => (
|
||||||
|
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
|
||||||
|
<span className="font-medium text-content">{turn.role}:</span>{" "}
|
||||||
|
{turn.textPreview}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -127,13 +207,82 @@ function TicketRow({
|
|||||||
function AgentRow({
|
function AgentRow({
|
||||||
agent,
|
agent,
|
||||||
conversations,
|
conversations,
|
||||||
|
visibleLeaves,
|
||||||
|
layout,
|
||||||
|
projectId,
|
||||||
|
onRefresh,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentWorkState;
|
agent: AgentWorkState;
|
||||||
conversations: Map<string, ConversationWorkSummary>;
|
conversations: Map<string, ConversationWorkSummary>;
|
||||||
|
visibleLeaves: LeafCell[];
|
||||||
|
layout: LayoutViewModel;
|
||||||
|
projectId: string;
|
||||||
|
onRefresh: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
|
const { agent: agentGateway } = useGateways();
|
||||||
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const live = agent.live !== undefined;
|
const live = agent.live !== undefined;
|
||||||
const busy = agent.busy.state === "busy";
|
const busy = agent.busy.state === "busy";
|
||||||
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
|
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
|
||||||
|
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
|
||||||
|
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
||||||
|
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
||||||
|
const currentTurn =
|
||||||
|
busy || tickets.some((ticket) => ticket.status === "inProgress");
|
||||||
|
|
||||||
|
async function refreshAfterAction(): Promise<void> {
|
||||||
|
await onRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attach(): Promise<void> {
|
||||||
|
if (!agent.live || !attachTarget || !agentGateway?.attachLiveAgent) return;
|
||||||
|
setActionBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const attached = await agentGateway.attachLiveAgent(
|
||||||
|
projectId,
|
||||||
|
agent.agentId,
|
||||||
|
attachTarget.id,
|
||||||
|
);
|
||||||
|
await layout.attachLiveAgentToCell(
|
||||||
|
attachTarget.id,
|
||||||
|
agent.agentId,
|
||||||
|
attached.sessionId,
|
||||||
|
);
|
||||||
|
await refreshAfterAction();
|
||||||
|
goToCell(attachTarget.id);
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(): Promise<void> {
|
||||||
|
if (!agent.live || !agentGateway?.stopLiveAgent) return;
|
||||||
|
const warning = currentTurn
|
||||||
|
? " The current turn will be interrupted."
|
||||||
|
: "";
|
||||||
|
if (!window.confirm(`Stop ${agent.name}?${warning}`)) return;
|
||||||
|
setActionBusy(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const stopped = await agentGateway.stopLiveAgent(projectId, agent.agentId);
|
||||||
|
if (visibleNodeIds.has(agent.live.nodeId)) {
|
||||||
|
const leaf = visibleLeaves.find((candidate) => candidate.id === agent.live?.nodeId);
|
||||||
|
if (!leaf?.session || leaf.session === stopped.sessionId) {
|
||||||
|
await layout.setSession(agent.live.nodeId, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await refreshAfterAction();
|
||||||
|
} catch (e) {
|
||||||
|
setMessage(e instanceof Error ? e.message : String(e));
|
||||||
|
} finally {
|
||||||
|
setActionBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="py-2 first:pt-0 last:pb-0">
|
<li className="py-2 first:pt-0 last:pb-0">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@ -173,8 +322,52 @@ function AgentRow({
|
|||||||
{shortTicket(agent.busy.ticket)}
|
{shortTicket(agent.busy.ticket)}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
|
{agent.live && liveVisible && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => goToCell(agent.live!.nodeId)}
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{agent.live && !liveVisible && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!attachTarget || !agentGateway?.attachLiveAgent}
|
||||||
|
loading={actionBusy}
|
||||||
|
title={
|
||||||
|
attachTarget
|
||||||
|
? undefined
|
||||||
|
: "No empty visible cell available"
|
||||||
|
}
|
||||||
|
onClick={() => void attach()}
|
||||||
|
>
|
||||||
|
Attach
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{agent.live && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
loading={actionBusy}
|
||||||
|
disabled={!agentGateway?.stopLiveAgent}
|
||||||
|
onClick={() => void stop()}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{agent.live && !liveVisible && !attachTarget && (
|
||||||
|
<p className="mt-1 text-xs text-muted">No empty visible cell.</p>
|
||||||
|
)}
|
||||||
|
{message && (
|
||||||
|
<p role="alert" className="mt-1 text-xs text-danger">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{tickets.length > 0 && (
|
{tickets.length > 0 && (
|
||||||
<ul
|
<ul
|
||||||
aria-label={`${agent.name} tickets`}
|
aria-label={`${agent.name} tickets`}
|
||||||
@ -195,7 +388,9 @@ function AgentRow({
|
|||||||
|
|
||||||
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
|
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
|
||||||
const vm = useProjectWorkState(projectId);
|
const vm = useProjectWorkState(projectId);
|
||||||
|
const layoutVm = useLayout(projectId);
|
||||||
const agents = vm.state?.agents ?? [];
|
const agents = vm.state?.agents ?? [];
|
||||||
|
const visibleLeaves = layoutVm.layout ? leaves(layoutVm.layout) : [];
|
||||||
const conversations = new Map(
|
const conversations = new Map(
|
||||||
(vm.state?.conversations ?? []).map((summary) => [
|
(vm.state?.conversations ?? []).map((summary) => [
|
||||||
summary.conversationId,
|
summary.conversationId,
|
||||||
@ -239,6 +434,10 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps)
|
|||||||
key={agent.agentId}
|
key={agent.agentId}
|
||||||
agent={agent}
|
agent={agent}
|
||||||
conversations={conversations}
|
conversations={conversations}
|
||||||
|
visibleLeaves={visibleLeaves}
|
||||||
|
layout={layoutVm}
|
||||||
|
projectId={projectId}
|
||||||
|
onRefresh={vm.refresh}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@ -1,8 +1,14 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
|
||||||
import { DIProvider } from "@/app/di";
|
import { DIProvider } from "@/app/di";
|
||||||
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
|
import {
|
||||||
|
MockAgentGateway,
|
||||||
|
MockLayoutGateway,
|
||||||
|
MockSystemGateway,
|
||||||
|
MockWorkStateGateway,
|
||||||
|
} from "@/adapters/mock";
|
||||||
|
import { leaves } from "@/features/layout/layout";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
|
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
|
||||||
|
|
||||||
@ -11,8 +17,9 @@ const PROJECT_ID = "project-work-state-test";
|
|||||||
function renderPanel(
|
function renderPanel(
|
||||||
workState: MockWorkStateGateway = new MockWorkStateGateway(),
|
workState: MockWorkStateGateway = new MockWorkStateGateway(),
|
||||||
system: MockSystemGateway = new MockSystemGateway(),
|
system: MockSystemGateway = new MockSystemGateway(),
|
||||||
|
overrides: Partial<Gateways> = {},
|
||||||
) {
|
) {
|
||||||
const gateways = { workState, system } as unknown as Gateways;
|
const gateways = { workState, system, ...overrides } as unknown as Gateways;
|
||||||
return {
|
return {
|
||||||
workState,
|
workState,
|
||||||
system,
|
system,
|
||||||
@ -411,4 +418,311 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
expect(screen.getByText("#1 In progress")).toBeTruthy();
|
expect(screen.getByText("#1 In progress")).toBeTruthy();
|
||||||
await waitFor(() => expect(spy).toHaveBeenCalled());
|
await waitFor(() => expect(spy).toHaveBeenCalled());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("Open focuses a visible live cell and does not launch", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.dataset.nodeId = leafId;
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "agent-open",
|
||||||
|
name: "Visible",
|
||||||
|
profileId: "codex",
|
||||||
|
live: { nodeId: leafId, sessionId: "session-open", kind: "pty" },
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(workState, new MockSystemGateway(), { layout, agent });
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Open" }));
|
||||||
|
|
||||||
|
expect(host.style.outline).toContain("2px solid");
|
||||||
|
expect(launchSpy).not.toHaveBeenCalled();
|
||||||
|
host.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Attach rebinds a hidden live agent to the deterministic empty cell", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const created = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Hidden",
|
||||||
|
profileId: "codex",
|
||||||
|
});
|
||||||
|
await agent.launchAgent(
|
||||||
|
PROJECT_ID,
|
||||||
|
created.id,
|
||||||
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: "hidden-node" },
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
const attachSpy = vi.spyOn(agent, "attachLiveAgent");
|
||||||
|
const launchSpy = vi.spyOn(agent, "launchAgent");
|
||||||
|
const target = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: created.id,
|
||||||
|
name: "Hidden",
|
||||||
|
profileId: "codex",
|
||||||
|
live: { nodeId: "hidden-node", sessionId: "mock-agent-session-1", kind: "pty" },
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(workState, new MockSystemGateway(), { layout, agent });
|
||||||
|
|
||||||
|
const attach = await screen.findByRole("button", { name: "Attach" });
|
||||||
|
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(false));
|
||||||
|
fireEvent.click(attach);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(attachSpy).toHaveBeenCalledWith(PROJECT_ID, created.id, target),
|
||||||
|
);
|
||||||
|
await waitFor(async () => {
|
||||||
|
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
|
||||||
|
expect(leaf.agent).toBe(created.id);
|
||||||
|
expect(leaf.session).toBe("mock-agent-session-1");
|
||||||
|
});
|
||||||
|
expect(launchSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables Attach when no visible target cell exists", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
|
||||||
|
await layout.mutateLayout(PROJECT_ID, {
|
||||||
|
type: "setCellAgent",
|
||||||
|
target: leafId,
|
||||||
|
agent: "other-agent",
|
||||||
|
});
|
||||||
|
await layout.mutateLayout(PROJECT_ID, {
|
||||||
|
type: "setSession",
|
||||||
|
target: leafId,
|
||||||
|
session: "occupied-session",
|
||||||
|
});
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "agent-hidden",
|
||||||
|
name: "Hidden",
|
||||||
|
profileId: "codex",
|
||||||
|
live: { nodeId: "hidden-node", sessionId: "session-hidden", kind: "pty" },
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(workState, new MockSystemGateway(), {
|
||||||
|
layout,
|
||||||
|
agent: new MockAgentGateway(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const attach = await screen.findByRole("button", { name: "Attach" });
|
||||||
|
await waitFor(() => expect((attach as HTMLButtonElement).disabled).toBe(true));
|
||||||
|
expect(await screen.findByText("No empty visible cell.")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Stop confirms with a busy warning, stops the live agent, and refreshes Work", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
const layout = new MockLayoutGateway();
|
||||||
|
const agent = new MockAgentGateway();
|
||||||
|
const created = await agent.createAgent(PROJECT_ID, {
|
||||||
|
name: "Runner",
|
||||||
|
profileId: "codex",
|
||||||
|
});
|
||||||
|
const leafId = leaves(await layout.loadLayout(PROJECT_ID))[0].id;
|
||||||
|
await agent.launchAgent(
|
||||||
|
PROJECT_ID,
|
||||||
|
created.id,
|
||||||
|
{ cwd: "/x", rows: 24, cols: 80, nodeId: leafId },
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
|
await layout.mutateLayout(PROJECT_ID, {
|
||||||
|
type: "setCellAgent",
|
||||||
|
target: leafId,
|
||||||
|
agent: created.id,
|
||||||
|
});
|
||||||
|
await layout.mutateLayout(PROJECT_ID, {
|
||||||
|
type: "setSession",
|
||||||
|
target: leafId,
|
||||||
|
session: "mock-agent-session-1",
|
||||||
|
});
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: created.id,
|
||||||
|
name: "Runner",
|
||||||
|
profileId: "codex",
|
||||||
|
live: { nodeId: leafId, sessionId: "mock-agent-session-1", kind: "pty" },
|
||||||
|
busy: { state: "busy", ticket: "ticket-stop", sinceMs: 1 },
|
||||||
|
tickets: [
|
||||||
|
{
|
||||||
|
ticketId: "ticket-stop",
|
||||||
|
conversationId: "conv-stop",
|
||||||
|
position: 0,
|
||||||
|
status: "inProgress",
|
||||||
|
source: { kind: "human" },
|
||||||
|
requesterLabel: "Human",
|
||||||
|
taskPreview: "Do work",
|
||||||
|
taskLen: 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [],
|
||||||
|
});
|
||||||
|
const stopSpy = vi.spyOn(agent, "stopLiveAgent");
|
||||||
|
const refreshSpy = vi.spyOn(workState, "getProjectWorkState");
|
||||||
|
const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true);
|
||||||
|
|
||||||
|
renderPanel(workState, new MockSystemGateway(), { layout, agent });
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Stop" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith(
|
||||||
|
"Stop Runner? The current turn will be interrupted.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(stopSpy).toHaveBeenCalledWith(PROJECT_ID, created.id));
|
||||||
|
await waitFor(() => expect(refreshSpy).toHaveBeenCalled());
|
||||||
|
await waitFor(async () => {
|
||||||
|
const leaf = leaves(await layout.loadLayout(PROJECT_ID))[0];
|
||||||
|
expect(leaf.session).toBeNull();
|
||||||
|
});
|
||||||
|
confirmSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("View conversation exposes only Lot C preview fields", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "agent-view",
|
||||||
|
name: "Viewer",
|
||||||
|
profileId: "codex",
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [
|
||||||
|
{
|
||||||
|
ticketId: "ticket-view",
|
||||||
|
conversationId: "conversation-view",
|
||||||
|
position: 0,
|
||||||
|
status: "queued",
|
||||||
|
source: { kind: "human" },
|
||||||
|
requesterLabel: "Human",
|
||||||
|
taskPreview: "Inspect preview",
|
||||||
|
taskLen: 15,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: "conversation-view",
|
||||||
|
status: "ready",
|
||||||
|
objectivePreview: "Keep it compact",
|
||||||
|
summaryPreview: "Only the exposed summary preview.",
|
||||||
|
summaryLen: 33,
|
||||||
|
upTo: "internal-turn-id",
|
||||||
|
recentTurns: [
|
||||||
|
{
|
||||||
|
role: "response",
|
||||||
|
source: { kind: "agent", agentId: "agent-view" },
|
||||||
|
atMs: 42,
|
||||||
|
textPreview: "Recent exposed turn.",
|
||||||
|
textLen: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(workState);
|
||||||
|
|
||||||
|
const list = await screen.findByLabelText("Viewer tickets");
|
||||||
|
fireEvent.click(within(list).getByRole("button", { name: "View" }));
|
||||||
|
|
||||||
|
expect(within(list).getByText("Goal:")).toBeTruthy();
|
||||||
|
expect(within(list).getByText("Keep it compact")).toBeTruthy();
|
||||||
|
expect(within(list).getByText("Summary:")).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
within(list).getByText("Only the exposed summary preview."),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(within(list).getByText("response:")).toBeTruthy();
|
||||||
|
expect(within(list).getByText("Recent exposed turn.")).toBeTruthy();
|
||||||
|
expect(list.textContent).not.toContain("internal-turn-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Copy summary copies only the exposed preview text", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
agents: [
|
||||||
|
{
|
||||||
|
agentId: "agent-copy",
|
||||||
|
name: "Copier",
|
||||||
|
profileId: "codex",
|
||||||
|
busy: { state: "idle" },
|
||||||
|
tickets: [
|
||||||
|
{
|
||||||
|
ticketId: "ticket-copy",
|
||||||
|
conversationId: "conversation-copy",
|
||||||
|
position: 0,
|
||||||
|
status: "queued",
|
||||||
|
source: { kind: "human" },
|
||||||
|
requesterLabel: "Human",
|
||||||
|
taskPreview: "Copy preview",
|
||||||
|
taskLen: 12,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
conversations: [
|
||||||
|
{
|
||||||
|
conversationId: "conversation-copy",
|
||||||
|
status: "ready",
|
||||||
|
objectivePreview: null,
|
||||||
|
summaryPreview: "Copy this summary preview only.",
|
||||||
|
summaryLen: 31,
|
||||||
|
upTo: null,
|
||||||
|
recentTurns: [
|
||||||
|
{
|
||||||
|
role: "prompt",
|
||||||
|
source: { kind: "human" },
|
||||||
|
atMs: 1,
|
||||||
|
textPreview: "Do not copy this turn.",
|
||||||
|
textLen: 22,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
renderPanel(workState);
|
||||||
|
|
||||||
|
const list = await screen.findByLabelText("Copier tickets");
|
||||||
|
fireEvent.click(within(list).getByRole("button", { name: "Copy" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(writeText).toHaveBeenCalledWith("Copy this summary preview only."),
|
||||||
|
);
|
||||||
|
expect(await within(list).findByRole("button", { name: "Copied" })).toBeTruthy();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -108,6 +108,11 @@ export interface AgentGateway {
|
|||||||
agentId: string,
|
agentId: string,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
): Promise<LiveAgent>;
|
): Promise<LiveAgent>;
|
||||||
|
/**
|
||||||
|
* Stops an already-live agent session without going through terminal teardown
|
||||||
|
* commands from the Work panel. The backend owns the live-agent lifecycle.
|
||||||
|
*/
|
||||||
|
stopLiveAgent?(projectId: string, agentId: string): Promise<StoppedLiveAgent>;
|
||||||
/** Creates a new agent from scratch; returns the created agent. */
|
/** Creates a new agent from scratch; returns the created agent. */
|
||||||
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
|
createAgent(projectId: string, input: CreateAgentInput): Promise<Agent>;
|
||||||
/**
|
/**
|
||||||
@ -205,7 +210,16 @@ export interface LiveAgent {
|
|||||||
* The live PTY session id, when the backend exposes it. Required for opening
|
* The live PTY session id, when the backend exposes it. Required for opening
|
||||||
* a background/running agent in a new visible cell without respawning it.
|
* a background/running agent in a new visible cell without respawning it.
|
||||||
*/
|
*/
|
||||||
sessionId?: string;
|
sessionId: string;
|
||||||
|
/** Runtime kind backing the live agent. */
|
||||||
|
kind: "pty" | "structured";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result returned when the backend stops a live agent. */
|
||||||
|
export interface StoppedLiveAgent {
|
||||||
|
agentId: string;
|
||||||
|
sessionId: string;
|
||||||
|
kind: "pty" | "structured";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user