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, {