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:
@ -3,12 +3,19 @@
|
||||
* and idle/busy state from the backend read-model, plus the current input queue.
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
import type {
|
||||
AgentTicketState,
|
||||
AgentWorkState,
|
||||
ConversationPreviewStatus,
|
||||
ConversationWorkSummary,
|
||||
LeafCell,
|
||||
} 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 { useProjectWorkState } from "./useProjectWorkState";
|
||||
|
||||
@ -52,26 +59,99 @@ function summaryText(summary: ConversationWorkSummary): string | 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({
|
||||
summary,
|
||||
}: {
|
||||
summary: ConversationWorkSummary;
|
||||
}) {
|
||||
const text = summaryText(summary);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
if (!text) return null;
|
||||
return (
|
||||
<div className="mt-1 flex min-w-0 items-start gap-2 pl-1 text-xs text-muted">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
||||
summary.status === "ready"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
{summaryLabel(summary.status)}
|
||||
</span>
|
||||
<span className="min-w-0 break-words">{text}</span>
|
||||
<div className="mt-1 min-w-0 pl-1 text-xs text-muted">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 shrink-0 rounded-full px-1.5 py-0.5 font-medium",
|
||||
summary.status === "ready"
|
||||
? "bg-success/10 text-success"
|
||||
: "bg-raised text-muted",
|
||||
)}
|
||||
>
|
||||
{summaryLabel(summary.status)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -127,13 +207,82 @@ function TicketRow({
|
||||
function AgentRow({
|
||||
agent,
|
||||
conversations,
|
||||
visibleLeaves,
|
||||
layout,
|
||||
projectId,
|
||||
onRefresh,
|
||||
}: {
|
||||
agent: AgentWorkState;
|
||||
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 busy = agent.busy.state === "busy";
|
||||
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 (
|
||||
<li className="py-2 first:pt-0 last:pb-0">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@ -173,8 +322,52 @@ function AgentRow({
|
||||
{shortTicket(agent.busy.ticket)}
|
||||
</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>
|
||||
</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 && (
|
||||
<ul
|
||||
aria-label={`${agent.name} tickets`}
|
||||
@ -195,7 +388,9 @@ function AgentRow({
|
||||
|
||||
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
|
||||
const vm = useProjectWorkState(projectId);
|
||||
const layoutVm = useLayout(projectId);
|
||||
const agents = vm.state?.agents ?? [];
|
||||
const visibleLeaves = layoutVm.layout ? leaves(layoutVm.layout) : [];
|
||||
const conversations = new Map(
|
||||
(vm.state?.conversations ?? []).map((summary) => [
|
||||
summary.conversationId,
|
||||
@ -239,6 +434,10 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps)
|
||||
key={agent.agentId}
|
||||
agent={agent}
|
||||
conversations={conversations}
|
||||
visibleLeaves={visibleLeaves}
|
||||
layout={layoutVm}
|
||||
projectId={projectId}
|
||||
onRefresh={vm.refresh}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@ -1,8 +1,14 @@
|
||||
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 { 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 { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
|
||||
|
||||
@ -11,8 +17,9 @@ const PROJECT_ID = "project-work-state-test";
|
||||
function renderPanel(
|
||||
workState: MockWorkStateGateway = new MockWorkStateGateway(),
|
||||
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 {
|
||||
workState,
|
||||
system,
|
||||
@ -411,4 +418,311 @@ describe("ProjectWorkStatePanel", () => {
|
||||
expect(screen.getByText("#1 In progress")).toBeTruthy();
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user