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:
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal file
106
frontend/src/features/workstate/ProjectWorkStatePanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
frontend/src/features/workstate/index.ts
Normal file
6
frontend/src/features/workstate/index.ts
Normal 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";
|
||||
73
frontend/src/features/workstate/useProjectWorkState.ts
Normal file
73
frontend/src/features/workstate/useProjectWorkState.ts
Normal 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 };
|
||||
}
|
||||
111
frontend/src/features/workstate/workstate.test.tsx
Normal file
111
frontend/src/features/workstate/workstate.test.tsx
Normal 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user