fix(web): stop new Office tasks from disappearing off the board (#2829)
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }) => (
|
||||
<a href={href} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
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 }) => (
|
||||
<div data-testid="execution-indicator">{status}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../../components/office-topbar-portal", () => ({
|
||||
OfficeTopbarPortal: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@kandev/ui/label", () => ({
|
||||
Label: ({ children }: { children: React.ReactNode }) => <label>{children}</label>,
|
||||
}));
|
||||
|
||||
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(
|
||||
<TaskAdvancedMode
|
||||
task={{ ...baseTask, status: "todo", rawStatus: "SCHEDULING" } as Task}
|
||||
onToggleSimple={() => {}}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<TaskAdvancedMode
|
||||
task={{ ...baseTask, status: "in_progress", rawStatus: "WAITING_FOR_INPUT" } as Task}
|
||||
onToggleSimple={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT");
|
||||
});
|
||||
|
||||
it("falls back to status when rawStatus is absent", () => {
|
||||
render(
|
||||
<TaskAdvancedMode task={{ ...baseTask, status: "in_progress" }} onToggleSimple={() => {}} />,
|
||||
);
|
||||
expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ export function TaskAdvancedMode({ task, onToggleSimple }: TaskAdvancedModeProps
|
||||
<span className="text-xs font-mono text-muted-foreground">{task.identifier}</span>
|
||||
<span className="text-sm font-medium truncate">{task.title}</span>
|
||||
<span className="flex-1" />
|
||||
<ExecutionIndicator status={task.status} />
|
||||
<ExecutionIndicator status={task.rawStatus ?? task.status} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor="advanced-toggle" className="text-xs text-muted-foreground cursor-pointer">
|
||||
{t("office:advanced")}
|
||||
|
||||
@@ -135,6 +135,10 @@ export type Task = {
|
||||
title: string;
|
||||
description?: string;
|
||||
status: TaskStatus;
|
||||
// Pre-normalization backend value (e.g. "SCHEDULING", "WAITING_FOR_INPUT"),
|
||||
// preserved so ExecutionIndicator can distinguish sub-states that `status`
|
||||
// collapses to the same canonical bucket. See OfficeTask.rawStatus.
|
||||
rawStatus?: string;
|
||||
priority: TaskPriority;
|
||||
labels: TaskLabelLocal[];
|
||||
assigneeAgentProfileId?: string;
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { canonicalStatusesToBackend, normalizeTaskStatus } from "./normalize-status";
|
||||
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
|
||||
describe("normalizeTaskStatus", () => {
|
||||
it("returns 'backlog' for null/undefined/empty input", () => {
|
||||
expect(normalizeTaskStatus(undefined)).toBe("backlog");
|
||||
expect(normalizeTaskStatus(null)).toBe("backlog");
|
||||
expect(normalizeTaskStatus("")).toBe("backlog");
|
||||
});
|
||||
|
||||
it("maps backend uppercase enums to canonical office statuses", () => {
|
||||
expect(normalizeTaskStatus("TODO")).toBe("todo");
|
||||
expect(normalizeTaskStatus("CREATED")).toBe("todo");
|
||||
expect(normalizeTaskStatus("SCHEDULING")).toBe("todo");
|
||||
expect(normalizeTaskStatus("IN_PROGRESS")).toBe("in_progress");
|
||||
expect(normalizeTaskStatus("WAITING_FOR_INPUT")).toBe("in_progress");
|
||||
expect(normalizeTaskStatus("REVIEW")).toBe("in_review");
|
||||
expect(normalizeTaskStatus("IN_REVIEW")).toBe("in_review");
|
||||
expect(normalizeTaskStatus("BLOCKED")).toBe("blocked");
|
||||
expect(normalizeTaskStatus("FAILED")).toBe("blocked");
|
||||
expect(normalizeTaskStatus("COMPLETED")).toBe("done");
|
||||
expect(normalizeTaskStatus("DONE")).toBe("done");
|
||||
expect(normalizeTaskStatus("CANCELLED")).toBe("cancelled");
|
||||
expect(normalizeTaskStatus("CANCELED")).toBe("cancelled");
|
||||
expect(normalizeTaskStatus("BACKLOG")).toBe("backlog");
|
||||
});
|
||||
|
||||
it("accepts already-canonical lowercase office statuses", () => {
|
||||
const canonical: OfficeTaskStatus[] = [
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"blocked",
|
||||
"done",
|
||||
"cancelled",
|
||||
"backlog",
|
||||
];
|
||||
for (const s of canonical) {
|
||||
expect(normalizeTaskStatus(s)).toBe(s);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to 'backlog' for unknown statuses", () => {
|
||||
expect(normalizeTaskStatus("UNKNOWN_STATE")).toBe("backlog");
|
||||
expect(normalizeTaskStatus("garbage")).toBe("backlog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalStatusesToBackend", () => {
|
||||
it("returns an empty array for empty input", () => {
|
||||
expect(canonicalStatusesToBackend([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("expands each canonical status to all backend enum aliases", () => {
|
||||
expect(canonicalStatusesToBackend(["todo"])).toEqual(["TODO", "CREATED", "SCHEDULING"]);
|
||||
expect(canonicalStatusesToBackend(["in_progress"])).toEqual([
|
||||
"IN_PROGRESS",
|
||||
"WAITING_FOR_INPUT",
|
||||
]);
|
||||
expect(canonicalStatusesToBackend(["in_review"])).toEqual(["REVIEW", "IN_REVIEW"]);
|
||||
expect(canonicalStatusesToBackend(["blocked"])).toEqual(["BLOCKED", "FAILED"]);
|
||||
expect(canonicalStatusesToBackend(["done"])).toEqual(["COMPLETED", "DONE"]);
|
||||
expect(canonicalStatusesToBackend(["cancelled"])).toEqual(["CANCELLED", "CANCELED"]);
|
||||
expect(canonicalStatusesToBackend(["backlog"])).toEqual(["BACKLOG"]);
|
||||
});
|
||||
|
||||
it("concatenates expansions for multiple canonical statuses", () => {
|
||||
expect(canonicalStatusesToBackend(["todo", "done"])).toEqual([
|
||||
"TODO",
|
||||
"CREATED",
|
||||
"SCHEDULING",
|
||||
"COMPLETED",
|
||||
"DONE",
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips: every backend value produced normalises back to the source canonical", () => {
|
||||
const all: OfficeTaskStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"blocked",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
for (const canonical of all) {
|
||||
const backendValues = canonicalStatusesToBackend([canonical]);
|
||||
expect(backendValues.length).toBeGreaterThan(0);
|
||||
for (const v of backendValues) {
|
||||
expect(normalizeTaskStatus(v)).toBe(canonical);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
import { normalizeTaskStatus } from "./normalize-status";
|
||||
import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
|
||||
import { STATUS_LABEL_KEYS } from "../lib/label-keys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupTasksByStatus } from "./task-board";
|
||||
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
|
||||
const COLUMN_STATUSES: OfficeTaskStatus[] = [
|
||||
"backlog",
|
||||
"todo",
|
||||
"in_progress",
|
||||
"in_review",
|
||||
"blocked",
|
||||
"done",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
function task(id: string, status: OfficeTaskStatus): OfficeTask {
|
||||
return {
|
||||
id,
|
||||
workspaceId: "workspace-1",
|
||||
identifier: id.toUpperCase(),
|
||||
title: id,
|
||||
status,
|
||||
priority: "none",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
// office.tasks.items is normalized to the canonical OfficeTaskStatus
|
||||
// vocabulary at ingestion (office-slice.ts setTasks/appendTasks/
|
||||
// patchTaskInStore), so groupTasksByStatus only ever sees canonical values —
|
||||
// coverage for raw-backend-status mapping (e.g. "CREATED" -> "todo") lives in
|
||||
// office-tasks.test.ts and normalize-status.test.ts instead.
|
||||
describe("groupTasksByStatus", () => {
|
||||
it("buckets a task into its matching column", () => {
|
||||
const todoTask = task("todo-task", "todo");
|
||||
|
||||
const grouped = groupTasksByStatus([todoTask], COLUMN_STATUSES);
|
||||
|
||||
expect(grouped.get("todo")).toEqual([todoTask]);
|
||||
});
|
||||
|
||||
it("buckets an in_progress task into the in_progress column", () => {
|
||||
const inProgress = task("in-progress-task", "in_progress");
|
||||
|
||||
const grouped = groupTasksByStatus([inProgress], COLUMN_STATUSES);
|
||||
|
||||
expect(grouped.get("in_progress")).toEqual([inProgress]);
|
||||
});
|
||||
|
||||
it("buckets a blocked task into the blocked column", () => {
|
||||
const blocked = task("blocked-task", "blocked");
|
||||
|
||||
const grouped = groupTasksByStatus([blocked], COLUMN_STATUSES);
|
||||
|
||||
expect(grouped.get("blocked")).toEqual([blocked]);
|
||||
});
|
||||
|
||||
it("drops a task whose status has no matching column", () => {
|
||||
const garbage = task("garbage-task", "SOME_UNKNOWN_STATE" as OfficeTaskStatus);
|
||||
|
||||
const grouped = groupTasksByStatus([garbage], COLUMN_STATUSES);
|
||||
|
||||
const totalBucketed = [...grouped.values()].reduce((sum, list) => sum + list.length, 0);
|
||||
expect(totalBucketed).toBe(0);
|
||||
});
|
||||
|
||||
it("never drops a task with a canonical status: every input task lands in exactly one bucket", () => {
|
||||
const tasks: OfficeTask[] = [
|
||||
task("a", "backlog"),
|
||||
task("b", "todo"),
|
||||
task("c", "in_progress"),
|
||||
task("d", "in_review"),
|
||||
task("e", "blocked"),
|
||||
task("f", "done"),
|
||||
task("g", "cancelled"),
|
||||
];
|
||||
|
||||
const grouped = groupTasksByStatus(tasks, COLUMN_STATUSES);
|
||||
|
||||
const totalBucketed = [...grouped.values()].reduce((sum, list) => sum + list.length, 0);
|
||||
expect(totalBucketed).toBe(tasks.length);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,24 @@ function BoardColumn({
|
||||
);
|
||||
}
|
||||
|
||||
// Board columns are keyed on the canonical lowercase OfficeTaskStatus
|
||||
// vocabulary. `task.status` is normalized to that vocabulary at ingestion
|
||||
// (office-slice.ts), so no per-consumer normalization is needed here.
|
||||
export function groupTasksByStatus(
|
||||
tasks: OfficeTask[],
|
||||
columnStatuses: OfficeTaskStatus[],
|
||||
): Map<OfficeTaskStatus, OfficeTask[]> {
|
||||
const grouped = new Map<OfficeTaskStatus, OfficeTask[]>();
|
||||
for (const status of columnStatuses) {
|
||||
grouped.set(status, []);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const list = grouped.get(task.status);
|
||||
if (list) list.push(task);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function TaskBoard({ tasks }: TaskBoardProps) {
|
||||
const { t } = useTranslation();
|
||||
const meta = useAppStore((s) => s.office.meta);
|
||||
@@ -78,14 +96,10 @@ export function TaskBoard({ tasks }: TaskBoardProps) {
|
||||
? meta.statuses.map((s) => ({ status: s.id as OfficeTaskStatus, label: s.label }))
|
||||
: FALLBACK_COLUMNS.map((c) => ({ status: c.status, label: t(c.labelKey) }));
|
||||
|
||||
const grouped = new Map<OfficeTaskStatus, OfficeTask[]>();
|
||||
for (const col of columns) {
|
||||
grouped.set(col.status, []);
|
||||
}
|
||||
for (const task of tasks) {
|
||||
const list = grouped.get(task.status);
|
||||
if (list) list.push(task);
|
||||
}
|
||||
const grouped = groupTasksByStatus(
|
||||
tasks,
|
||||
columns.map((c) => c.status),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 overflow-x-auto pb-4">
|
||||
|
||||
@@ -101,7 +101,7 @@ export function TaskRow({
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
<ExecutionIndicator status={task.status} />
|
||||
<ExecutionIndicator status={task.rawStatus ?? task.status} />
|
||||
{agentName && (
|
||||
<span className="text-xs text-muted-foreground shrink-0 truncate max-w-[100px]">
|
||||
{agentName}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { toast } from "@/lib/toast/sonner";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { listTasks, type ListTasksParams } from "@/lib/api/domains/office-extended-api";
|
||||
import type { TaskFilterState, TaskSortDir, TaskSortField } from "@/lib/state/slices/office/types";
|
||||
import { canonicalStatusesToBackend } from "./normalize-status";
|
||||
import { canonicalStatusesToBackend } from "@/lib/api/domains/office-task-normalize";
|
||||
// Module-level `t` (resolved at call time, not import time): these strings are
|
||||
// error-only and live inside a fetching effect and its callbacks. Putting the
|
||||
// hook's `t` in those dep arrays would re-issue the task list request on every
|
||||
|
||||
@@ -33,13 +33,18 @@ const PRIORITY_ORDER = {
|
||||
none: 4,
|
||||
};
|
||||
|
||||
function task(id: string, title: string, parentId?: string): OfficeTask {
|
||||
function task(
|
||||
id: string,
|
||||
title: string,
|
||||
parentId?: string,
|
||||
status: OfficeTask["status"] = "todo",
|
||||
): OfficeTask {
|
||||
return {
|
||||
id,
|
||||
workspaceId: "workspace-1",
|
||||
identifier: id.toUpperCase(),
|
||||
title,
|
||||
status: "todo",
|
||||
status,
|
||||
priority: "none",
|
||||
parentId,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
@@ -52,10 +57,11 @@ function buildNodes(
|
||||
expandedIds: Set<string>,
|
||||
sortField: TaskSortField = "title",
|
||||
sortDir: TaskSortDir = "asc",
|
||||
filters: TaskFilterState = FILTERS,
|
||||
): FlatTaskNode[] {
|
||||
return buildTaskTreeNodes({
|
||||
tasks,
|
||||
filters: FILTERS,
|
||||
filters,
|
||||
sortField,
|
||||
sortDir,
|
||||
nestingEnabled: true,
|
||||
@@ -125,4 +131,31 @@ describe("office task tree", () => {
|
||||
[grandchild.id, 1, false],
|
||||
]);
|
||||
});
|
||||
|
||||
// office.tasks.items is normalized to the canonical OfficeTaskStatus
|
||||
// vocabulary at ingestion (office-slice.ts setTasks/appendTasks/
|
||||
// patchTaskInStore), so buildTaskTreeNodes only ever sees canonical
|
||||
// values — coverage for the raw-backend-status case (e.g. "CREATED")
|
||||
// lives in office-tasks.test.ts instead.
|
||||
it("filters to the todo status", () => {
|
||||
const todo = task("todo-task", "Todo task", undefined, "todo");
|
||||
const done = task("done-task", "Done task", undefined, "done");
|
||||
|
||||
const nodes = buildNodes([todo, done], new Set(), "title", "asc", {
|
||||
...FILTERS,
|
||||
statuses: ["todo"],
|
||||
});
|
||||
|
||||
expect(nodes.map((node) => node.task.id)).toEqual([todo.id]);
|
||||
});
|
||||
|
||||
it("sorts by status order", () => {
|
||||
const todo = task("todo-task", "Todo task", undefined, "todo");
|
||||
const inProgress = task("in-progress", "In progress task", undefined, "in_progress");
|
||||
const cancelled = task("cancelled", "Cancelled task", undefined, "cancelled");
|
||||
|
||||
const nodes = buildNodes([cancelled, inProgress, todo], new Set(), "status", "asc");
|
||||
|
||||
expect(nodes.map((node) => node.task.id)).toEqual([todo.id, inProgress.id, cancelled.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,9 @@ vi.mock("@kandev/ui/tooltip", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/app/office/components/execution-indicator", () => ({
|
||||
ExecutionIndicator: () => <div data-testid="execution-indicator" />,
|
||||
ExecutionIndicator: ({ status }: { status: string }) => (
|
||||
<div data-testid="execution-indicator">{status}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./components/topbar-working-indicator", () => ({
|
||||
@@ -176,3 +178,27 @@ describe("OfficeSimplePane comment composer", () => {
|
||||
expect(screen.getByTestId(CHAT_READONLY_TEST_ID).textContent).toBe(CHAT_READONLY);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OfficeSimplePane 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", () => {
|
||||
renderPane({ ...baseTask, status: "todo", rawStatus: "SCHEDULING" }, []);
|
||||
|
||||
expect(screen.getByTestId("execution-indicator").textContent).toBe("SCHEDULING");
|
||||
});
|
||||
|
||||
it("prefers rawStatus over status so a WAITING_FOR_INPUT task does not falsely show Live", () => {
|
||||
renderPane({ ...baseTask, status: "in_progress", rawStatus: "WAITING_FOR_INPUT" }, []);
|
||||
|
||||
expect(screen.getByTestId("execution-indicator").textContent).toBe("WAITING_FOR_INPUT");
|
||||
});
|
||||
|
||||
it("falls back to status when rawStatus is absent", () => {
|
||||
renderPane({ ...baseTask, status: "in_progress" }, []);
|
||||
|
||||
expect(screen.getByTestId("execution-indicator").textContent).toBe("in_progress");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -477,7 +477,7 @@ export function OfficeSimplePane({
|
||||
<TaskBreadcrumb task={task} />
|
||||
<TopbarWorkingIndicator taskId={task.id} />
|
||||
<span className="flex-1" />
|
||||
<ExecutionIndicator status={task.status} />
|
||||
<ExecutionIndicator status={task.rawStatus ?? task.status} />
|
||||
{onToggleAdvanced && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ApiError } from "@/lib/api/client";
|
||||
import { updateTask } from "@/lib/api/domains/office-extended-api";
|
||||
import type { Task, TaskStatus } from "@/app/office/tasks/[id]/types";
|
||||
import { StatusIcon } from "@/app/office/tasks/[id]/status-icon";
|
||||
import { normalizeTaskStatus } from "@/app/office/tasks/normalize-status";
|
||||
import { normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { test, expect } from "../../fixtures/office-fixture";
|
||||
* - Comment input hidden on office tasks
|
||||
* - Topbar disappears on /office/tasks/:id
|
||||
* - Role-aware avatar shown on agent detail page
|
||||
* - Fresh (raw "CREATED"-status) task missing from Board/Todo filter
|
||||
*/
|
||||
|
||||
test.describe("Office regression net", () => {
|
||||
@@ -164,4 +165,52 @@ test.describe("Office regression net", () => {
|
||||
// agent detail page.
|
||||
await expect(testPage.locator('svg[class*="tabler-icon-robot"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("board and list views place a freshly created task under Todo", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
officeSeed,
|
||||
}) => {
|
||||
// A freshly created task's tasks.state DB column defaults to
|
||||
// "CREATED" (buildTask in service_tasks.go) and stays there until
|
||||
// something acts on it — assignment, a session start, or an explicit
|
||||
// status change. dbStateToOfficeStatus only maps 7 known DB states;
|
||||
// "CREATED" falls through its default branch unmapped, so the API
|
||||
// returns the raw string "CREATED" rather than the canonical "todo".
|
||||
// Pre-fix, task-board.tsx and use-tasks-tree.ts compared that raw
|
||||
// value directly against the canonical lowercase vocabulary, so the
|
||||
// task silently vanished from every board column and from the Todo
|
||||
// filter in list view — reported as "task in Todo status not shown
|
||||
// in Board view".
|
||||
const title = "Status Normalization Regression Task";
|
||||
await apiClient.createTask(officeSeed.workspaceId, title, {
|
||||
workflow_id: officeSeed.workflowId,
|
||||
});
|
||||
|
||||
await testPage.goto("/office/tasks");
|
||||
// Sanity: the unfiltered list always shows it — matchesFilters
|
||||
// short-circuits its status check when no status filter is active,
|
||||
// so this alone would pass even pre-fix.
|
||||
await expect(testPage.getByText(title)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// --- Board view: pre-fix groupTasksByStatus keyed the column map on
|
||||
// the raw task.status, so "CREATED" matched no column and the task
|
||||
// disappeared from the board entirely.
|
||||
await testPage.locator("button:has(svg.tabler-icon-columns-3)").click();
|
||||
const todoColumn = testPage
|
||||
.getByText("Todo", { exact: true })
|
||||
.locator("xpath=ancestor::div[contains(@class,'min-w-[240px]')]");
|
||||
await expect(todoColumn.getByText(title)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// --- List view + Todo status filter: the server-side query already
|
||||
// maps canonical "todo" to the backend states {TODO, CREATED,
|
||||
// SCHEDULING} and returns the row correctly, but pre-fix the
|
||||
// client-side re-filter in use-tasks-tree.ts's matchesFilters
|
||||
// compared the raw status again and dropped the task a second time.
|
||||
await testPage.locator("button:has(svg.tabler-icon-list)").click();
|
||||
await testPage.locator("button:has(svg.tabler-icon-filter)").click();
|
||||
await testPage.getByText("Todo", { exact: true }).click();
|
||||
await testPage.keyboard.press("Escape");
|
||||
await expect(testPage.getByText(title)).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { fetchJson, fetchJsonWithRetry, type ApiRequestOptions } from "../client";
|
||||
import type { DashboardData, OfficeTask } from "@/lib/state/slices/office/types";
|
||||
import type { DashboardData } from "@/lib/state/slices/office/types";
|
||||
import { normalizeOfficeTask, type OfficeTaskWire } from "./office-task-normalize";
|
||||
|
||||
const BASE = "/api/v1/office";
|
||||
|
||||
@@ -161,10 +162,10 @@ export type TimelineEvent = {
|
||||
};
|
||||
|
||||
export function getTask(taskId: string, options?: ApiRequestOptions) {
|
||||
return fetchJson<{ task: OfficeTask; timeline?: TimelineEvent[] }>(
|
||||
return fetchJson<{ task: OfficeTaskWire; timeline?: TimelineEvent[] }>(
|
||||
`${BASE}/tasks/${taskId}`,
|
||||
options,
|
||||
);
|
||||
).then((response) => ({ ...response, task: normalizeOfficeTask(response.task) }));
|
||||
}
|
||||
|
||||
// --- Task mutations (PATCH /tasks/:id) ---
|
||||
@@ -378,10 +379,13 @@ export function searchTasks(
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
const params = new URLSearchParams({ q: query, limit: String(limit) });
|
||||
return fetchJson<{ tasks: OfficeTask[] }>(
|
||||
return fetchJson<{ tasks: OfficeTaskWire[] }>(
|
||||
`${BASE}/workspaces/${workspaceId}/tasks/search?${params.toString()}`,
|
||||
options,
|
||||
);
|
||||
).then((response) => ({
|
||||
...response,
|
||||
tasks: response.tasks.map(normalizeOfficeTask),
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Instructions ---
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalStatusesToBackend,
|
||||
normalizeOfficeTask,
|
||||
normalizeTaskStatus,
|
||||
} from "./office-task-normalize";
|
||||
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
|
||||
function makeTask(status: string): OfficeTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
workspaceId: "ws-1",
|
||||
identifier: "OFC-1",
|
||||
title: "Test task",
|
||||
status: status as OfficeTask["status"],
|
||||
priority: "medium",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("normalizeTaskStatus", () => {
|
||||
it("returns backlog for missing input", () => {
|
||||
expect(normalizeTaskStatus(undefined)).toBe("backlog");
|
||||
expect(normalizeTaskStatus(null)).toBe("backlog");
|
||||
expect(normalizeTaskStatus("")).toBe("backlog");
|
||||
});
|
||||
|
||||
it("maps backend aliases to canonical Office statuses", () => {
|
||||
const aliases: Record<string, OfficeTaskStatus> = {
|
||||
TODO: "todo",
|
||||
CREATED: "todo",
|
||||
SCHEDULING: "todo",
|
||||
IN_PROGRESS: "in_progress",
|
||||
WAITING_FOR_INPUT: "in_progress",
|
||||
REVIEW: "in_review",
|
||||
IN_REVIEW: "in_review",
|
||||
BLOCKED: "blocked",
|
||||
FAILED: "blocked",
|
||||
COMPLETED: "done",
|
||||
DONE: "done",
|
||||
CANCELLED: "cancelled",
|
||||
CANCELED: "cancelled",
|
||||
BACKLOG: "backlog",
|
||||
};
|
||||
for (const [raw, canonical] of Object.entries(aliases)) {
|
||||
expect(normalizeTaskStatus(raw)).toBe(canonical);
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to backlog for unknown statuses", () => {
|
||||
expect(normalizeTaskStatus("UNKNOWN_STATE")).toBe("backlog");
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeOfficeTask", () => {
|
||||
it("normalizes a wire task and preserves its raw status", () => {
|
||||
const task = normalizeOfficeTask(makeTask("CREATED"));
|
||||
expect(task.status).toBe("todo");
|
||||
expect(task.rawStatus).toBe("CREATED");
|
||||
});
|
||||
|
||||
it("normalizes nested children", () => {
|
||||
const task = normalizeOfficeTask({
|
||||
...makeTask("TODO"),
|
||||
children: [makeTask("REVIEW")],
|
||||
});
|
||||
expect(task.children?.[0].status).toBe("in_review");
|
||||
expect(task.children?.[0].rawStatus).toBe("REVIEW");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalStatusesToBackend", () => {
|
||||
it("expands canonical statuses to backend aliases", () => {
|
||||
expect(canonicalStatusesToBackend(["todo", "done"])).toEqual([
|
||||
"TODO",
|
||||
"CREATED",
|
||||
"SCHEDULING",
|
||||
"COMPLETED",
|
||||
"DONE",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+23
-14
@@ -1,14 +1,15 @@
|
||||
import type { OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
import type { OfficeTask, OfficeTaskStatus } from "@/lib/state/slices/office/types";
|
||||
|
||||
/** The task shape received before the Office status contract is applied. */
|
||||
export type OfficeTaskWire = Omit<OfficeTask, "status" | "rawStatus" | "children"> & {
|
||||
status: string;
|
||||
rawStatus?: string | null;
|
||||
children?: OfficeTaskWire[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalises a task status string from any source (backend uppercase enum
|
||||
* like "REVIEW", lowercase office canonical "in_review", or local
|
||||
* detail-page TaskStatus) into the canonical OfficeTaskStatus union.
|
||||
*
|
||||
* Backend `task.state` uses the core task enum (TODO, REVIEW, IN_PROGRESS,
|
||||
* COMPLETED, …) while office's display layer wants the lowercase
|
||||
* Linear-style names (todo, in_review, done, …). This function bridges
|
||||
* them so every status display can look itself up in a single map.
|
||||
* Normalises a task status from any backend or local source into the
|
||||
* canonical OfficeTaskStatus union.
|
||||
*/
|
||||
const STATUS_MAP: Record<string, OfficeTaskStatus> = {
|
||||
todo: "todo",
|
||||
@@ -32,10 +33,18 @@ export function normalizeTaskStatus(status: string | undefined | null): OfficeTa
|
||||
return STATUS_MAP[status.toLowerCase()] ?? "backlog";
|
||||
}
|
||||
|
||||
// Inverse of STATUS_MAP — maps the canonical office status to the set of
|
||||
// backend `task.state` enum values that normalise to it. Used when sending
|
||||
// status filters to the backend's filtered-task-list endpoint, which
|
||||
// matches against raw task.state strings via SQL `IN (...)`.
|
||||
/** Converts a wire task into the canonical Office store shape. */
|
||||
export function normalizeOfficeTask(task: OfficeTaskWire): OfficeTask {
|
||||
const rawStatus = task.rawStatus ?? task.status;
|
||||
return {
|
||||
...task,
|
||||
status: normalizeTaskStatus(rawStatus),
|
||||
rawStatus,
|
||||
children: task.children?.map(normalizeOfficeTask),
|
||||
};
|
||||
}
|
||||
|
||||
// Inverse of STATUS_MAP. It expands canonical values for backend SQL filters.
|
||||
const CANONICAL_TO_BACKEND: Record<OfficeTaskStatus, string[]> = {
|
||||
backlog: ["BACKLOG"],
|
||||
todo: ["TODO", "CREATED", "SCHEDULING"],
|
||||
@@ -48,6 +57,6 @@ const CANONICAL_TO_BACKEND: Record<OfficeTaskStatus, string[]> = {
|
||||
|
||||
export function canonicalStatusesToBackend(statuses: OfficeTaskStatus[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const s of statuses) out.push(...(CANONICAL_TO_BACKEND[s] ?? []));
|
||||
for (const status of statuses) out.push(...(CANONICAL_TO_BACKEND[status] ?? []));
|
||||
return out;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/config", () => ({
|
||||
getBackendConfig: () => ({ apiBaseUrl: "http://api.test" }),
|
||||
}));
|
||||
|
||||
import { searchTasks } from "./office-extended-api";
|
||||
import { listTasks, type ListTasksParams } from "./office-tasks-api";
|
||||
|
||||
type FetchInput = Parameters<typeof fetch>[0];
|
||||
@@ -34,6 +35,7 @@ function lastUrl(): string {
|
||||
}
|
||||
|
||||
const BASE = "http://api.test/api/v1/office/workspaces/ws-1/tasks";
|
||||
const SEARCH_BASE = "http://api.test/api/v1/office/workspaces/ws-1/tasks/search";
|
||||
|
||||
describe("listTasks — query string building", () => {
|
||||
it("emits no query string when no params are passed", async () => {
|
||||
@@ -103,3 +105,40 @@ describe("listTasks — query string building", () => {
|
||||
expect(lastUrl()).toContain("status=DONE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Office task API status normalization", () => {
|
||||
it("normalizes raw statuses before returning paginated tasks", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ tasks: [{ id: "task-1", status: "CREATED" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await listTasks("ws-1");
|
||||
|
||||
expect(response.tasks[0]).toMatchObject({
|
||||
id: "task-1",
|
||||
status: "todo",
|
||||
rawStatus: "CREATED",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes raw statuses before returning search results", async () => {
|
||||
fetchSpy.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ tasks: [{ id: "task-1", status: "CREATED" }] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await searchTasks("ws-1", "created task");
|
||||
|
||||
expect(lastUrl()).toBe(`${SEARCH_BASE}?q=created+task&limit=50`);
|
||||
expect(response.tasks[0]).toMatchObject({
|
||||
id: "task-1",
|
||||
status: "todo",
|
||||
rawStatus: "CREATED",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// budget.
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
import type { OfficeTask } from "@/lib/state/slices/office/types";
|
||||
import { normalizeOfficeTask, type OfficeTaskWire } from "./office-task-normalize";
|
||||
|
||||
const BASE = "/api/v1/office";
|
||||
|
||||
@@ -33,6 +34,12 @@ export type ListTasksResponse = {
|
||||
next_id?: string;
|
||||
};
|
||||
|
||||
type ListTasksWireResponse = {
|
||||
tasks: OfficeTaskWire[];
|
||||
next_cursor?: string;
|
||||
next_id?: string;
|
||||
};
|
||||
|
||||
function buildTaskListQuery(params: ListTasksParams | undefined): string {
|
||||
if (!params) return "";
|
||||
const usp = new URLSearchParams();
|
||||
@@ -75,8 +82,13 @@ export function listTasks(
|
||||
params = paramsOrOptions as ListTasksParams | undefined;
|
||||
options = maybeOptions;
|
||||
}
|
||||
return fetchJson<ListTasksResponse>(
|
||||
return fetchJson<ListTasksWireResponse>(
|
||||
`${BASE}/workspaces/${workspaceId}/tasks${buildTaskListQuery(params)}`,
|
||||
options,
|
||||
).then(
|
||||
(response): ListTasksResponse => ({
|
||||
...response,
|
||||
tasks: response.tasks.map(normalizeOfficeTask),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { StateCreator } from "zustand";
|
||||
import type { OfficeSlice, OfficeSliceState } from "./types";
|
||||
import { normalizeOfficeTask, normalizeTaskStatus } from "@/lib/api/domains/office-task-normalize";
|
||||
|
||||
export const defaultTaskFilters = {
|
||||
statuses: [] as string[],
|
||||
@@ -142,11 +143,20 @@ function createProjectActions(set: SetFn) {
|
||||
};
|
||||
}
|
||||
|
||||
type StoredTask = OfficeSlice["office"]["tasks"]["items"][number];
|
||||
|
||||
// Normalizes a freshly-ingested task's status, keeping the raw pre-
|
||||
// normalization value on `rawStatus` for the few consumers that need a
|
||||
// sub-state the canonical union collapses (see OfficeTask.rawStatus).
|
||||
function normalizeIngestedTask(task: StoredTask): StoredTask {
|
||||
return normalizeOfficeTask(task);
|
||||
}
|
||||
|
||||
function createTaskActions(set: SetFn) {
|
||||
return {
|
||||
setTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) =>
|
||||
set((draft) => {
|
||||
draft.office.tasks.items = tasks;
|
||||
draft.office.tasks.items = tasks.map(normalizeIngestedTask);
|
||||
}),
|
||||
appendTasks: (tasks: OfficeSlice["office"]["tasks"]["items"]) =>
|
||||
set((draft) => {
|
||||
@@ -154,18 +164,28 @@ function createTaskActions(set: SetFn) {
|
||||
const existing = new Set(draft.office.tasks.items.map((t) => t.id));
|
||||
for (const t of tasks) {
|
||||
if (!existing.has(t.id)) {
|
||||
draft.office.tasks.items.push(t);
|
||||
draft.office.tasks.items.push(normalizeIngestedTask(t));
|
||||
existing.add(t.id);
|
||||
}
|
||||
}
|
||||
}),
|
||||
patchTaskInStore: (
|
||||
taskId: string,
|
||||
patch: Partial<OfficeSlice["office"]["tasks"]["items"][number]>,
|
||||
) =>
|
||||
patchTaskInStore: (taskId: string, patch: Partial<StoredTask>) =>
|
||||
set((draft) => {
|
||||
const idx = draft.office.tasks.items.findIndex((t) => t.id === taskId);
|
||||
if (idx >= 0) Object.assign(draft.office.tasks.items[idx], patch);
|
||||
if (idx < 0) return;
|
||||
if (patch.status === undefined) {
|
||||
Object.assign(draft.office.tasks.items[idx], patch);
|
||||
return;
|
||||
}
|
||||
// `patch.rawStatus` is only already set when the caller is restoring
|
||||
// a full prior snapshot (see useOptimisticTaskMutation's rollback) —
|
||||
// preserve it rather than re-deriving from the (already-canonical)
|
||||
// snapshot status.
|
||||
Object.assign(draft.office.tasks.items[idx], {
|
||||
...patch,
|
||||
rawStatus: patch.rawStatus ?? patch.status,
|
||||
status: normalizeTaskStatus(patch.status),
|
||||
});
|
||||
}),
|
||||
setTaskFilters: (filters: Partial<OfficeSlice["office"]["tasks"]["filters"]>) =>
|
||||
set((draft) => {
|
||||
|
||||
@@ -75,3 +75,61 @@ describe("office task actions — appendTasks", () => {
|
||||
expect(store.getState().office.tasks.items).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("office task actions — status normalization at ingestion", () => {
|
||||
it("normalizes a raw backend status on setTasks and keeps the raw value on rawStatus", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setTasks([makeTask({ status: "CREATED" as OfficeTask["status"] })]);
|
||||
expect(store.getState().office.tasks.items[0].status).toBe("todo");
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("CREATED");
|
||||
});
|
||||
|
||||
it("normalizes a raw backend status on appendTasks", () => {
|
||||
const store = makeStore();
|
||||
store.getState().appendTasks([makeTask({ status: "COMPLETED" as OfficeTask["status"] })]);
|
||||
expect(store.getState().office.tasks.items[0].status).toBe("done");
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("COMPLETED");
|
||||
});
|
||||
|
||||
it("preserves a raw sub-state (SCHEDULING) that collapses into the same canonical status", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setTasks([makeTask({ status: "SCHEDULING" as OfficeTask["status"] })]);
|
||||
expect(store.getState().office.tasks.items[0].status).toBe("todo");
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
|
||||
});
|
||||
|
||||
it("normalizes a raw status patch via patchTaskInStore", () => {
|
||||
const store = makeStore();
|
||||
store.getState().setTasks([makeTask({ id: "task-1", status: "todo" })]);
|
||||
store.getState().patchTaskInStore("task-1", { status: "SCHEDULING" as OfficeTask["status"] });
|
||||
expect(store.getState().office.tasks.items[0].status).toBe("todo");
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
|
||||
});
|
||||
|
||||
it("leaves rawStatus untouched when a patch omits status", () => {
|
||||
const store = makeStore();
|
||||
store
|
||||
.getState()
|
||||
.setTasks([makeTask({ id: "task-1", status: "SCHEDULING" as OfficeTask["status"] })]);
|
||||
store.getState().patchTaskInStore("task-1", { priority: "high" });
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
|
||||
expect(store.getState().office.tasks.items[0].priority).toBe("high");
|
||||
});
|
||||
|
||||
it("restores the original rawStatus when patchTaskInStore is given a full prior snapshot (optimistic-mutation rollback)", () => {
|
||||
const store = makeStore();
|
||||
store
|
||||
.getState()
|
||||
.setTasks([makeTask({ id: "task-1", status: "SCHEDULING" as OfficeTask["status"] })]);
|
||||
const snapshot = store.getState().office.tasks.items[0];
|
||||
|
||||
// Simulate an optimistic patch to a different status, then a rollback
|
||||
// via the full prior snapshot — exactly what useOptimisticTaskMutation
|
||||
// does on API failure.
|
||||
store.getState().patchTaskInStore("task-1", { status: "in_review" });
|
||||
store.getState().patchTaskInStore("task-1", snapshot);
|
||||
|
||||
expect(store.getState().office.tasks.items[0].status).toBe("todo");
|
||||
expect(store.getState().office.tasks.items[0].rawStatus).toBe("SCHEDULING");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
// --- Provider routing types ---
|
||||
//
|
||||
// Split out of types.ts (which grows past the file-length lint limit easily
|
||||
// given how many office domains it hosts) — provider routing is a
|
||||
// self-contained concern with no dependencies on the rest of the office
|
||||
// slice types, so it splits cleanly.
|
||||
|
||||
export type Tier = "frontier" | "balanced" | "economy";
|
||||
|
||||
export type RoutingErrorCode =
|
||||
| "auth_required"
|
||||
| "missing_credentials"
|
||||
| "subscription_required"
|
||||
| "quota_limited"
|
||||
| "rate_limited"
|
||||
| ("network_unavailable" | "provider_unavailable" | "provider_overloaded" | "model_capacity")
|
||||
| "model_unavailable"
|
||||
| "provider_not_configured"
|
||||
| "unknown_provider_error"
|
||||
| "agent_runtime_error"
|
||||
| "task_error"
|
||||
| "repo_error"
|
||||
| "permission_denied_by_user"
|
||||
| "agent_transport_lost";
|
||||
|
||||
export type TierMap = {
|
||||
frontier?: string;
|
||||
balanced?: string;
|
||||
economy?: string;
|
||||
};
|
||||
|
||||
export type ProviderProfile = {
|
||||
tier_map: TierMap;
|
||||
execution_profile_ids?: Partial<Record<Tier, string>>;
|
||||
/** @deprecated Accepted only while reading pre-migration routing payloads. */
|
||||
tier_profile_ids?: Partial<Record<Tier, string>>;
|
||||
mode?: string;
|
||||
flags?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ExecutionProfileSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
mode?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
// Wake reasons the workspace can map onto specific tiers. v1 keeps the
|
||||
// surface narrow — only the three reasons that historically used the
|
||||
// cheap-profile shortcut. Adding more requires both a backend constant
|
||||
// and a UI copy update so the user gets context for each row.
|
||||
export type WakeReason = "heartbeat" | "routine_trigger" | "budget_alert";
|
||||
|
||||
export type TierPerReason = Partial<Record<WakeReason, Tier>>;
|
||||
|
||||
export type WorkspaceRouting = {
|
||||
enabled: boolean;
|
||||
provider_order: string[];
|
||||
default_tier: Tier;
|
||||
provider_profiles: Record<string, ProviderProfile>;
|
||||
tier_per_reason?: TierPerReason;
|
||||
};
|
||||
|
||||
export type AgentRoutingOverrides = {
|
||||
provider_order_source?: "inherit" | "override" | "";
|
||||
provider_order?: string[];
|
||||
tier_source?: "inherit" | "override" | "";
|
||||
tier?: Tier | "";
|
||||
tier_per_reason_source?: "inherit" | "override" | "";
|
||||
tier_per_reason?: TierPerReason;
|
||||
};
|
||||
|
||||
export type ProviderHealthState = "healthy" | "short_retry" | "degraded" | "user_action_required";
|
||||
export type ProviderHealthScope = "provider" | "model" | "tier";
|
||||
|
||||
export type ProviderHealth = {
|
||||
workspace_id?: string;
|
||||
provider_id: string;
|
||||
scope: ProviderHealthScope;
|
||||
scope_value: string;
|
||||
state: ProviderHealthState;
|
||||
error_code?: RoutingErrorCode;
|
||||
retry_at?: string;
|
||||
backoff_step: number;
|
||||
last_failure?: string;
|
||||
last_success?: string;
|
||||
raw_excerpt?: string;
|
||||
};
|
||||
|
||||
export type RouteAttemptOutcome =
|
||||
| "launched"
|
||||
| "retry_scheduled"
|
||||
| "failed_provider_unavailable"
|
||||
| "failed_other"
|
||||
| "skipped_degraded"
|
||||
| "skipped_user_action"
|
||||
| "skipped_missing_mapping"
|
||||
| "skipped_max_attempts";
|
||||
|
||||
export type RouteAttempt = {
|
||||
seq: number;
|
||||
execution_profile_id?: string;
|
||||
provider_id: string;
|
||||
model?: string;
|
||||
tier: Tier | "";
|
||||
outcome: RouteAttemptOutcome;
|
||||
error_code?: RoutingErrorCode;
|
||||
error_confidence?: "high" | "medium" | "low";
|
||||
adapter_phase?: string;
|
||||
classifier_rule?: string;
|
||||
exit_code?: number;
|
||||
raw_excerpt?: string;
|
||||
reset_hint?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
};
|
||||
|
||||
export type ProviderModelPair = {
|
||||
execution_profile_id?: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
tier: Tier | "";
|
||||
};
|
||||
|
||||
export type AgentRoutePreview = {
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
tier_source: "inherit" | "override";
|
||||
effective_tier: Tier;
|
||||
// primary_* reflects configured intent — first entry in the
|
||||
// effective provider order, even when that provider is currently
|
||||
// skipped (degraded / missing mapping).
|
||||
primary_provider_id?: string;
|
||||
primary_execution_profile_id?: string;
|
||||
primary_model?: string;
|
||||
// current_* reflects the candidate the next launch would actually
|
||||
// pick; equal to primary when not degraded. Empty when every
|
||||
// candidate is skipped.
|
||||
current_provider_id?: string;
|
||||
current_execution_profile_id?: string;
|
||||
current_model?: string;
|
||||
fallback_chain: ProviderModelPair[];
|
||||
missing: string[];
|
||||
degraded: boolean;
|
||||
};
|
||||
|
||||
export type AgentRouteData = {
|
||||
preview: AgentRoutePreview;
|
||||
/**
|
||||
* Raw routing override blob persisted on the agent's settings. The
|
||||
* agent routing UI hydrates from this so toggles + tier override +
|
||||
* provider-order override reflect persisted values on first paint.
|
||||
*/
|
||||
overrides: AgentRoutingOverrides;
|
||||
last_failure_code?: RoutingErrorCode;
|
||||
last_failure_run?: string;
|
||||
};
|
||||
|
||||
export type RoutingState = {
|
||||
byWorkspace: Record<string, WorkspaceRouting | undefined>;
|
||||
knownProviders: string[];
|
||||
preview: { byWorkspace: Record<string, AgentRoutePreview[] | undefined> };
|
||||
};
|
||||
|
||||
export type ProviderHealthSliceState = {
|
||||
byWorkspace: Record<string, ProviderHealth[]>;
|
||||
};
|
||||
|
||||
export type RunAttemptsState = {
|
||||
byRunId: Record<string, RouteAttempt[]>;
|
||||
};
|
||||
|
||||
export type AgentRoutingSliceState = {
|
||||
byAgentId: Record<string, AgentRouteData | undefined>;
|
||||
};
|
||||
@@ -261,6 +261,9 @@ export type OfficeTask = {
|
||||
title: string;
|
||||
description?: string;
|
||||
status: OfficeTaskStatus;
|
||||
// Pre-normalization backend value (e.g. "SCHEDULING"); read only for a raw
|
||||
// sub-state the canonical union erases (ExecutionIndicator's "Live" dot).
|
||||
rawStatus?: string;
|
||||
priority: OfficeTaskPriority;
|
||||
parentId?: string;
|
||||
projectId?: string;
|
||||
@@ -479,178 +482,44 @@ export type OfficeMeta = {
|
||||
};
|
||||
|
||||
// --- Provider routing types ---
|
||||
// Extracted to routing-types.ts to keep this file under the file-length
|
||||
// lint limit; re-exported here so existing `from ".../office/types"`
|
||||
// imports keep working unchanged.
|
||||
|
||||
export type Tier = "frontier" | "balanced" | "economy";
|
||||
|
||||
export type RoutingErrorCode =
|
||||
| "auth_required"
|
||||
| "missing_credentials"
|
||||
| "subscription_required"
|
||||
| "quota_limited"
|
||||
| "rate_limited"
|
||||
| ("network_unavailable" | "provider_unavailable" | "provider_overloaded" | "model_capacity")
|
||||
| "model_unavailable"
|
||||
| "provider_not_configured"
|
||||
| "unknown_provider_error"
|
||||
| "agent_runtime_error"
|
||||
| "task_error"
|
||||
| "repo_error"
|
||||
| "permission_denied_by_user"
|
||||
| "agent_transport_lost";
|
||||
|
||||
export type TierMap = {
|
||||
frontier?: string;
|
||||
balanced?: string;
|
||||
economy?: string;
|
||||
};
|
||||
|
||||
export type ProviderProfile = {
|
||||
tier_map: TierMap;
|
||||
execution_profile_ids?: Partial<Record<Tier, string>>;
|
||||
/** @deprecated Accepted only while reading pre-migration routing payloads. */
|
||||
tier_profile_ids?: Partial<Record<Tier, string>>;
|
||||
mode?: string;
|
||||
flags?: string[];
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type ExecutionProfileSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
mode?: string;
|
||||
workspace_id?: string;
|
||||
};
|
||||
|
||||
// Wake reasons the workspace can map onto specific tiers. v1 keeps the
|
||||
// surface narrow — only the three reasons that historically used the
|
||||
// cheap-profile shortcut. Adding more requires both a backend constant
|
||||
// and a UI copy update so the user gets context for each row.
|
||||
export type WakeReason = "heartbeat" | "routine_trigger" | "budget_alert";
|
||||
|
||||
export type TierPerReason = Partial<Record<WakeReason, Tier>>;
|
||||
|
||||
export type WorkspaceRouting = {
|
||||
enabled: boolean;
|
||||
provider_order: string[];
|
||||
default_tier: Tier;
|
||||
provider_profiles: Record<string, ProviderProfile>;
|
||||
tier_per_reason?: TierPerReason;
|
||||
};
|
||||
|
||||
export type AgentRoutingOverrides = {
|
||||
provider_order_source?: "inherit" | "override" | "";
|
||||
provider_order?: string[];
|
||||
tier_source?: "inherit" | "override" | "";
|
||||
tier?: Tier | "";
|
||||
tier_per_reason_source?: "inherit" | "override" | "";
|
||||
tier_per_reason?: TierPerReason;
|
||||
};
|
||||
|
||||
export type ProviderHealthState = "healthy" | "short_retry" | "degraded" | "user_action_required";
|
||||
export type ProviderHealthScope = "provider" | "model" | "tier";
|
||||
|
||||
export type ProviderHealth = {
|
||||
workspace_id?: string;
|
||||
provider_id: string;
|
||||
scope: ProviderHealthScope;
|
||||
scope_value: string;
|
||||
state: ProviderHealthState;
|
||||
error_code?: RoutingErrorCode;
|
||||
retry_at?: string;
|
||||
backoff_step: number;
|
||||
last_failure?: string;
|
||||
last_success?: string;
|
||||
raw_excerpt?: string;
|
||||
};
|
||||
|
||||
export type RouteAttemptOutcome =
|
||||
| "launched"
|
||||
| "retry_scheduled"
|
||||
| "failed_provider_unavailable"
|
||||
| "failed_other"
|
||||
| "skipped_degraded"
|
||||
| "skipped_user_action"
|
||||
| "skipped_missing_mapping"
|
||||
| "skipped_max_attempts";
|
||||
|
||||
export type RouteAttempt = {
|
||||
seq: number;
|
||||
execution_profile_id?: string;
|
||||
provider_id: string;
|
||||
model?: string;
|
||||
tier: Tier | "";
|
||||
outcome: RouteAttemptOutcome;
|
||||
error_code?: RoutingErrorCode;
|
||||
error_confidence?: "high" | "medium" | "low";
|
||||
adapter_phase?: string;
|
||||
classifier_rule?: string;
|
||||
exit_code?: number;
|
||||
raw_excerpt?: string;
|
||||
reset_hint?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
};
|
||||
|
||||
export type ProviderModelPair = {
|
||||
execution_profile_id?: string;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
tier: Tier | "";
|
||||
};
|
||||
|
||||
export type AgentRoutePreview = {
|
||||
agent_id: string;
|
||||
agent_name: string;
|
||||
tier_source: "inherit" | "override";
|
||||
effective_tier: Tier;
|
||||
// primary_* reflects configured intent — first entry in the
|
||||
// effective provider order, even when that provider is currently
|
||||
// skipped (degraded / missing mapping).
|
||||
primary_provider_id?: string;
|
||||
primary_execution_profile_id?: string;
|
||||
primary_model?: string;
|
||||
// current_* reflects the candidate the next launch would actually
|
||||
// pick; equal to primary when not degraded. Empty when every
|
||||
// candidate is skipped.
|
||||
current_provider_id?: string;
|
||||
current_execution_profile_id?: string;
|
||||
current_model?: string;
|
||||
fallback_chain: ProviderModelPair[];
|
||||
missing: string[];
|
||||
degraded: boolean;
|
||||
};
|
||||
|
||||
export type AgentRouteData = {
|
||||
preview: AgentRoutePreview;
|
||||
/**
|
||||
* Raw routing override blob persisted on the agent's settings. The
|
||||
* agent routing UI hydrates from this so toggles + tier override +
|
||||
* provider-order override reflect persisted values on first paint.
|
||||
*/
|
||||
overrides: AgentRoutingOverrides;
|
||||
last_failure_code?: RoutingErrorCode;
|
||||
last_failure_run?: string;
|
||||
};
|
||||
|
||||
export type RoutingState = {
|
||||
byWorkspace: Record<string, WorkspaceRouting | undefined>;
|
||||
knownProviders: string[];
|
||||
preview: { byWorkspace: Record<string, AgentRoutePreview[] | undefined> };
|
||||
};
|
||||
|
||||
export type ProviderHealthSliceState = {
|
||||
byWorkspace: Record<string, ProviderHealth[]>;
|
||||
};
|
||||
|
||||
export type RunAttemptsState = {
|
||||
byRunId: Record<string, RouteAttempt[]>;
|
||||
};
|
||||
|
||||
export type AgentRoutingSliceState = {
|
||||
byAgentId: Record<string, AgentRouteData | undefined>;
|
||||
};
|
||||
export type {
|
||||
Tier,
|
||||
RoutingErrorCode,
|
||||
TierMap,
|
||||
ProviderProfile,
|
||||
ExecutionProfileSummary,
|
||||
WakeReason,
|
||||
TierPerReason,
|
||||
WorkspaceRouting,
|
||||
AgentRoutingOverrides,
|
||||
ProviderHealthState,
|
||||
ProviderHealthScope,
|
||||
ProviderHealth,
|
||||
RouteAttemptOutcome,
|
||||
RouteAttempt,
|
||||
ProviderModelPair,
|
||||
AgentRoutePreview,
|
||||
AgentRouteData,
|
||||
RoutingState,
|
||||
ProviderHealthSliceState,
|
||||
RunAttemptsState,
|
||||
AgentRoutingSliceState,
|
||||
} from "./routing-types";
|
||||
import type {
|
||||
AgentRouteData,
|
||||
AgentRoutePreview,
|
||||
AgentRoutingSliceState,
|
||||
ProviderHealth,
|
||||
ProviderHealthSliceState,
|
||||
RouteAttempt,
|
||||
RoutingState,
|
||||
RunAttemptsState,
|
||||
WorkspaceRouting,
|
||||
} from "./routing-types";
|
||||
|
||||
// --- Slice state & actions ---
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ function makeStore(activeWorkspaceId: string | null) {
|
||||
const upsertProviderHealth = vi.fn();
|
||||
const appendRunAttempt = vi.fn();
|
||||
const setWorkspaceRouting = vi.fn();
|
||||
const patchTaskInStore = vi.fn();
|
||||
let state = {
|
||||
workspaces: { items: [], activeId: activeWorkspaceId },
|
||||
office: { tasks: { items: [] } },
|
||||
@@ -21,6 +22,7 @@ function makeStore(activeWorkspaceId: string | null) {
|
||||
upsertProviderHealth,
|
||||
appendRunAttempt,
|
||||
setWorkspaceRouting,
|
||||
patchTaskInStore,
|
||||
} as unknown as AppState;
|
||||
const subs: Array<(s: AppState) => void> = [];
|
||||
const store: StoreApi<AppState> = {
|
||||
@@ -47,6 +49,7 @@ function makeStore(activeWorkspaceId: string | null) {
|
||||
upsertProviderHealth,
|
||||
appendRunAttempt,
|
||||
setWorkspaceRouting,
|
||||
patchTaskInStore,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +134,65 @@ describe("office WS handler — workspace filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("office WS handler — task field updates delegate to patchTaskInStore", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps the producer state field on office.task.updated", () => {
|
||||
const { store, patchTaskInStore } = makeStore(ACTIVE_WS);
|
||||
const handlers = registerOfficeHandlers(store);
|
||||
const handler = handlers["office.task.updated"]!;
|
||||
|
||||
handler({
|
||||
type: "notification",
|
||||
action: "office.task.updated",
|
||||
payload: { workspace_id: ACTIVE_WS, task_id: "t-1", state: "SCHEDULING" },
|
||||
} as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(patchTaskInStore).toHaveBeenCalledWith(
|
||||
"t-1",
|
||||
expect.objectContaining({ status: "SCHEDULING" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("refreshes tasks for a producer task.moved payload without a status", () => {
|
||||
const { store, patchTaskInStore, setOfficeRefetchTrigger } = makeStore(ACTIVE_WS);
|
||||
const handlers = registerOfficeHandlers(store);
|
||||
const handler = handlers["office.task.moved"]!;
|
||||
|
||||
handler({
|
||||
type: "notification",
|
||||
action: "office.task.moved",
|
||||
payload: {
|
||||
workspace_id: ACTIVE_WS,
|
||||
task_id: "t-1",
|
||||
from_step_id: "step-todo",
|
||||
to_step_id: "step-review",
|
||||
session_id: "session-1",
|
||||
},
|
||||
} as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(patchTaskInStore).not.toHaveBeenCalled();
|
||||
expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("tasks");
|
||||
expect(setOfficeRefetchTrigger).toHaveBeenCalledWith("dashboard");
|
||||
});
|
||||
|
||||
it("passes the raw new_status through on office.task.status_changed", () => {
|
||||
const { store, patchTaskInStore } = makeStore(ACTIVE_WS);
|
||||
const handlers = registerOfficeHandlers(store);
|
||||
const handler = handlers["office.task.status_changed"]!;
|
||||
|
||||
handler({
|
||||
type: "notification",
|
||||
action: "office.task.status_changed",
|
||||
payload: { workspace_id: ACTIVE_WS, task_id: "t-1", new_status: "CREATED" },
|
||||
} as Parameters<typeof handler>[0]);
|
||||
|
||||
expect(patchTaskInStore).toHaveBeenCalledWith("t-1", { status: "CREATED" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("office WS handler — approval flow events", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { StoreApi } from "zustand";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import type { WsHandlers } from "@/lib/ws/handlers/types";
|
||||
import type {
|
||||
OfficeTask,
|
||||
OfficeTaskStatus,
|
||||
ProviderHealth,
|
||||
RouteAttempt,
|
||||
} from "@/lib/state/slices/office/types";
|
||||
|
||||
/**
|
||||
* Registers WS handlers for office domain events.
|
||||
@@ -27,17 +33,11 @@ export function registerOfficeHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
return !wsId || wsId === activeId;
|
||||
};
|
||||
|
||||
// Delegates to the store's own patchTaskInStore action rather than
|
||||
// writing office.tasks.items directly, so a raw wire status (e.g.
|
||||
// "SCHEDULING") gets the same normalization as API-sourced task loads.
|
||||
const updateTaskStatus = (taskId: string, fields: Record<string, unknown>) => {
|
||||
store.setState((state) => ({
|
||||
...state,
|
||||
office: {
|
||||
...state.office,
|
||||
tasks: {
|
||||
...state.office.tasks,
|
||||
items: state.office.tasks.items.map((i) => (i.id === taskId ? { ...i, ...fields } : i)),
|
||||
},
|
||||
},
|
||||
}));
|
||||
store.getState().patchTaskInStore(taskId, fields as Partial<OfficeTask>);
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -256,8 +256,8 @@ function buildRoutingHandlers(
|
||||
};
|
||||
}
|
||||
|
||||
type ProviderHealthPayload = import("@/lib/state/slices/office/types").ProviderHealth;
|
||||
type RouteAttemptPayload = import("@/lib/state/slices/office/types").RouteAttempt;
|
||||
type ProviderHealthPayload = ProviderHealth;
|
||||
type RouteAttemptPayload = RouteAttempt;
|
||||
|
||||
function extractProviderHealth(p: Record<string, unknown>): ProviderHealthPayload | null {
|
||||
if (typeof p.provider_id !== "string" || typeof p.scope !== "string") return null;
|
||||
@@ -281,6 +281,7 @@ function normalizeIssueFields(p: Record<string, unknown>): Record<string, unknow
|
||||
const out: Record<string, unknown> = {};
|
||||
if (p.title != null) out.title = p.title;
|
||||
if (p.description != null) out.description = p.description;
|
||||
if (p.state != null) out.status = p.state;
|
||||
if (p.status != null) out.status = p.status;
|
||||
if (p.new_status != null) out.status = p.new_status;
|
||||
if (p.priority != null) out.priority = p.priority;
|
||||
@@ -288,6 +289,3 @@ function normalizeIssueFields(p: Record<string, unknown>): Record<string, unknow
|
||||
if (p.assignee_agent_profile_id != null) out.assigneeAgentProfileId = p.assignee_agent_profile_id;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Re-import the type for the status field cast
|
||||
type OfficeTaskStatus = import("@/lib/state/slices/office/types").OfficeTaskStatus;
|
||||
|
||||
Reference in New Issue
Block a user