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

@ -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 };
}