Merge feature/ticket87-web-workspace-collapse-tasks into develop
Repliage par défaut des background tasks par agent dans le WebWorkspace (#87), fix frontend pur, tests verts (887 passants, tsc clean). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@ -253,6 +253,28 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
|
|||||||
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
|
const [openAgentId, setOpenAgentId] = useState<string | null>(null);
|
||||||
const agents = vm.state?.agents ?? [];
|
const agents = vm.state?.agents ?? [];
|
||||||
|
|
||||||
|
// Per-agent background-tasks disclosure (collapsed by default). A live refresh
|
||||||
|
// must never flip this — only drop an entry once its agent has no tasks left,
|
||||||
|
// so state doesn't leak for agents that no longer have a background section.
|
||||||
|
const [expandedBgAgents, setExpandedBgAgents] = useState<Set<string>>(new Set());
|
||||||
|
useEffect(() => {
|
||||||
|
const withTasks = new Set(
|
||||||
|
agents.filter((a) => (a.backgroundTasks ?? []).length > 0).map((a) => a.agentId),
|
||||||
|
);
|
||||||
|
setExpandedBgAgents((cur) => {
|
||||||
|
const next = new Set([...cur].filter((id) => withTasks.has(id)));
|
||||||
|
return next.size === cur.size ? cur : next;
|
||||||
|
});
|
||||||
|
}, [agents]);
|
||||||
|
const toggleBgExpanded = useCallback((agentId: string) => {
|
||||||
|
setExpandedBgAgents((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
if (next.has(agentId)) next.delete(agentId);
|
||||||
|
else next.add(agentId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="mt-2">
|
<Panel className="mt-2">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
@ -284,6 +306,8 @@ function LiveProjectPanel({ projectId, root }: { projectId: string; root: string
|
|||||||
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
|
setOpenAgentId((cur) => (cur === a.agentId ? null : a.agentId))
|
||||||
}
|
}
|
||||||
onRefresh={vm.refresh}
|
onRefresh={vm.refresh}
|
||||||
|
bgExpanded={expandedBgAgents.has(a.agentId)}
|
||||||
|
onToggleBgExpanded={() => toggleBgExpanded(a.agentId)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@ -310,11 +334,15 @@ function AgentLiveRow({
|
|||||||
open,
|
open,
|
||||||
onToggleOpen,
|
onToggleOpen,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
bgExpanded,
|
||||||
|
onToggleBgExpanded,
|
||||||
}: {
|
}: {
|
||||||
agent: AgentWorkState;
|
agent: AgentWorkState;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggleOpen: () => void;
|
onToggleOpen: () => void;
|
||||||
onRefresh: () => Promise<void>;
|
onRefresh: () => Promise<void>;
|
||||||
|
bgExpanded: boolean;
|
||||||
|
onToggleBgExpanded: () => void;
|
||||||
}) {
|
}) {
|
||||||
const live = agent.live !== undefined;
|
const live = agent.live !== undefined;
|
||||||
const busy = agent.busy?.state === "busy";
|
const busy = agent.busy?.state === "busy";
|
||||||
@ -360,12 +388,35 @@ function AgentLiveRow({
|
|||||||
|
|
||||||
{backgroundTasks.length > 0 && (
|
{backgroundTasks.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<p className="text-[11px] font-semibold uppercase tracking-wide text-faint">Background tasks</p>
|
<button
|
||||||
<ul aria-label={`${agent.name} background tasks`} className="mt-1 flex flex-col gap-1">
|
type="button"
|
||||||
{backgroundTasks.map((task) => (
|
aria-expanded={bgExpanded}
|
||||||
<WebBackgroundTaskRow key={task.taskId} task={task} onRefresh={onRefresh} />
|
aria-controls={`web-bg-tasks-${agent.agentId}`}
|
||||||
))}
|
aria-label={
|
||||||
</ul>
|
bgExpanded
|
||||||
|
? `Masquer les background tasks de ${agent.name}`
|
||||||
|
: `Afficher les background tasks de ${agent.name}`
|
||||||
|
}
|
||||||
|
onClick={onToggleBgExpanded}
|
||||||
|
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-faint hover:text-muted"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">{bgExpanded ? "▾" : "▸"}</span>
|
||||||
|
Background tasks
|
||||||
|
<span className="rounded-full bg-raised px-1.5 py-0.5 font-medium normal-case tracking-normal text-muted">
|
||||||
|
{backgroundTasks.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{bgExpanded && (
|
||||||
|
<ul
|
||||||
|
id={`web-bg-tasks-${agent.agentId}`}
|
||||||
|
aria-label={`${agent.name} background tasks`}
|
||||||
|
className="mt-1 flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
{backgroundTasks.map((task) => (
|
||||||
|
<WebBackgroundTaskRow key={task.taskId} task={task} onRefresh={onRefresh} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@ -57,6 +57,32 @@ function renderPaired(gateways: Gateways) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
|
const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
|
||||||
|
const AGENT_FRONT = { agentId: "a2", name: "Front", profileId: "p2", busy: { state: "idle" as const }, tickets: [] };
|
||||||
|
|
||||||
|
function backgroundTask(
|
||||||
|
taskId: string,
|
||||||
|
ownerAgentId: string,
|
||||||
|
updatedAtMs = 1,
|
||||||
|
): BackgroundCompletion {
|
||||||
|
return {
|
||||||
|
taskId,
|
||||||
|
ownerAgentId,
|
||||||
|
projectId: "p",
|
||||||
|
kind: "shell",
|
||||||
|
status: "running",
|
||||||
|
exitCode: null,
|
||||||
|
summary: null,
|
||||||
|
stdoutTail: null,
|
||||||
|
stderrTail: null,
|
||||||
|
updatedAtMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openLivePanel(gateways: Gateways) {
|
||||||
|
renderPaired(gateways);
|
||||||
|
fireEvent.click(await screen.findByText("Demo"));
|
||||||
|
return screen.findByTestId("web-workstate");
|
||||||
|
}
|
||||||
|
|
||||||
describe("WebWorkspace live surfaces (F5)", () => {
|
describe("WebWorkspace live surfaces (F5)", () => {
|
||||||
it("refreshes the work-state on a relevant event.domain event", async () => {
|
it("refreshes the work-state on a relevant event.domain event", async () => {
|
||||||
@ -83,23 +109,14 @@ describe("WebWorkspace live surfaces (F5)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders background tasks and wires cancel through the gateway", async () => {
|
it("renders background tasks and wires cancel through the gateway", async () => {
|
||||||
const task: BackgroundCompletion = {
|
const task = backgroundTask("bt-1", "a1");
|
||||||
taskId: "bt-1",
|
|
||||||
ownerAgentId: "a1",
|
|
||||||
projectId: "p",
|
|
||||||
kind: "shell",
|
|
||||||
status: "running",
|
|
||||||
exitCode: null,
|
|
||||||
summary: null,
|
|
||||||
stdoutTail: null,
|
|
||||||
stderrTail: null,
|
|
||||||
updatedAtMs: 1,
|
|
||||||
};
|
|
||||||
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
|
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
|
||||||
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
|
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
|
||||||
renderPaired(gateways);
|
await openLivePanel(gateways);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByText("Demo"));
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Afficher les background tasks de Archi" }),
|
||||||
|
);
|
||||||
const tasks = await screen.findByLabelText("Archi background tasks");
|
const tasks = await screen.findByLabelText("Archi background tasks");
|
||||||
expect(tasks.textContent).toContain("Running");
|
expect(tasks.textContent).toContain("Running");
|
||||||
expect(tasks.textContent).toContain("shell");
|
expect(tasks.textContent).toContain("shell");
|
||||||
@ -108,6 +125,170 @@ describe("WebWorkspace live surfaces (F5)", () => {
|
|||||||
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
|
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps background task groups collapsed by default and toggles them per agent", async () => {
|
||||||
|
const { gateways } = await seeded(state([
|
||||||
|
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-a", "a1")] },
|
||||||
|
{ ...AGENT_FRONT, backgroundTasks: [backgroundTask("bt-f", "a2")] },
|
||||||
|
]));
|
||||||
|
await openLivePanel(gateways);
|
||||||
|
|
||||||
|
const archiToggle = await screen.findByRole("button", {
|
||||||
|
name: "Afficher les background tasks de Archi",
|
||||||
|
});
|
||||||
|
const frontToggle = await screen.findByRole("button", {
|
||||||
|
name: "Afficher les background tasks de Front",
|
||||||
|
});
|
||||||
|
expect(archiToggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(frontToggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(archiToggle);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Masquer les background tasks de Archi" })
|
||||||
|
.getAttribute("aria-expanded"),
|
||||||
|
).toBe("true");
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Afficher les background tasks de Front" })
|
||||||
|
.getAttribute("aria-expanded"),
|
||||||
|
).toBe("false");
|
||||||
|
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText("Front background tasks")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Front" }));
|
||||||
|
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
|
||||||
|
expect(screen.getByLabelText("Front background tasks")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates task counters without changing the current expanded state", async () => {
|
||||||
|
const { gateways, projectId } = await seeded(state([
|
||||||
|
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
|
||||||
|
]));
|
||||||
|
await openLivePanel(gateways);
|
||||||
|
|
||||||
|
const collapsedToggle = await screen.findByRole("button", {
|
||||||
|
name: "Afficher les background tasks de Archi",
|
||||||
|
});
|
||||||
|
expect(collapsedToggle.getAttribute("aria-expanded")).toBe("false");
|
||||||
|
expect(collapsedToggle.textContent).toContain("1");
|
||||||
|
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
|
||||||
|
|
||||||
|
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
|
||||||
|
projectId,
|
||||||
|
state([
|
||||||
|
{
|
||||||
|
...AGENT_IDLE,
|
||||||
|
backgroundTasks: [backgroundTask("bt-1", "a1", 1), backgroundTask("bt-2", "a1", 2)],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
(gateways.system as MockSystemGateway).emit({
|
||||||
|
type: "backgroundTaskChanged",
|
||||||
|
projectId,
|
||||||
|
taskId: "bt-2",
|
||||||
|
agentId: "a1",
|
||||||
|
state: "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Afficher les background tasks de Archi" }).textContent,
|
||||||
|
).toContain("2"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Afficher les background tasks de Archi" })
|
||||||
|
.getAttribute("aria-expanded"),
|
||||||
|
).toBe("false");
|
||||||
|
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Afficher les background tasks de Archi" }));
|
||||||
|
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
|
||||||
|
|
||||||
|
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
|
||||||
|
projectId,
|
||||||
|
state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1", 3)] }]),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
(gateways.system as MockSystemGateway).emit({
|
||||||
|
type: "backgroundTaskChanged",
|
||||||
|
projectId,
|
||||||
|
taskId: "bt-2",
|
||||||
|
agentId: "a1",
|
||||||
|
state: "completed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Masquer les background tasks de Archi" }).textContent,
|
||||||
|
).toContain("1"),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Masquer les background tasks de Archi" })
|
||||||
|
.getAttribute("aria-expanded"),
|
||||||
|
).toBe("true");
|
||||||
|
expect(screen.getByLabelText("Archi background tasks")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides and resets an open background task group when its task count reaches zero", async () => {
|
||||||
|
const { gateways, projectId } = await seeded(state([
|
||||||
|
{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-1", "a1")] },
|
||||||
|
]));
|
||||||
|
await openLivePanel(gateways);
|
||||||
|
fireEvent.click(await screen.findByRole("button", {
|
||||||
|
name: "Afficher les background tasks de Archi",
|
||||||
|
}));
|
||||||
|
expect(await screen.findByLabelText("Archi background tasks")).toBeTruthy();
|
||||||
|
|
||||||
|
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
|
||||||
|
projectId,
|
||||||
|
state([{ ...AGENT_IDLE, backgroundTasks: [] }]),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
(gateways.system as MockSystemGateway).emit({
|
||||||
|
type: "backgroundTaskChanged",
|
||||||
|
projectId,
|
||||||
|
taskId: "bt-1",
|
||||||
|
agentId: "a1",
|
||||||
|
state: "completed",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByRole("button", { name: /background tasks de Archi/ })).toBeNull(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
|
||||||
|
|
||||||
|
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
|
||||||
|
projectId,
|
||||||
|
state([{ ...AGENT_IDLE, backgroundTasks: [backgroundTask("bt-2", "a1", 2)] }]),
|
||||||
|
);
|
||||||
|
act(() => {
|
||||||
|
(gateways.system as MockSystemGateway).emit({
|
||||||
|
type: "backgroundTaskChanged",
|
||||||
|
projectId,
|
||||||
|
taskId: "bt-2",
|
||||||
|
agentId: "a1",
|
||||||
|
state: "running",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Afficher les background tasks de Archi" })
|
||||||
|
.getAttribute("aria-expanded"),
|
||||||
|
).toBe("false"),
|
||||||
|
);
|
||||||
|
expect(screen.queryByLabelText("Archi background tasks")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("re-synchronises the read-model when the WS reconnects", async () => {
|
it("re-synchronises the read-model when the WS reconnects", async () => {
|
||||||
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
|
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
|
||||||
// Register a live client whose connection state we can drive.
|
// Register a live client whose connection state we can drive.
|
||||||
|
|||||||
Reference in New Issue
Block a user