feat(workstate): UI live-state des conversations/délégations (Lot A frontend)

Ajoute le port et l'adaptateur workState (+ mock) côté frontend, le type domaine
associé, et la feature `workstate` (panneau ProjectWorkStatePanel + hook
useProjectWorkState) consommant le read-model live exposé par le backend.
Intègre le panneau dans ProjectsView. Tests verts (workstate + projects).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:06:28 +02:00
parent aae18499a9
commit 17685a08e1
11 changed files with 397 additions and 1 deletions

View File

@ -38,6 +38,7 @@ import { SkillsPanel } from "@/features/skills";
import { MemoryPanel } from "@/features/memory";
import { EmbedderSettings } from "@/features/embedder";
import { PermissionsPanel } from "@/features/permissions";
import { ProjectWorkStatePanel } from "@/features/workstate";
import { GitPanel, GitGraphView } from "@/features/git";
import { Button, Input, Panel, Tabs, cn } from "@/shared";
import { useGateways } from "@/app/di";
@ -47,6 +48,7 @@ import { useProjects } from "./useProjects";
type SidebarTab =
| "projects"
| "context"
| "work"
| "agents"
| "templates"
| "skills"
@ -57,6 +59,7 @@ type SidebarTab =
const SIDEBAR_TABS: { id: SidebarTab; label: string }[] = [
{ id: "projects", label: "Projects" },
{ id: "context", label: "Context" },
{ id: "work", label: "Work" },
{ id: "agents", label: "Agents" },
{ id: "templates", label: "Templates" },
{ id: "skills", label: "Skills" },
@ -282,6 +285,14 @@ export function ProjectsView() {
<p className="text-sm text-muted">Open a project to edit context.</p>
)}
{/* Work-state panel */}
{sidebarTab === "work" && active && (
<ProjectWorkStatePanel projectId={active.id} />
)}
{sidebarTab === "work" && !active && (
<p className="text-sm text-muted">Open a project to view work state.</p>
)}
{/* Agents panel — only rendered when active project exists */}
{sidebarTab === "agents" && active && (
<AgentsPanel projectId={active.id} projectRoot={active.root} />

View File

@ -12,7 +12,7 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway } from "@/adapters/mock";
import { MockAgentGateway, MockGitGateway, MockProfileGateway, MockProjectGateway, MockSystemGateway, MockTemplateGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { ProjectsView } from "./ProjectsView";
@ -33,6 +33,7 @@ function renderView(
profile: new MockProfileGateway(),
template: new MockTemplateGateway(agentGateway),
git: new MockGitGateway(),
workState: new MockWorkStateGateway(),
} as unknown as Gateways;
return {
project,
@ -130,6 +131,20 @@ describe("ProjectsView (with MockProjectGateway)", () => {
expect([a.id, b.id]).toHaveLength(2);
});
it("shows and renders the Work sidebar tab for an active project", async () => {
const project = new MockProjectGateway();
await project.createProject("alpha", "/p/a");
renderView(project);
await screen.findByText("/p/a");
fireEvent.click(screen.getByRole("button", { name: "Open" }));
await screen.findByRole("tab", { name: "alpha" });
fireEvent.click(screen.getByRole("button", { name: "Work" }));
expect(await screen.findByText("No agent work state.")).toBeTruthy();
});
it("closing a tab removes it from the tab bar", async () => {
renderView();
await createProject("alpha", "/home/me/alpha");

View File

@ -0,0 +1,106 @@
/**
* Read-only project work-state panel: one row per agent, showing live/offline
* and idle/busy state from the backend read-model.
*/
import type { AgentWorkState } from "@/domain";
import { Button, Panel, Spinner, cn } from "@/shared";
import { useProjectWorkState } from "./useProjectWorkState";
export interface ProjectWorkStatePanelProps {
projectId: string;
}
function shortTicket(ticket: string): string {
return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
}
function AgentRow({ agent }: { agent: AgentWorkState }) {
const live = agent.live !== undefined;
const busy = agent.busy.state === "busy";
return (
<li className="flex items-center justify-between gap-3 py-2 first:pt-0 last:pb-0">
<span className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-sm font-medium text-content">
{agent.name}
</span>
<code className="truncate text-xs text-muted">{agent.profileId}</code>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
live
? "bg-success/15 text-success"
: "bg-raised text-muted",
)}
>
{live ? "Live" : "Offline"}
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-medium",
busy
? "bg-warning/15 text-warning"
: "bg-raised text-muted",
)}
>
{busy ? "Busy" : "Idle"}
</span>
{agent.busy.state === "busy" && (
<code
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
title={agent.busy.ticket}
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
>
{shortTicket(agent.busy.ticket)}
</code>
)}
</span>
</li>
);
}
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
const vm = useProjectWorkState(projectId);
const agents = vm.state?.agents ?? [];
return (
<Panel
title="Work"
actions={
<Button
size="sm"
variant="ghost"
onClick={() => void vm.refresh()}
loading={vm.busy}
>
Refresh
</Button>
}
>
{vm.error && (
<p
role="alert"
className="mb-3 rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
>
{vm.error}
</p>
)}
{vm.busy && vm.state === null ? (
<div className="flex items-center gap-2 text-sm text-muted">
<Spinner size={14} />
<span>Loading work state</span>
</div>
) : agents.length === 0 ? (
<p className="text-sm text-muted">No agent work state.</p>
) : (
<ul className="flex flex-col divide-y divide-border">
{agents.map((agent) => (
<AgentRow key={agent.agentId} agent={agent} />
))}
</ul>
)}
</Panel>
);
}

View File

@ -0,0 +1,6 @@
/** Work-state feature: read-only live/busy read-model for a project. */
export { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
export type { ProjectWorkStatePanelProps } from "./ProjectWorkStatePanel";
export { useProjectWorkState } from "./useProjectWorkState";
export type { ProjectWorkStateViewModel } from "./useProjectWorkState";

View File

@ -0,0 +1,73 @@
/**
* View-model hook for the project work-state read-model.
*
* It consumes the WorkStateGateway only, and optionally subscribes to existing
* domain events through SystemGateway to refresh the read-only snapshot.
*/
import { useCallback, useEffect, useState } from "react";
import type { GatewayError, ProjectWorkState } from "@/domain";
import { useGateways } from "@/app/di";
export interface ProjectWorkStateViewModel {
state: ProjectWorkState | null;
error: string | null;
busy: boolean;
refresh: () => Promise<void>;
}
function describe(e: unknown): string {
if (e && typeof e === "object" && "message" in e) {
return String((e as GatewayError).message);
}
return String(e);
}
export function useProjectWorkState(projectId: string): ProjectWorkStateViewModel {
const { workState, system } = useGateways();
const [state, setState] = useState<ProjectWorkState | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setBusy(true);
setError(null);
try {
setState(await workState.getProjectWorkState(projectId));
} catch (e) {
setError(describe(e));
} finally {
setBusy(false);
}
}, [projectId, workState]);
useEffect(() => {
void refresh();
}, [refresh]);
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system.onDomainEvent((event) => {
if (
event.type === "agentLaunched" ||
event.type === "agentExited" ||
event.type === "agentBusyChanged" ||
event.type === "orchestratorRequestProcessed"
) {
void refresh();
}
}).then((u) => {
if (cancelled) u();
else unsubscribe = u;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [refresh, system]);
return { state, error, busy, refresh };
}

View File

@ -0,0 +1,111 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import { DIProvider } from "@/app/di";
import { MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
const PROJECT_ID = "project-work-state-test";
function renderPanel(
workState: MockWorkStateGateway = new MockWorkStateGateway(),
system: MockSystemGateway = new MockSystemGateway(),
) {
const gateways = { workState, system } as unknown as Gateways;
return {
workState,
system,
...render(
<DIProvider gateways={gateways}>
<ProjectWorkStatePanel projectId={PROJECT_ID} />
</DIProvider>,
),
};
}
describe("ProjectWorkStatePanel", () => {
it("renders the empty state", async () => {
renderPanel();
expect(await screen.findByText("No agent work state.")).toBeTruthy();
});
it("renders an idle offline agent", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-1",
name: "Planner",
profileId: "codex",
busy: { state: "idle" },
},
],
});
renderPanel(workState);
expect(await screen.findByText("Planner")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Idle")).toBeTruthy();
});
it("renders a live busy agent with a short ticket", async () => {
const workState = new MockWorkStateGateway();
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-2",
name: "Builder",
profileId: "claude",
live: {
nodeId: "node-a",
sessionId: "session-a",
kind: "pty",
},
busy: {
state: "busy",
ticket: "4c65d981-da5e-412d-85ed-3b9779b670fa",
sinceMs: 123,
},
},
],
});
renderPanel(workState);
expect(await screen.findByText("Builder")).toBeTruthy();
expect(screen.getByText("Live")).toBeTruthy();
expect(screen.getByText("Busy")).toBeTruthy();
expect(screen.getByLabelText("busy ticket 4c65d981")).toBeTruthy();
});
it("refreshes when a relevant domain event fires", async () => {
const workState = new MockWorkStateGateway();
const system = new MockSystemGateway();
renderPanel(workState, system);
await screen.findByText("No agent work state.");
const spy = vi.spyOn(workState, "getProjectWorkState");
workState._setProjectWorkState(PROJECT_ID, {
agents: [
{
agentId: "agent-3",
name: "Responder",
profileId: "gemini",
busy: { state: "busy", ticket: "ticket-123456789", sinceMs: 456 },
},
],
});
system.emit({
type: "agentBusyChanged",
agentId: "agent-3",
busy: true,
});
expect(await screen.findByText("Responder")).toBeTruthy();
await waitFor(() => expect(spy).toHaveBeenCalled());
});
});