diff --git a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx index f8aaf2182e..4f9314c623 100644 --- a/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx +++ b/apps/web/app/office/agents/[id]/runs/components/tasks-touched.tsx @@ -7,7 +7,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { getTask } from "@/lib/api/domains/office-extended-api"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import { StatusIcon } from "@/app/office/tasks/status-icon"; -import { normalizeTaskStatus } from "@/app/office/tasks/normalize-status"; +import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize"; import { PRIORITY_LABEL_KEYS, STATUS_LABEL_KEYS } from "@/app/office/lib/label-keys"; import { useTranslation } from "react-i18next"; diff --git a/apps/web/app/office/tasks/[id]/page.test.ts b/apps/web/app/office/tasks/[id]/page.test.ts new file mode 100644 index 0000000000..dfbda38c7f --- /dev/null +++ b/apps/web/app/office/tasks/[id]/page.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { mapOfficeTaskToTask } from "./page"; +import type { OfficeTask } from "@/lib/state/slices/office/types"; + +const baseRaw: OfficeTask = { + id: "task-1", + workspaceId: "workspace-1", + identifier: "E2E-1", + title: "Mapper regression task", + status: "todo", + priority: "medium", + createdAt: "2026-05-01T10:00:00Z", + updatedAt: "2026-05-01T10:00:00Z", +}; + +describe("mapOfficeTaskToTask", () => { + // Regression: the detail page seeds its Task from the store-held OfficeTask + // before the API GET resolves (page.tsx's useIssueData). Dropping rawStatus + // here silently breaks ExecutionIndicator's Live/Ready sub-state distinction + // for however long the seeded task is shown — see task-advanced-mode.test.tsx + // and OfficeSimplePane.test.tsx for the consumer-level regression. + it("carries rawStatus through when the store holds one", () => { + const mapped = mapOfficeTaskToTask({ ...baseRaw, status: "todo", rawStatus: "SCHEDULING" }); + + expect(mapped.rawStatus).toBe("SCHEDULING"); + }); + + it("falls back to the canonical status when rawStatus is absent", () => { + const mapped = mapOfficeTaskToTask({ ...baseRaw, status: "in_progress" }); + + expect(mapped.rawStatus).toBe("in_progress"); + }); +}); diff --git a/apps/web/app/office/tasks/[id]/page.tsx b/apps/web/app/office/tasks/[id]/page.tsx index e95acad65b..ad4e63010c 100644 --- a/apps/web/app/office/tasks/[id]/page.tsx +++ b/apps/web/app/office/tasks/[id]/page.tsx @@ -58,7 +58,7 @@ function mapDecisionDTO(d: TaskDecisionDTO): TaskDecision { }; } -function mapOfficeTaskToTask(raw: OfficeTask): Task { +export function mapOfficeTaskToTask(raw: OfficeTask): Task { // The server DTO includes reviewers/approvers/decisions even though the // strongly-typed OfficeTask only declares the cross-cutting fields. We // read those extra props off the raw object. @@ -75,6 +75,7 @@ function mapOfficeTaskToTask(raw: OfficeTask): Task { title: raw.title, description: raw.description, status: raw.status as Task["status"], + rawStatus: raw.rawStatus ?? raw.status, priority: (raw.priority || "medium") as Task["priority"], labels: (raw.labels ?? []).map((l) => typeof l === "string" ? { name: l, color: "#6b7280" } : l, diff --git a/apps/web/app/office/tasks/[id]/task-advanced-mode.test.tsx b/apps/web/app/office/tasks/[id]/task-advanced-mode.test.tsx new file mode 100644 index 0000000000..f89120afce --- /dev/null +++ b/apps/web/app/office/tasks/[id]/task-advanced-mode.test.tsx @@ -0,0 +1,100 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import type { Task } from "./types"; + +vi.mock("@/lib/routing/client-dynamic", () => ({ + default: () => + function MockOfficeDockviewLayout() { + return null; + }, +})); + +vi.mock("@/components/routing/app-link", () => ({ + default: ({ children, href, ...props }: { children: React.ReactNode; href: string }) => ( + + {children} + + ), +})); + +vi.mock("./status-icon", () => ({ + StatusIcon: () => null, +})); + +vi.mock("./use-advanced-session", () => ({ + useAdvancedSession: () => ({ sessionId: null, isSessionEnded: false }), +})); + +vi.mock("../../components/execution-indicator", () => ({ + ExecutionIndicator: ({ status }: { status: string }) => ( +
{status}
+ ), +})); + +vi.mock("../../components/office-topbar-portal", () => ({ + OfficeTopbarPortal: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +vi.mock("@kandev/ui/label", () => ({ + Label: ({ children }: { children: React.ReactNode }) => , +})); + +vi.mock("@kandev/ui/switch", () => ({ + Switch: () => null, +})); + +import { TaskAdvancedMode } from "./task-advanced-mode"; + +const baseTask: Task = { + id: "task-1", + workspaceId: "workspace-1", + identifier: "E2E-1", + title: "Advanced mode task", + status: "todo", + priority: "medium", + labels: [], + blockedBy: [], + blocking: [], + children: [], + reviewers: [], + approvers: [], + decisions: [], + createdBy: "", + createdAt: "2026-05-01T10:00:00Z", + updatedAt: "2026-05-01T10:00:00Z", +}; + +afterEach(() => cleanup()); + +describe("TaskAdvancedMode ExecutionIndicator wiring", () => { + // Regression: the detail page's store-seeded task must feed ExecutionIndicator + // the raw backend status, not the normalized canonical one, so it still tells + // SCHEDULING (live) apart from WAITING_FOR_INPUT (not live) — the same + // distinction task-row.tsx already preserves for the board/list views. + it("prefers rawStatus over status so a SCHEDULING task still shows Live", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("execution-indicator").textContent).toBe("SCHEDULING"); + }); + + it("prefers rawStatus over status so a WAITING_FOR_INPUT task does not falsely show Live", () => { + render( + {}} + />, + ); + expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT"); + }); + + it("falls back to status when rawStatus is absent", () => { + render( + {}} />, + ); + expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress"); + }); +}); diff --git a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx index 8e2f454259..a0e92ea60e 100644 --- a/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx +++ b/apps/web/app/office/tasks/[id]/task-advanced-mode.tsx @@ -33,7 +33,7 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps {task.identifier} {task.title} - +