fix(workstate): normalisation du work-state pour éviter l'onglet Work vide
Normalise l'état Work pour empêcher l'affichage d'un onglet Work vide. QA VERT : tsc --noEmit exit 0 ; vitest run 42 fichiers / 407 tests passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -68,6 +68,7 @@ import type {
|
|||||||
TerminalHandle,
|
TerminalHandle,
|
||||||
WorkStateGateway,
|
WorkStateGateway,
|
||||||
} from "@/ports";
|
} from "@/ports";
|
||||||
|
import { normalizeProjectWorkState } from "../workStateNormalization";
|
||||||
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
import { applyOperation, singleLeafTree } from "@/features/layout/layout";
|
||||||
|
|
||||||
export class MockSystemGateway implements SystemGateway {
|
export class MockSystemGateway implements SystemGateway {
|
||||||
@ -1681,10 +1682,7 @@ export class MockWorkStateGateway implements WorkStateGateway {
|
|||||||
private states = new Map<string, ProjectWorkState>();
|
private states = new Map<string, ProjectWorkState>();
|
||||||
|
|
||||||
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
/** Seeds the read-model returned for a project (deterministic tests/dev). */
|
||||||
_setProjectWorkState(
|
_setProjectWorkState(projectId: string, state: unknown): void {
|
||||||
projectId: string,
|
|
||||||
state: ProjectWorkState | LegacyProjectWorkState,
|
|
||||||
): void {
|
|
||||||
this.states.set(projectId, normalizeProjectWorkState(state));
|
this.states.set(projectId, normalizeProjectWorkState(state));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1695,25 +1693,6 @@ export class MockWorkStateGateway implements WorkStateGateway {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type LegacyProjectWorkState = {
|
|
||||||
agents: Array<Omit<ProjectWorkState["agents"][number], "tickets"> & {
|
|
||||||
tickets?: ProjectWorkState["agents"][number]["tickets"];
|
|
||||||
}>;
|
|
||||||
conversations?: ProjectWorkState["conversations"];
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeProjectWorkState(
|
|
||||||
state: ProjectWorkState | LegacyProjectWorkState,
|
|
||||||
): ProjectWorkState {
|
|
||||||
return {
|
|
||||||
agents: state.agents.map((agent) => ({
|
|
||||||
...structuredClone(agent),
|
|
||||||
tickets: structuredClone(agent.tickets ?? []),
|
|
||||||
})),
|
|
||||||
conversations: structuredClone(state.conversations ?? []),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mostRestrictive(
|
function mostRestrictive(
|
||||||
project?: PermissionSet["fallback"],
|
project?: PermissionSet["fallback"],
|
||||||
agent?: PermissionSet["fallback"],
|
agent?: PermissionSet["fallback"],
|
||||||
|
|||||||
@ -9,9 +9,11 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
import type { ProjectWorkState } from "@/domain";
|
import type { ProjectWorkState } from "@/domain";
|
||||||
import type { WorkStateGateway } from "@/ports";
|
import type { WorkStateGateway } from "@/ports";
|
||||||
|
import { normalizeProjectWorkState } from "./workStateNormalization";
|
||||||
|
|
||||||
export class TauriWorkStateGateway implements WorkStateGateway {
|
export class TauriWorkStateGateway implements WorkStateGateway {
|
||||||
getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
|
||||||
return invoke<ProjectWorkState>("get_project_work_state", { projectId });
|
const state = await invoke<unknown>("get_project_work_state", { projectId });
|
||||||
|
return normalizeProjectWorkState(state);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
145
frontend/src/adapters/workStateNormalization.ts
Normal file
145
frontend/src/adapters/workStateNormalization.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import type {
|
||||||
|
AgentTicketState,
|
||||||
|
AgentWorkState,
|
||||||
|
ConversationPreviewStatus,
|
||||||
|
ConversationTurnWorkPreview,
|
||||||
|
ConversationWorkSummary,
|
||||||
|
ProjectWorkState,
|
||||||
|
TicketWorkSource,
|
||||||
|
TicketWorkStatus,
|
||||||
|
WorkBusyState,
|
||||||
|
} from "@/domain";
|
||||||
|
|
||||||
|
type RecordLike = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is RecordLike {
|
||||||
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown, fallback = ""): string {
|
||||||
|
return typeof value === "string" ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberValue(value: unknown, fallback = 0): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayValue(value: unknown): unknown[] {
|
||||||
|
return Array.isArray(value) ? value : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSource(value: unknown): TicketWorkSource {
|
||||||
|
if (!isRecord(value)) return { kind: "human" };
|
||||||
|
if (value.kind === "agent") {
|
||||||
|
return { kind: "agent", agentId: stringValue(value.agentId, "unknown") };
|
||||||
|
}
|
||||||
|
return { kind: "human" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTicketStatus(value: unknown): TicketWorkStatus {
|
||||||
|
return value === "inProgress" ? "inProgress" : "queued";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBusy(value: unknown): WorkBusyState {
|
||||||
|
if (isRecord(value) && value.state === "busy") {
|
||||||
|
return {
|
||||||
|
state: "busy",
|
||||||
|
ticket: stringValue(value.ticket, "unknown"),
|
||||||
|
sinceMs: numberValue(value.sinceMs, 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { state: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTicket(value: unknown, index: number): AgentTicketState {
|
||||||
|
const ticket = isRecord(value) ? value : {};
|
||||||
|
const taskPreview = stringValue(ticket.taskPreview);
|
||||||
|
return {
|
||||||
|
ticketId: stringValue(ticket.ticketId, `legacy-ticket-${index}`),
|
||||||
|
conversationId: stringValue(ticket.conversationId),
|
||||||
|
position: numberValue(ticket.position, index),
|
||||||
|
status: normalizeTicketStatus(ticket.status),
|
||||||
|
source: normalizeSource(ticket.source),
|
||||||
|
requesterLabel: stringValue(ticket.requesterLabel),
|
||||||
|
taskPreview,
|
||||||
|
taskLen: numberValue(ticket.taskLen, taskPreview.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAgent(value: unknown, index: number): AgentWorkState {
|
||||||
|
const agent = isRecord(value) ? value : {};
|
||||||
|
const liveKind: "pty" | "structured" =
|
||||||
|
isRecord(agent.live) && agent.live.kind === "structured" ? "structured" : "pty";
|
||||||
|
const live = isRecord(agent.live)
|
||||||
|
? {
|
||||||
|
nodeId: stringValue(agent.live.nodeId),
|
||||||
|
sessionId: stringValue(agent.live.sessionId),
|
||||||
|
kind: liveKind,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentId: stringValue(agent.agentId, `legacy-agent-${index}`),
|
||||||
|
name: stringValue(agent.name, "Unnamed agent"),
|
||||||
|
profileId: stringValue(agent.profileId, "unknown"),
|
||||||
|
...(live ? { live } : {}),
|
||||||
|
busy: normalizeBusy(agent.busy),
|
||||||
|
tickets: arrayValue(agent.tickets).map(normalizeTicket),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSummaryStatus(value: unknown): ConversationPreviewStatus {
|
||||||
|
if (
|
||||||
|
value === "ready" ||
|
||||||
|
value === "partial" ||
|
||||||
|
value === "unavailable" ||
|
||||||
|
value === "missing"
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTurn(value: unknown, index: number): ConversationTurnWorkPreview {
|
||||||
|
const turn = isRecord(value) ? value : {};
|
||||||
|
const textPreview = stringValue(turn.textPreview);
|
||||||
|
const role =
|
||||||
|
turn.role === "response" || turn.role === "toolActivity"
|
||||||
|
? turn.role
|
||||||
|
: "prompt";
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
source: normalizeSource(turn.source),
|
||||||
|
atMs: numberValue(turn.atMs, index),
|
||||||
|
textPreview,
|
||||||
|
textLen: numberValue(turn.textLen, textPreview.length),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(value: unknown): string | null {
|
||||||
|
return typeof value === "string" ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeConversation(
|
||||||
|
value: unknown,
|
||||||
|
index: number,
|
||||||
|
): ConversationWorkSummary {
|
||||||
|
const summary = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
conversationId: stringValue(summary.conversationId, `legacy-conversation-${index}`),
|
||||||
|
status: normalizeSummaryStatus(summary.status),
|
||||||
|
objectivePreview: nullableString(summary.objectivePreview),
|
||||||
|
summaryPreview: nullableString(summary.summaryPreview),
|
||||||
|
summaryLen: numberValue(summary.summaryLen, 0),
|
||||||
|
upTo: nullableString(summary.upTo),
|
||||||
|
recentTurns: arrayValue(summary.recentTurns).map(normalizeTurn),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProjectWorkState(value: unknown): ProjectWorkState {
|
||||||
|
const state = isRecord(value) ? value : {};
|
||||||
|
return {
|
||||||
|
agents: arrayValue(state.agents).map(normalizeAgent),
|
||||||
|
conversations: arrayValue(state.conversations).map(normalizeConversation),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
* and idle/busy state from the backend read-model, plus the current input queue.
|
* and idle/busy state from the backend read-model, plus the current input queue.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { Component, type ReactNode, useState } from "react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentTicketState,
|
AgentTicketState,
|
||||||
@ -23,6 +23,58 @@ export interface ProjectWorkStatePanelProps {
|
|||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WorkStatePanelErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkStatePanelErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
retryKey: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorkStatePanelErrorBoundary extends Component<
|
||||||
|
WorkStatePanelErrorBoundaryProps,
|
||||||
|
WorkStatePanelErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: WorkStatePanelErrorBoundaryState = { error: null, retryKey: 0 };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): Partial<WorkStatePanelErrorBoundaryState> {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
private retry = () => {
|
||||||
|
this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.error) {
|
||||||
|
return (
|
||||||
|
<Panel title="Work">
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||||
|
>
|
||||||
|
<p className="font-medium">Work panel failed to render.</p>
|
||||||
|
<p className="mt-1 text-xs text-danger/80">
|
||||||
|
{this.state.error.message}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={this.retry}
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div key={this.state.retryKey}>{this.props.children}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function shortTicket(ticket: string): string {
|
function shortTicket(ticket: string): string {
|
||||||
return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
|
return ticket.length <= 8 ? ticket : ticket.slice(0, 8);
|
||||||
}
|
}
|
||||||
@ -55,7 +107,7 @@ function summaryLabel(status: ConversationPreviewStatus): string {
|
|||||||
function summaryText(summary: ConversationWorkSummary): string | null {
|
function summaryText(summary: ConversationWorkSummary): string | null {
|
||||||
if (summary.objectivePreview) return `Goal: ${summary.objectivePreview}`;
|
if (summary.objectivePreview) return `Goal: ${summary.objectivePreview}`;
|
||||||
if (summary.summaryPreview) return summary.summaryPreview;
|
if (summary.summaryPreview) return summary.summaryPreview;
|
||||||
const lastTurn = summary.recentTurns.at(-1);
|
const lastTurn = (summary.recentTurns ?? []).at(-1);
|
||||||
return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
|
return lastTurn ? `Last: ${lastTurn.textPreview}` : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -140,9 +192,9 @@ function ConversationSummaryLine({
|
|||||||
{summary.summaryPreview}
|
{summary.summaryPreview}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{summary.recentTurns.length > 0 && (
|
{(summary.recentTurns ?? []).length > 0 && (
|
||||||
<ul className="space-y-0.5">
|
<ul className="space-y-0.5">
|
||||||
{summary.recentTurns.map((turn) => (
|
{(summary.recentTurns ?? []).map((turn) => (
|
||||||
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
|
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
|
||||||
<span className="font-medium text-content">{turn.role}:</span>{" "}
|
<span className="font-medium text-content">{turn.role}:</span>{" "}
|
||||||
{turn.textPreview}
|
{turn.textPreview}
|
||||||
@ -223,8 +275,9 @@ function AgentRow({
|
|||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
const [actionBusy, setActionBusy] = useState(false);
|
const [actionBusy, setActionBusy] = useState(false);
|
||||||
const live = agent.live !== undefined;
|
const live = agent.live !== undefined;
|
||||||
const busy = agent.busy.state === "busy";
|
const busyState = agent.busy ?? { state: "idle" };
|
||||||
const tickets = [...agent.tickets].sort((a, b) => a.position - b.position);
|
const busy = busyState.state === "busy";
|
||||||
|
const tickets = [...(agent.tickets ?? [])].sort((a, b) => a.position - b.position);
|
||||||
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
|
const visibleNodeIds = new Set(visibleLeaves.map((leaf) => leaf.id));
|
||||||
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
const liveVisible = live && visibleNodeIds.has(agent.live!.nodeId);
|
||||||
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent);
|
||||||
@ -313,13 +366,13 @@ function AgentRow({
|
|||||||
>
|
>
|
||||||
{busy ? "Busy" : "Idle"}
|
{busy ? "Busy" : "Idle"}
|
||||||
</span>
|
</span>
|
||||||
{agent.busy.state === "busy" && (
|
{busyState.state === "busy" && (
|
||||||
<code
|
<code
|
||||||
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
|
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
|
||||||
title={agent.busy.ticket}
|
title={busyState.ticket}
|
||||||
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
|
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
|
||||||
>
|
>
|
||||||
{shortTicket(agent.busy.ticket)}
|
{shortTicket(busyState.ticket)}
|
||||||
</code>
|
</code>
|
||||||
)}
|
)}
|
||||||
{agent.live && liveVisible && (
|
{agent.live && liveVisible && (
|
||||||
@ -386,7 +439,7 @@ function AgentRow({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) {
|
function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps) {
|
||||||
const vm = useProjectWorkState(projectId);
|
const vm = useProjectWorkState(projectId);
|
||||||
const layoutVm = useLayout(projectId);
|
const layoutVm = useLayout(projectId);
|
||||||
const agents = vm.state?.agents ?? [];
|
const agents = vm.state?.agents ?? [];
|
||||||
@ -445,3 +498,11 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps)
|
|||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) {
|
||||||
|
return (
|
||||||
|
<WorkStatePanelErrorBoundary>
|
||||||
|
<ProjectWorkStatePanelContent {...props} />
|
||||||
|
</WorkStatePanelErrorBoundary>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import {
|
|||||||
} from "@/adapters/mock";
|
} from "@/adapters/mock";
|
||||||
import { leaves } from "@/features/layout/layout";
|
import { leaves } from "@/features/layout/layout";
|
||||||
import type { Gateways } from "@/ports";
|
import type { Gateways } from "@/ports";
|
||||||
import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel";
|
import {
|
||||||
|
ProjectWorkStatePanel,
|
||||||
|
WorkStatePanelErrorBoundary,
|
||||||
|
} from "./ProjectWorkStatePanel";
|
||||||
|
|
||||||
const PROJECT_ID = "project-work-state-test";
|
const PROJECT_ID = "project-work-state-test";
|
||||||
|
|
||||||
@ -173,7 +176,7 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("normalizes mock work state without tickets", async () => {
|
it("renders a legacy agent without tickets", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
agents: [
|
agents: [
|
||||||
@ -186,20 +189,42 @@ describe("ProjectWorkStatePanel", () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
renderPanel(workState);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Legacy")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Offline")).toBeTruthy();
|
||||||
|
expect(screen.getByText("Idle")).toBeTruthy();
|
||||||
|
expect(screen.queryByLabelText("Legacy tickets")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes missing top-level work state arrays", async () => {
|
||||||
|
const workState = new MockWorkStateGateway();
|
||||||
|
workState._setProjectWorkState(PROJECT_ID, {});
|
||||||
|
|
||||||
await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({
|
await expect(workState.getProjectWorkState(PROJECT_ID)).resolves.toEqual({
|
||||||
agents: [
|
agents: [],
|
||||||
{
|
|
||||||
agentId: "agent-6",
|
|
||||||
name: "Legacy",
|
|
||||||
profileId: "gemini",
|
|
||||||
busy: { state: "idle" },
|
|
||||||
tickets: [],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
conversations: [],
|
conversations: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders an error fallback instead of blanking the app when Work throws", () => {
|
||||||
|
function BrokenWorkPanel(): JSX.Element {
|
||||||
|
throw new Error("render exploded");
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkStatePanelErrorBoundary>
|
||||||
|
<BrokenWorkPanel />
|
||||||
|
</WorkStatePanelErrorBoundary>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain(
|
||||||
|
"Work panel failed to render.",
|
||||||
|
);
|
||||||
|
expect(screen.getByText("render exploded")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders conversation objective and summary joined by conversation id", async () => {
|
it("renders conversation objective and summary joined by conversation id", async () => {
|
||||||
const workState = new MockWorkStateGateway();
|
const workState = new MockWorkStateGateway();
|
||||||
workState._setProjectWorkState(PROJECT_ID, {
|
workState._setProjectWorkState(PROJECT_ID, {
|
||||||
|
|||||||
Reference in New Issue
Block a user