From 3e1e5536ccb1334c4e01a60f82e1e9301e5e3cbf Mon Sep 17 00:00:00 2001 From: Blomios Date: Sun, 21 Jun 2026 09:27:52 +0200 Subject: [PATCH] =?UTF-8?q?fix(workstate):=20normalisation=20du=20work-sta?= =?UTF-8?q?te=20pour=20=C3=A9viter=20l'onglet=20Work=20vide?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/adapters/mock/index.ts | 25 +-- frontend/src/adapters/workState.ts | 6 +- .../src/adapters/workStateNormalization.ts | 145 ++++++++++++++++++ .../workstate/ProjectWorkStatePanel.tsx | 83 ++++++++-- .../src/features/workstate/workstate.test.tsx | 47 ++++-- 5 files changed, 259 insertions(+), 47 deletions(-) create mode 100644 frontend/src/adapters/workStateNormalization.ts diff --git a/frontend/src/adapters/mock/index.ts b/frontend/src/adapters/mock/index.ts index cea2413..6dc3d8b 100644 --- a/frontend/src/adapters/mock/index.ts +++ b/frontend/src/adapters/mock/index.ts @@ -68,6 +68,7 @@ import type { TerminalHandle, WorkStateGateway, } from "@/ports"; +import { normalizeProjectWorkState } from "../workStateNormalization"; import { applyOperation, singleLeafTree } from "@/features/layout/layout"; export class MockSystemGateway implements SystemGateway { @@ -1681,10 +1682,7 @@ export class MockWorkStateGateway implements WorkStateGateway { private states = new Map(); /** Seeds the read-model returned for a project (deterministic tests/dev). */ - _setProjectWorkState( - projectId: string, - state: ProjectWorkState | LegacyProjectWorkState, - ): void { + _setProjectWorkState(projectId: string, state: unknown): void { this.states.set(projectId, normalizeProjectWorkState(state)); } @@ -1695,25 +1693,6 @@ export class MockWorkStateGateway implements WorkStateGateway { } } -type LegacyProjectWorkState = { - agents: Array & { - 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( project?: PermissionSet["fallback"], agent?: PermissionSet["fallback"], diff --git a/frontend/src/adapters/workState.ts b/frontend/src/adapters/workState.ts index cde9d38..7a5b0e1 100644 --- a/frontend/src/adapters/workState.ts +++ b/frontend/src/adapters/workState.ts @@ -9,9 +9,11 @@ import { invoke } from "@tauri-apps/api/core"; import type { ProjectWorkState } from "@/domain"; import type { WorkStateGateway } from "@/ports"; +import { normalizeProjectWorkState } from "./workStateNormalization"; export class TauriWorkStateGateway implements WorkStateGateway { - getProjectWorkState(projectId: string): Promise { - return invoke("get_project_work_state", { projectId }); + async getProjectWorkState(projectId: string): Promise { + const state = await invoke("get_project_work_state", { projectId }); + return normalizeProjectWorkState(state); } } diff --git a/frontend/src/adapters/workStateNormalization.ts b/frontend/src/adapters/workStateNormalization.ts new file mode 100644 index 0000000..1499e0b --- /dev/null +++ b/frontend/src/adapters/workStateNormalization.ts @@ -0,0 +1,145 @@ +import type { + AgentTicketState, + AgentWorkState, + ConversationPreviewStatus, + ConversationTurnWorkPreview, + ConversationWorkSummary, + ProjectWorkState, + TicketWorkSource, + TicketWorkStatus, + WorkBusyState, +} from "@/domain"; + +type RecordLike = Record; + +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), + }; +} diff --git a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx index 63a48a9..48c93e2 100644 --- a/frontend/src/features/workstate/ProjectWorkStatePanel.tsx +++ b/frontend/src/features/workstate/ProjectWorkStatePanel.tsx @@ -3,7 +3,7 @@ * 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 { AgentTicketState, @@ -23,6 +23,58 @@ export interface ProjectWorkStatePanelProps { 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 { + return { error }; + } + + private retry = () => { + this.setState(({ retryKey }) => ({ error: null, retryKey: retryKey + 1 })); + }; + + render() { + if (this.state.error) { + return ( + +
+

Work panel failed to render.

+

+ {this.state.error.message} +

+ +
+
+ ); + } + + return
{this.props.children}
; + } +} + function shortTicket(ticket: string): string { return ticket.length <= 8 ? ticket : ticket.slice(0, 8); } @@ -55,7 +107,7 @@ function summaryLabel(status: ConversationPreviewStatus): string { function summaryText(summary: ConversationWorkSummary): string | null { if (summary.objectivePreview) return `Goal: ${summary.objectivePreview}`; if (summary.summaryPreview) return summary.summaryPreview; - const lastTurn = summary.recentTurns.at(-1); + const lastTurn = (summary.recentTurns ?? []).at(-1); return lastTurn ? `Last: ${lastTurn.textPreview}` : null; } @@ -140,9 +192,9 @@ function ConversationSummaryLine({ {summary.summaryPreview}

)} - {summary.recentTurns.length > 0 && ( + {(summary.recentTurns ?? []).length > 0 && (
    - {summary.recentTurns.map((turn) => ( + {(summary.recentTurns ?? []).map((turn) => (
  • {turn.role}:{" "} {turn.textPreview} @@ -223,8 +275,9 @@ function AgentRow({ const [message, setMessage] = useState(null); const [actionBusy, setActionBusy] = useState(false); const live = agent.live !== undefined; - const busy = agent.busy.state === "busy"; - const tickets = [...agent.tickets].sort((a, b) => a.position - b.position); + const busyState = agent.busy ?? { state: "idle" }; + 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 liveVisible = live && visibleNodeIds.has(agent.live!.nodeId); const attachTarget = visibleLeaves.find((leaf) => !leaf.session && !leaf.agent); @@ -313,13 +366,13 @@ function AgentRow({ > {busy ? "Busy" : "Idle"} - {agent.busy.state === "busy" && ( + {busyState.state === "busy" && ( - {shortTicket(agent.busy.ticket)} + {shortTicket(busyState.ticket)} )} {agent.live && liveVisible && ( @@ -386,7 +439,7 @@ function AgentRow({ ); } -export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) { +function ProjectWorkStatePanelContent({ projectId }: ProjectWorkStatePanelProps) { const vm = useProjectWorkState(projectId); const layoutVm = useLayout(projectId); const agents = vm.state?.agents ?? []; @@ -445,3 +498,11 @@ export function ProjectWorkStatePanel({ projectId }: ProjectWorkStatePanelProps) ); } + +export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) { + return ( + + + + ); +} diff --git a/frontend/src/features/workstate/workstate.test.tsx b/frontend/src/features/workstate/workstate.test.tsx index f696d1a..44bb65a 100644 --- a/frontend/src/features/workstate/workstate.test.tsx +++ b/frontend/src/features/workstate/workstate.test.tsx @@ -10,7 +10,10 @@ import { } from "@/adapters/mock"; import { leaves } from "@/features/layout/layout"; import type { Gateways } from "@/ports"; -import { ProjectWorkStatePanel } from "./ProjectWorkStatePanel"; +import { + ProjectWorkStatePanel, + WorkStatePanelErrorBoundary, +} from "./ProjectWorkStatePanel"; 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(); workState._setProjectWorkState(PROJECT_ID, { 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({ - agents: [ - { - agentId: "agent-6", - name: "Legacy", - profileId: "gemini", - busy: { state: "idle" }, - tickets: [], - }, - ], + agents: [], conversations: [], }); }); + it("renders an error fallback instead of blanking the app when Work throws", () => { + function BrokenWorkPanel(): JSX.Element { + throw new Error("render exploded"); + } + + render( + + + , + ); + + 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 () => { const workState = new MockWorkStateGateway(); workState._setProjectWorkState(PROJECT_ID, {