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:
@ -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<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 {
|
||||
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}
|
||||
</p>
|
||||
)}
|
||||
{summary.recentTurns.length > 0 && (
|
||||
{(summary.recentTurns ?? []).length > 0 && (
|
||||
<ul className="space-y-0.5">
|
||||
{summary.recentTurns.map((turn) => (
|
||||
{(summary.recentTurns ?? []).map((turn) => (
|
||||
<li key={`${turn.atMs}-${turn.role}-${turn.textPreview}`}>
|
||||
<span className="font-medium text-content">{turn.role}:</span>{" "}
|
||||
{turn.textPreview}
|
||||
@ -223,8 +275,9 @@ function AgentRow({
|
||||
const [message, setMessage] = useState<string | null>(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"}
|
||||
</span>
|
||||
{agent.busy.state === "busy" && (
|
||||
{busyState.state === "busy" && (
|
||||
<code
|
||||
aria-label={`busy ticket ${shortTicket(agent.busy.ticket)}`}
|
||||
title={agent.busy.ticket}
|
||||
aria-label={`busy ticket ${shortTicket(busyState.ticket)}`}
|
||||
title={busyState.ticket}
|
||||
className="rounded bg-raised px-1.5 py-0.5 text-xs text-muted"
|
||||
>
|
||||
{shortTicket(agent.busy.ticket)}
|
||||
{shortTicket(busyState.ticket)}
|
||||
</code>
|
||||
)}
|
||||
{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)
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProjectWorkStatePanel(props: ProjectWorkStatePanelProps) {
|
||||
return (
|
||||
<WorkStatePanelErrorBoundary>
|
||||
<ProjectWorkStatePanelContent {...props} />
|
||||
</WorkStatePanelErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
<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 () => {
|
||||
const workState = new MockWorkStateGateway();
|
||||
workState._setProjectWorkState(PROJECT_ID, {
|
||||
|
||||
Reference in New Issue
Block a user