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:
2026-06-21 09:27:52 +02:00
parent fb501d10e5
commit 3e1e5536cc
5 changed files with 259 additions and 47 deletions

View File

@ -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<string, ProjectWorkState>();
/** 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<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(
project?: PermissionSet["fallback"],
agent?: PermissionSet["fallback"],

View File

@ -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<ProjectWorkState> {
return invoke<ProjectWorkState>("get_project_work_state", { projectId });
async getProjectWorkState(projectId: string): Promise<ProjectWorkState> {
const state = await invoke<unknown>("get_project_work_state", { projectId });
return normalizeProjectWorkState(state);
}
}

View 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),
};
}