fix(tasks): refresh top bar after workflow migration (#3927)
This commit is contained in:
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
const ARCHIVED_AT = "2026-07-19T00:00:00Z";
|
||||
const FRESH_KANBAN_UPDATED_AT = "2026-07-20T00:00:00Z";
|
||||
|
||||
function makeArchivedTaskDetails(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
@@ -561,7 +562,10 @@ describe("resolveEffectiveTask archived state", () => {
|
||||
const enabled = resolveEffectiveTask(
|
||||
base,
|
||||
null,
|
||||
makeKanbanTask({ metadata: { port_forwarding_enabled: true } }),
|
||||
makeKanbanTask({
|
||||
metadata: { port_forwarding_enabled: true },
|
||||
updatedAt: FRESH_KANBAN_UPDATED_AT,
|
||||
}),
|
||||
"task-1",
|
||||
);
|
||||
expect(enabled?.metadata).toEqual({ port_forwarding_enabled: true });
|
||||
@@ -601,7 +605,11 @@ describe("resolveEffectiveTask archived state", () => {
|
||||
|
||||
it("prefers live kanban title/state while preserving base-only fields", () => {
|
||||
const taskDetails = makeArchivedTaskDetails({ archived_at: null });
|
||||
const kanbanTask = makeKanbanTask({ title: "Live title", state: "IN_PROGRESS" });
|
||||
const kanbanTask = makeKanbanTask({
|
||||
title: "Live title",
|
||||
state: "IN_PROGRESS",
|
||||
updatedAt: FRESH_KANBAN_UPDATED_AT,
|
||||
});
|
||||
|
||||
const resolved = resolveEffectiveTask(taskDetails, null, kanbanTask, "task-1");
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { KanbanState } from "@/lib/state/slices";
|
||||
import { issueFieldsFromMetadata } from "@/lib/metadata-utils";
|
||||
import { repositorySlug } from "@/lib/repository-slug";
|
||||
import { remoteRepositoryBrowserUrl } from "@/lib/utils/remote-repository-browser-url";
|
||||
import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
|
||||
import type { TaskActionsMenuBoardRow } from "@/hooks/use-task-actions-menu";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
@@ -141,29 +142,77 @@ export function resolveEffectiveTask(
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolveLatestTaskProjection(
|
||||
taskId: string | null,
|
||||
activeTasks: KanbanState["tasks"],
|
||||
snapshots: Record<string, { tasks: KanbanState["tasks"] }>,
|
||||
): KanbanState["tasks"][number] | null {
|
||||
if (!taskId) return null;
|
||||
const candidates = [activeTasks, ...Object.values(snapshots).map((snapshot) => snapshot.tasks)];
|
||||
let latestTask: KanbanState["tasks"][number] | null = null;
|
||||
let latestTimestamp: bigint | null = null;
|
||||
|
||||
for (const tasks of candidates) {
|
||||
for (const task of tasks) {
|
||||
if (task.id !== taskId) continue;
|
||||
const updatedAt = parseTurnTimestamp(task.updatedAt ?? undefined);
|
||||
if (
|
||||
!latestTask ||
|
||||
(updatedAt !== null && (latestTimestamp === null || updatedAt > latestTimestamp))
|
||||
) {
|
||||
latestTask = task;
|
||||
latestTimestamp = updatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return latestTask;
|
||||
}
|
||||
|
||||
export function resolveWorkflowCurrentStepId(
|
||||
sessionStepId: string | null,
|
||||
taskStepId: string | null,
|
||||
workflowStepIds: readonly string[],
|
||||
): string | null {
|
||||
// The resolved task step is freshest across sources. Only use the session
|
||||
// step as a fallback when it belongs to this workflow.
|
||||
if (taskStepId) return taskStepId;
|
||||
if (sessionStepId && workflowStepIds.includes(sessionStepId)) return sessionStepId;
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasNewerKanbanState(
|
||||
baseTask: Task,
|
||||
kanbanTimestamp: bigint,
|
||||
baseTimestamp: bigint | null,
|
||||
): boolean {
|
||||
return Boolean(baseTask.archived_at && baseTimestamp !== null && kanbanTimestamp > baseTimestamp);
|
||||
}
|
||||
|
||||
export function mergeBaseWithKanban(
|
||||
baseTask: Task,
|
||||
kanbanTask: KanbanState["tasks"][number] | null,
|
||||
): Task {
|
||||
if (!kanbanTask) return baseTask;
|
||||
const kanbanUpdatedAt = Date.parse(kanbanTask.updatedAt ?? "");
|
||||
const baseUpdatedAt = Date.parse(baseTask.updated_at ?? "");
|
||||
const hasNewerKanbanState =
|
||||
Boolean(baseTask.archived_at) &&
|
||||
Number.isFinite(kanbanUpdatedAt) &&
|
||||
Number.isFinite(baseUpdatedAt) &&
|
||||
kanbanUpdatedAt > baseUpdatedAt;
|
||||
const kanbanTimestamp = parseTurnTimestamp(kanbanTask.updatedAt ?? undefined);
|
||||
const baseTimestamp = parseTurnTimestamp(baseTask.updated_at);
|
||||
if (kanbanTimestamp === null || (baseTimestamp !== null && kanbanTimestamp < baseTimestamp)) {
|
||||
return baseTask;
|
||||
}
|
||||
const hasCompletePlacement = Boolean(kanbanTask.workflowId && kanbanTask.workflowStepId);
|
||||
return {
|
||||
...baseTask,
|
||||
title: kanbanTask.title ?? baseTask.title,
|
||||
description: kanbanTask.description ?? baseTask.description,
|
||||
workflow_step_id:
|
||||
(kanbanTask.workflowStepId as string | undefined) ?? baseTask.workflow_step_id,
|
||||
workflow_id: hasCompletePlacement ? toWorkflowId(kanbanTask.workflowId) : baseTask.workflow_id,
|
||||
workflow_step_id: hasCompletePlacement ? kanbanTask.workflowStepId : baseTask.workflow_step_id,
|
||||
position: kanbanTask.position ?? baseTask.position,
|
||||
state: (kanbanTask.state as Task["state"] | undefined) ?? baseTask.state,
|
||||
repositories: baseTask.repositories,
|
||||
metadata: kanbanTask.metadata !== undefined ? kanbanTask.metadata : baseTask.metadata,
|
||||
archived_at: hasNewerKanbanState ? null : baseTask.archived_at,
|
||||
archived_at: hasNewerKanbanState(baseTask, kanbanTimestamp, baseTimestamp)
|
||||
? null
|
||||
: baseTask.archived_at,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,7 +225,7 @@ export function buildTaskFromKanban(kanbanTask: KanbanState["tasks"][number]): T
|
||||
position: kanbanTask.position,
|
||||
state: kanbanTask.state ?? "CREATED",
|
||||
workspace_id: toWorkspaceId(""),
|
||||
workflow_id: toWorkflowId(""),
|
||||
workflow_id: toWorkflowId(kanbanTask.workflowId ?? ""),
|
||||
priority: kanbanTask.priority ?? "medium",
|
||||
repositories: [],
|
||||
created_at: "",
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { repositoryId, taskId, workflowId, type Task } from "@/lib/types/http";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import {
|
||||
buildTaskFromKanban,
|
||||
resolveEffectiveTask,
|
||||
resolveLatestTaskProjection,
|
||||
resolveWorkflowCurrentStepId,
|
||||
} from "./task-page-content-helpers";
|
||||
|
||||
type KanbanTask = KanbanState["tasks"][number];
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
const SOURCE_WORKFLOW = "workflow-source";
|
||||
const DESTINATION_WORKFLOW = "workflow-destination";
|
||||
const SOURCE_STEP = "step-source";
|
||||
const DESTINATION_STEP = "step-destination";
|
||||
const SOURCE_UPDATED_AT = "2026-07-18T00:00:00Z";
|
||||
const DESTINATION_UPDATED_AT = "2026-07-19T00:00:00Z";
|
||||
|
||||
function makeTaskDetails(overrides: Partial<Task> = {}): Task {
|
||||
return {
|
||||
id: taskId(TASK_ID),
|
||||
title: "Workflow migration task",
|
||||
description: "Task details",
|
||||
workflow_step_id: SOURCE_STEP,
|
||||
position: 0,
|
||||
state: "TODO",
|
||||
workspace_id: "workspace-1",
|
||||
workflow_id: workflowId(SOURCE_WORKFLOW),
|
||||
priority: "medium",
|
||||
repositories: [],
|
||||
created_at: "",
|
||||
updated_at: SOURCE_UPDATED_AT,
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function makeKanbanTask(overrides: Partial<KanbanTask> = {}): KanbanTask {
|
||||
return {
|
||||
id: TASK_ID,
|
||||
title: "Workflow migration task",
|
||||
workflowId: SOURCE_WORKFLOW,
|
||||
workflowStepId: SOURCE_STEP,
|
||||
position: 0,
|
||||
state: "TODO",
|
||||
...overrides,
|
||||
} as KanbanTask;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- placement cases share fixtures and freshness assertions.
|
||||
describe("task workflow placement", () => {
|
||||
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
|
||||
it("applies a newer destination placement and retains task-only details", () => {
|
||||
const repositories: Task["repositories"] = [
|
||||
{
|
||||
id: "task-repo-1",
|
||||
task_id: taskId(TASK_ID),
|
||||
repository_id: repositoryId("repo-1"),
|
||||
base_branch: "main",
|
||||
position: 0,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
},
|
||||
];
|
||||
const details = makeTaskDetails({ repositories });
|
||||
const destination = makeKanbanTask({
|
||||
workflowId: DESTINATION_WORKFLOW,
|
||||
workflowStepId: DESTINATION_STEP,
|
||||
updatedAt: DESTINATION_UPDATED_AT,
|
||||
});
|
||||
|
||||
const resolved = resolveEffectiveTask(details, null, destination, TASK_ID);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
workflow_id: DESTINATION_WORKFLOW,
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
repositories,
|
||||
});
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
|
||||
it("rejects an older source placement as one workflow and step pair", () => {
|
||||
const details = makeTaskDetails({
|
||||
workflow_id: workflowId(DESTINATION_WORKFLOW),
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
updated_at: DESTINATION_UPDATED_AT,
|
||||
});
|
||||
const staleSource = makeKanbanTask({ updatedAt: SOURCE_UPDATED_AT });
|
||||
|
||||
const resolved = resolveEffectiveTask(details, null, staleSource, TASK_ID);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
workflow_id: DESTINATION_WORKFLOW,
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
});
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
|
||||
it("preserves the known placement when a live row omits its destination step", () => {
|
||||
const details = makeTaskDetails();
|
||||
const partial = makeKanbanTask({
|
||||
workflowId: DESTINATION_WORKFLOW,
|
||||
workflowStepId: "",
|
||||
});
|
||||
|
||||
const resolved = resolveEffectiveTask(details, null, partial, TASK_ID);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
workflow_id: SOURCE_WORKFLOW,
|
||||
workflow_step_id: SOURCE_STEP,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not merge an older projection's freshness-sensitive details", () => {
|
||||
const details = makeTaskDetails({
|
||||
title: "Fresh title",
|
||||
description: "Fresh description",
|
||||
state: "IN_PROGRESS",
|
||||
position: 9,
|
||||
metadata: { source: "http" },
|
||||
workflow_id: workflowId(DESTINATION_WORKFLOW),
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
updated_at: DESTINATION_UPDATED_AT,
|
||||
});
|
||||
const staleProjection = makeKanbanTask({
|
||||
title: "Stale title",
|
||||
description: "Stale description",
|
||||
state: "TODO",
|
||||
position: 1,
|
||||
metadata: { source: "cache" },
|
||||
workflowId: SOURCE_WORKFLOW,
|
||||
workflowStepId: SOURCE_STEP,
|
||||
updatedAt: SOURCE_UPDATED_AT,
|
||||
});
|
||||
|
||||
const resolved = resolveEffectiveTask(details, null, staleProjection, TASK_ID);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
title: "Fresh title",
|
||||
description: "Fresh description",
|
||||
state: "IN_PROGRESS",
|
||||
position: 9,
|
||||
metadata: { source: "http" },
|
||||
workflow_id: DESTINATION_WORKFLOW,
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing", undefined],
|
||||
["malformed", "not-a-timestamp"],
|
||||
["date-normalized malformed", "2026-02-30T00:00:00Z"],
|
||||
])("rejects a %s projection timestamp for placement", (_label, updatedAt) => {
|
||||
const details = makeTaskDetails({
|
||||
workflow_id: workflowId(DESTINATION_WORKFLOW),
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
updated_at: SOURCE_UPDATED_AT,
|
||||
});
|
||||
const untrustedProjection = makeKanbanTask({
|
||||
workflowId: SOURCE_WORKFLOW,
|
||||
workflowStepId: SOURCE_STEP,
|
||||
updatedAt,
|
||||
});
|
||||
|
||||
const resolved = resolveEffectiveTask(details, null, untrustedProjection, TASK_ID);
|
||||
|
||||
expect(resolved).toMatchObject({
|
||||
workflow_id: DESTINATION_WORKFLOW,
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("task workflow projection selection", () => {
|
||||
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
|
||||
it("selects the newer destination row when an older source snapshot remains", () => {
|
||||
const source = makeKanbanTask({ updatedAt: SOURCE_UPDATED_AT });
|
||||
const destination = makeKanbanTask({
|
||||
workflowId: DESTINATION_WORKFLOW,
|
||||
workflowStepId: DESTINATION_STEP,
|
||||
updatedAt: DESTINATION_UPDATED_AT,
|
||||
});
|
||||
|
||||
const selected = resolveLatestTaskProjection(TASK_ID, [source], {
|
||||
[SOURCE_WORKFLOW]: { tasks: [source] },
|
||||
[DESTINATION_WORKFLOW]: { tasks: [destination] },
|
||||
});
|
||||
|
||||
expect(selected).toBe(destination);
|
||||
});
|
||||
|
||||
it("keeps a nanosecond-newer active row over an older cached snapshot", () => {
|
||||
const freshDestination = makeKanbanTask({
|
||||
workflowId: DESTINATION_WORKFLOW,
|
||||
workflowStepId: DESTINATION_STEP,
|
||||
updatedAt: "2026-07-19T00:00:00.123456790Z",
|
||||
});
|
||||
const staleSource = makeKanbanTask({
|
||||
updatedAt: "2026-07-19T00:00:00.123456789Z",
|
||||
});
|
||||
|
||||
const selected = resolveLatestTaskProjection(TASK_ID, [freshDestination], {
|
||||
[SOURCE_WORKFLOW]: { tasks: [staleSource] },
|
||||
});
|
||||
|
||||
expect(selected).toBe(freshDestination);
|
||||
});
|
||||
|
||||
it("ignores a date-normalized malformed cache timestamp", () => {
|
||||
const validTask = makeKanbanTask({
|
||||
workflowId: DESTINATION_WORKFLOW,
|
||||
workflowStepId: DESTINATION_STEP,
|
||||
updatedAt: "2026-02-28T00:00:00Z",
|
||||
});
|
||||
const malformedTask = makeKanbanTask({
|
||||
updatedAt: "2026-02-30T00:00:00Z",
|
||||
});
|
||||
|
||||
const selected = resolveLatestTaskProjection(TASK_ID, [validTask], {
|
||||
[SOURCE_WORKFLOW]: { tasks: [malformedTask] },
|
||||
});
|
||||
|
||||
expect(selected).toBe(validTask);
|
||||
});
|
||||
|
||||
// @covers AC-TASKS-CHANGE-WORKFLOW-001.8
|
||||
it("uses a task step when the session step belongs to the source workflow", () => {
|
||||
expect(
|
||||
resolveWorkflowCurrentStepId(SOURCE_STEP, DESTINATION_STEP, [DESTINATION_STEP, "step-next"]),
|
||||
).toBe(DESTINATION_STEP);
|
||||
});
|
||||
|
||||
it("uses the fresh task-detail step over an older cached step in the same workflow", () => {
|
||||
const details = makeTaskDetails({
|
||||
workflow_id: workflowId(SOURCE_WORKFLOW),
|
||||
workflow_step_id: DESTINATION_STEP,
|
||||
updated_at: DESTINATION_UPDATED_AT,
|
||||
});
|
||||
const staleCachedTask = makeKanbanTask({
|
||||
workflowId: SOURCE_WORKFLOW,
|
||||
workflowStepId: SOURCE_STEP,
|
||||
updatedAt: SOURCE_UPDATED_AT,
|
||||
});
|
||||
const resolvedTask = resolveEffectiveTask(details, null, staleCachedTask, TASK_ID);
|
||||
|
||||
expect(
|
||||
resolveWorkflowCurrentStepId(
|
||||
staleCachedTask.workflowStepId,
|
||||
resolvedTask?.workflow_step_id ?? null,
|
||||
[SOURCE_STEP, DESTINATION_STEP],
|
||||
),
|
||||
).toBe(DESTINATION_STEP);
|
||||
});
|
||||
|
||||
it("includes the workflow ID when building a task from a projection", () => {
|
||||
const task = buildTaskFromKanban(
|
||||
makeKanbanTask({ workflowId: DESTINATION_WORKFLOW, workflowStepId: DESTINATION_STEP }),
|
||||
);
|
||||
|
||||
expect(task.workflow_id).toBe(DESTINATION_WORKFLOW);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,27 @@
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import { useEffect } from "react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createElement, type ReactNode, useEffect } from "react";
|
||||
import { act, cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import { TaskLoadErrorState } from "./task-page-content";
|
||||
import * as api from "@/lib/api";
|
||||
import { taskId, workflowId, workspaceId, type Task } from "@/lib/types/http";
|
||||
import { TaskLoadErrorState, useTaskDetails } from "./task-page-content";
|
||||
import { TaskRemovalBoundary } from "./task-removal-boundary";
|
||||
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const TASK_A = "task-a";
|
||||
const TASK_B = "task-b";
|
||||
const REMOVAL_STATUS_TEST_ID = "task-removal-status";
|
||||
|
||||
function createStateWrapper(initialState: unknown) {
|
||||
return function StateTestWrapper({ children }: { children: ReactNode }) {
|
||||
return createElement(StateProvider, { initialState: initialState as never, children });
|
||||
};
|
||||
}
|
||||
|
||||
function renderErrorState(activeId: string | null) {
|
||||
render(
|
||||
<StateProvider initialState={{ workspaces: { items: [], activeId } }}>
|
||||
@@ -37,6 +48,68 @@ describe("TaskLoadErrorState", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTaskDetails reconnect refresh", () => {
|
||||
it("reloads task placement after the websocket reconnects", async () => {
|
||||
const initialTask = {
|
||||
id: taskId(TASK_A),
|
||||
title: "Workflow migration task",
|
||||
description: "Task details",
|
||||
workflow_id: workflowId("workflow-source"),
|
||||
workflow_step_id: "step-source",
|
||||
position: 0,
|
||||
state: "TODO",
|
||||
workspace_id: workspaceId("workspace-1"),
|
||||
priority: "medium",
|
||||
repositories: [],
|
||||
created_at: "2026-07-18T00:00:00Z",
|
||||
updated_at: "2026-07-18T00:00:00Z",
|
||||
} as Task;
|
||||
const movedTask = {
|
||||
...initialTask,
|
||||
workflow_id: workflowId("workflow-destination"),
|
||||
workflow_step_id: "step-analysis",
|
||||
updated_at: "2026-07-19T00:00:00Z",
|
||||
};
|
||||
const fetchTask = vi.spyOn(api, "fetchTask").mockResolvedValue(movedTask);
|
||||
const wrapper = createStateWrapper({
|
||||
tasks: { activeTaskId: TASK_A },
|
||||
connection: { status: "disconnected" },
|
||||
kanban: {
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_A,
|
||||
title: initialTask.title,
|
||||
description: initialTask.description,
|
||||
workflowId: "workflow-source",
|
||||
workflowStepId: "step-source",
|
||||
position: initialTask.position,
|
||||
state: initialTask.state,
|
||||
updatedAt: initialTask.updated_at,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => ({
|
||||
details: useTaskDetails(TASK_A, initialTask),
|
||||
store: useAppStoreApi(),
|
||||
}),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
expect(fetchTask).not.toHaveBeenCalled();
|
||||
act(() => result.current.store.getState().setConnectionStatus("connected"));
|
||||
|
||||
await waitFor(() => expect(fetchTask).toHaveBeenCalledWith(TASK_A, { cache: "no-store" }));
|
||||
await waitFor(() => {
|
||||
expect(result.current.details.task).toMatchObject({
|
||||
workflow_id: "workflow-destination",
|
||||
workflow_step_id: "step-analysis",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function StartRemoval({
|
||||
removalTaskId = "task-1",
|
||||
activeTaskId,
|
||||
|
||||
@@ -7,7 +7,6 @@ import Link from "@/components/routing/app-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Repository, RepositoryScript, Task } from "@/lib/types/http";
|
||||
import type { Terminal } from "@/hooks/domains/session/use-terminals";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
|
||||
import { useSessionAgent } from "@/hooks/domains/session/use-session-agent";
|
||||
import { useSessionResumption } from "@/hooks/domains/session/use-session-resumption";
|
||||
@@ -18,7 +17,8 @@ import { useEnsureTaskSession } from "@/hooks/domains/session/use-ensure-task-se
|
||||
import { useExternalVcsFileLinkHydration } from "@/hooks/domains/workspace/use-external-vcs-file-link";
|
||||
import { fetchTask } from "@/lib/api";
|
||||
import { linkToTaskOverview } from "@/lib/links";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import { useWorkflowSnapshotById } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
||||
import { useWorkflowStepsById } from "@/hooks/domains/kanban/use-workflow-steps-by-id";
|
||||
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
|
||||
import { useFeature } from "@/hooks/domains/features/use-feature";
|
||||
import { useTaskCanvasesStateForTask } from "@/hooks/domains/task/use-task-canvases";
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
buildArchivedValue,
|
||||
hasResolvedTaskDetails,
|
||||
resolveEffectiveTask,
|
||||
resolveLatestTaskProjection,
|
||||
resolveTaskContentState,
|
||||
syncActiveTaskSession,
|
||||
} from "@/components/task/task-page-content-helpers";
|
||||
@@ -48,23 +49,16 @@ type TaskPageContentProps = {
|
||||
officeTaskHref?: string | null;
|
||||
};
|
||||
|
||||
export function useWorkflowStepsMapped() {
|
||||
const kanbanSteps = useAppStore((state) => state.kanban.steps);
|
||||
return useMemo(
|
||||
() =>
|
||||
kanbanSteps.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.title,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
events: s.events,
|
||||
allow_manual_move: s.allow_manual_move,
|
||||
prompt: s.prompt,
|
||||
is_start_step: s.is_start_step,
|
||||
agent_profile_id: s.agent_profile_id,
|
||||
})),
|
||||
[kanbanSteps],
|
||||
);
|
||||
export function useWorkflowStepsMapped(workflowId: string | null | undefined) {
|
||||
return useWorkflowStepsById(workflowId);
|
||||
}
|
||||
|
||||
function useTaskWorkflowSnapshot(task: Task | null) {
|
||||
useWorkflowSnapshotById(task?.workspace_id ?? null, task?.workflow_id ?? null);
|
||||
}
|
||||
|
||||
function getTaskArchivedState(task: Task | null): boolean | null {
|
||||
return task ? task.archived_at != null : null;
|
||||
}
|
||||
|
||||
export function useSessionPanelState(effectiveSessionId: string | null | undefined) {
|
||||
@@ -83,8 +77,10 @@ export function useSessionPanelState(effectiveSessionId: string | null | undefin
|
||||
const sessionWorkflowStepId = useAppStore((state) => {
|
||||
const taskId = state.tasks.activeTaskId;
|
||||
if (!taskId) return null;
|
||||
const task = state.kanban.tasks.find((t: { id: string }) => t.id === taskId);
|
||||
return (task?.workflowStepId as string) ?? null;
|
||||
return (
|
||||
resolveLatestTaskProjection(taskId, state.kanban.tasks, state.kanbanMulti.snapshots)
|
||||
?.workflowStepId ?? null
|
||||
);
|
||||
});
|
||||
const previewOpen = useAppStore((state) =>
|
||||
effectiveSessionId ? (state.previewPanel.openBySessionId[effectiveSessionId] ?? false) : false,
|
||||
@@ -187,18 +183,16 @@ export function TaskLoadErrorState() {
|
||||
);
|
||||
}
|
||||
|
||||
function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) {
|
||||
export function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) {
|
||||
const [taskDetails, setTaskDetails] = useState<Task | null>(initialTask);
|
||||
const [taskLoadError, setTaskLoadError] = useState<unknown | null>(null);
|
||||
const activeTaskIdRef = useRef(activeTaskId);
|
||||
const kanbanTask = useAppStore((state) =>
|
||||
activeTaskId
|
||||
? (state.kanban.tasks.find(
|
||||
(item: KanbanState["tasks"][number]) => item.id === activeTaskId,
|
||||
) ?? null)
|
||||
: null,
|
||||
);
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
const previousConnectionStatus = useRef(connectionStatus);
|
||||
const effectiveTaskId = activeTaskId ?? initialTask?.id ?? null;
|
||||
const kanbanTask = useAppStore((state) =>
|
||||
resolveLatestTaskProjection(effectiveTaskId, state.kanban.tasks, state.kanbanMulti.snapshots),
|
||||
);
|
||||
const task = useMemo(
|
||||
() => resolveEffectiveTask(taskDetails, initialTask, kanbanTask, effectiveTaskId),
|
||||
[taskDetails, initialTask, kanbanTask, effectiveTaskId],
|
||||
@@ -208,8 +202,6 @@ function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) {
|
||||
taskDetailsId: taskDetails?.id ?? null,
|
||||
initialTaskId: initialTask?.id ?? null,
|
||||
});
|
||||
useTasks(task?.workflow_id ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
activeTaskIdRef.current = activeTaskId;
|
||||
}, [activeTaskId]);
|
||||
@@ -238,6 +230,13 @@ function useTaskDetails(activeTaskId: string | null, initialTask: Task | null) {
|
||||
void loadTaskDetails();
|
||||
}, [activeTaskId, taskDetails?.id, loadTaskDetails]);
|
||||
|
||||
useEffect(() => {
|
||||
const reconnected =
|
||||
previousConnectionStatus.current !== "connected" && connectionStatus === "connected";
|
||||
previousConnectionStatus.current = connectionStatus;
|
||||
if (reconnected) void loadTaskDetails();
|
||||
}, [connectionStatus, loadTaskDetails]);
|
||||
|
||||
useForegroundRefresh(loadTaskDetails, Boolean(activeTaskId), activeTaskId);
|
||||
|
||||
const onTaskUnarchived = useCallback(
|
||||
@@ -360,16 +359,17 @@ function TaskPageContentLive({
|
||||
onTaskUnarchived,
|
||||
refreshTask,
|
||||
} = useTaskPageData(initialTask, initialTaskId, sessionId, initialRepositories);
|
||||
useTaskWorkflowSnapshot(task);
|
||||
const taskCanvasesState = useTaskCanvasesStateForTask(task, canvasesEnabled);
|
||||
useExternalVcsFileLinkHydration(task, repositories);
|
||||
|
||||
const workflowSteps = useWorkflowStepsMapped();
|
||||
const workflowSteps = useWorkflowStepsMapped(task?.workflow_id);
|
||||
const sessionPanel = useSessionPanelState(effectiveSessionId);
|
||||
const agentctlStatus = useSessionAgentctl(effectiveSessionId);
|
||||
const resumption = useSessionResumption(
|
||||
task?.id ?? null,
|
||||
effectiveSessionId,
|
||||
task ? task.archived_at != null : null,
|
||||
getTaskArchivedState(task),
|
||||
{ onTaskArchiveConflict: refreshTask },
|
||||
);
|
||||
const merged = useMergedAgentState(agent, resumption, sessionPanel, effectiveSessionId, task);
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
buildDebugEntries,
|
||||
buildArchivedValue,
|
||||
resolveTaskProps,
|
||||
resolveWorkflowCurrentStepId,
|
||||
useTaskActionsMenuBoardRow,
|
||||
selectWorkspaceRepositories,
|
||||
} from "@/components/task/task-page-content-helpers";
|
||||
@@ -105,12 +106,12 @@ function resolveRemoteExecutor(status?: RemoteExecutorStatus | null) {
|
||||
};
|
||||
}
|
||||
|
||||
// Prefer the session-level step (delivered direct via session.state_changed) over the task-level step (routed through the hub broadcast and slightly stale).
|
||||
function resolveCurrentStepId(
|
||||
sessionStepId: string | null,
|
||||
taskStepId: string | null,
|
||||
workflowStepIds: readonly string[],
|
||||
): string | null {
|
||||
return sessionStepId || taskStepId || null;
|
||||
return resolveWorkflowCurrentStepId(sessionStepId, taskStepId, workflowStepIds);
|
||||
}
|
||||
|
||||
function buildTaskTopBarProps(params: {
|
||||
@@ -137,7 +138,11 @@ function buildTaskTopBarProps(params: {
|
||||
showDebugOverlay,
|
||||
onToggleDebugOverlay,
|
||||
workflowSteps,
|
||||
currentStepId: resolveCurrentStepId(params.sessionWorkflowStepId, taskProps.workflowStepId),
|
||||
currentStepId: resolveCurrentStepId(
|
||||
params.sessionWorkflowStepId,
|
||||
taskProps.workflowStepId,
|
||||
workflowSteps.map((step) => step.id),
|
||||
),
|
||||
workflowId: taskProps.workflowId,
|
||||
taskState: params.task?.state ?? null,
|
||||
workspaceId: taskProps.workspaceId,
|
||||
|
||||
@@ -9,7 +9,38 @@ import {
|
||||
} from "./task-workflow-agent-overrides-helpers";
|
||||
|
||||
test.describe("Change workflow", () => {
|
||||
test("maps a task profile for a later destination step and preserves its context", async ({
|
||||
test("updates an open task page after a move from another client", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
const destination = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
"External move target",
|
||||
);
|
||||
const analysis = await apiClient.createWorkflowStep(destination.id, "Analysis", 0);
|
||||
await apiClient.createWorkflowStep(destination.id, "Implement", 1);
|
||||
const task = await apiClient.createTask(seedData.workspaceId, "External workflow move task", {
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
});
|
||||
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
await expect(testPage.getByTestId("task-topbar")).toBeVisible();
|
||||
const taskUrl = testPage.url();
|
||||
|
||||
await apiClient.moveTask(task.id, destination.id, analysis.id);
|
||||
|
||||
await expect(testPage).toHaveURL(taskUrl);
|
||||
const stepper = testPage.getByTestId("workflow-stepper");
|
||||
await expect(stepper.getByTestId("workflow-step-Analysis")).toHaveAttribute(
|
||||
"aria-current",
|
||||
"step",
|
||||
);
|
||||
await expect(stepper.getByTestId("workflow-step-Implement")).toBeVisible();
|
||||
});
|
||||
|
||||
test("updates the open task stepper after changing workflow and preserves its context", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
@@ -39,6 +70,13 @@ test.describe("Change workflow", () => {
|
||||
fixture.profileA.id,
|
||||
);
|
||||
const before = await apiClient.getTask(task.id);
|
||||
await testPage.goto(`/t/${task.id}`);
|
||||
await expect(testPage.getByTestId("task-topbar")).toBeVisible();
|
||||
const taskUrl = testPage.url();
|
||||
await testPage.getByTestId("task-topbar-actions-menu").click();
|
||||
await expect(testPage.getByRole("menuitem", { name: "Change workflow..." })).toBeVisible();
|
||||
await testPage.getByRole("menuitem", { name: "Change workflow..." }).click();
|
||||
|
||||
const previewChanges: Array<Record<string, unknown>> = [];
|
||||
testPage.on("request", (request) => {
|
||||
if (request.url().includes(`/api/v1/tasks/${task.id}/move-preview`)) {
|
||||
@@ -49,12 +87,6 @@ test.describe("Change workflow", () => {
|
||||
previewChanges.push(body.workflow_change as unknown as Record<string, unknown>);
|
||||
}
|
||||
});
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.openTaskActionsMenu(task.id);
|
||||
|
||||
await expect(testPage.getByRole("menuitem", { name: "Change workflow..." })).toBeVisible();
|
||||
await kanban.contextChangeWorkflow().click();
|
||||
const changeWorkflow = new ChangeWorkflowPage(testPage);
|
||||
await expect(changeWorkflow.desktopDialog).toBeVisible();
|
||||
await changeWorkflow.chooseWorkflow(fixture.workflow.id);
|
||||
@@ -95,6 +127,26 @@ test.describe("Change workflow", () => {
|
||||
await expect(changeWorkflow.desktopDialog).toBeHidden();
|
||||
|
||||
await waitForWorkflowStep(apiClient, task.id, fixture.prStep.id);
|
||||
await expect(testPage).toHaveURL(taskUrl);
|
||||
const stepper = testPage.getByTestId("workflow-stepper");
|
||||
await expect(stepper.getByTestId("workflow-step-Analysis")).toBeVisible();
|
||||
await expect(stepper.getByTestId("workflow-step-Implement")).toBeVisible();
|
||||
await expect(stepper.getByTestId("workflow-step-Review")).toBeVisible();
|
||||
await expect(stepper.getByTestId("workflow-step-PR")).toHaveAttribute("aria-current", "step");
|
||||
if (prCapture.capturing) {
|
||||
await testPage.evaluate(async () => {
|
||||
await Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.playState === "running")
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
await prCapture.screenshot("desktop-task-destination-stepper", {
|
||||
caption:
|
||||
"Open task page showing the destination workflow and current step after migration.",
|
||||
});
|
||||
}
|
||||
const destinationSessionId = await waitForNewWorkflowProfileSession(
|
||||
apiClient,
|
||||
task.id,
|
||||
|
||||
@@ -127,6 +127,27 @@ test("changes workflow from phone task actions with task-local agent routing", a
|
||||
originalSessionId,
|
||||
]);
|
||||
await waitForWorkflowMoveLifecycle(apiClient, task.id);
|
||||
await expect(actions.column(task.id)).toBeVisible();
|
||||
await actions.open(task.id);
|
||||
await actions.nested("Move to");
|
||||
const currentDestinationStep = testPage.getByTestId(`task-context-step-${fixture.prStep.id}`);
|
||||
await expect(currentDestinationStep).toBeVisible();
|
||||
await expect(currentDestinationStep).toBeDisabled();
|
||||
if (prCapture.capturing) {
|
||||
await testPage.evaluate(async () => {
|
||||
await Promise.all(
|
||||
document
|
||||
.getAnimations()
|
||||
.filter((animation) => animation.playState === "running")
|
||||
.map((animation) => animation.finished.catch(() => undefined)),
|
||||
);
|
||||
});
|
||||
await prCapture.screenshot("phone-task-destination-step", {
|
||||
caption:
|
||||
"Phone task actions showing the task in its destination workflow step after migration.",
|
||||
});
|
||||
}
|
||||
|
||||
const changed = await apiClient.getTask(task.id);
|
||||
expect(changed).toMatchObject({
|
||||
id: before.id,
|
||||
|
||||
@@ -7,18 +7,23 @@ const mockSetKanbanMultiLoading = vi.fn();
|
||||
const mockSetWorkflowSnapshot = vi.fn();
|
||||
const mockSetWorkspaceSnapshotRead = vi.fn();
|
||||
const mockFetchWorkflowSnapshot = vi.fn();
|
||||
const mockHydrate = vi.fn();
|
||||
|
||||
type Workflow = { id: string; workspaceId: string; name: string };
|
||||
const NEW_WORKFLOW_ID = "workflow-new";
|
||||
const NEW_WORKFLOW_NAME = "Created later";
|
||||
type MockState = {
|
||||
connection: { status: string };
|
||||
workspaces: { activeId: string | null };
|
||||
workspaceContextGeneration: number;
|
||||
workflows: { items: Workflow[] };
|
||||
kanban: { workflowId: string | null; steps: unknown[]; tasks: unknown[] };
|
||||
kanbanMulti: { snapshots: Record<string, unknown>; isLoading: boolean };
|
||||
clearKanbanMulti: typeof mockClearKanbanMulti;
|
||||
setKanbanMultiLoading: typeof mockSetKanbanMultiLoading;
|
||||
setWorkflowSnapshot: typeof mockSetWorkflowSnapshot;
|
||||
setWorkspaceSnapshotRead: typeof mockSetWorkspaceSnapshotRead;
|
||||
hydrate: typeof mockHydrate;
|
||||
};
|
||||
|
||||
let mockState: MockState = {
|
||||
@@ -26,11 +31,13 @@ let mockState: MockState = {
|
||||
workspaces: { activeId: "ws-A" },
|
||||
workspaceContextGeneration: 0,
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, steps: [], tasks: [] },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
clearKanbanMulti: mockClearKanbanMulti,
|
||||
setKanbanMultiLoading: mockSetKanbanMultiLoading,
|
||||
setWorkflowSnapshot: mockSetWorkflowSnapshot,
|
||||
setWorkspaceSnapshotRead: mockSetWorkspaceSnapshotRead,
|
||||
hydrate: mockHydrate,
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
@@ -42,7 +49,7 @@ vi.mock("@/lib/api", () => ({
|
||||
fetchWorkflowSnapshot: (...args: unknown[]) => mockFetchWorkflowSnapshot(...args),
|
||||
}));
|
||||
|
||||
import { useAllWorkflowSnapshots } from "./use-all-workflow-snapshots";
|
||||
import { useAllWorkflowSnapshots, useWorkflowSnapshotById } from "./use-all-workflow-snapshots";
|
||||
|
||||
function resetMocks(workflows: Workflow[] = []) {
|
||||
vi.clearAllMocks();
|
||||
@@ -52,11 +59,13 @@ function resetMocks(workflows: Workflow[] = []) {
|
||||
workspaces: { activeId: workflows[0]?.workspaceId ?? null },
|
||||
workspaceContextGeneration: 0,
|
||||
workflows: { items: workflows },
|
||||
kanban: { workflowId: null, steps: [], tasks: [] },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
clearKanbanMulti: mockClearKanbanMulti,
|
||||
setKanbanMultiLoading: mockSetKanbanMultiLoading,
|
||||
setWorkflowSnapshot: mockSetWorkflowSnapshot,
|
||||
setWorkspaceSnapshotRead: mockSetWorkspaceSnapshotRead,
|
||||
hydrate: mockHydrate,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -152,6 +161,95 @@ describe("useAllWorkflowSnapshots — workspace scoping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkflowSnapshotById", () => {
|
||||
beforeEach(() => {
|
||||
resetMocks([{ id: "workflow-catalog", workspaceId: "ws-A", name: "Catalog workflow" }]);
|
||||
});
|
||||
|
||||
it("loads steps for a workflow missing from the catalog without changing board selection", async () => {
|
||||
mockState.kanban = { workflowId: "workflow-active", steps: [], tasks: [] };
|
||||
mockFetchWorkflowSnapshot.mockResolvedValueOnce({
|
||||
workflow: { id: NEW_WORKFLOW_ID, name: NEW_WORKFLOW_NAME },
|
||||
steps: [{ id: "analysis", name: "Analysis", position: 0 }],
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
renderHook(() => useWorkflowSnapshotById("ws-A", NEW_WORKFLOW_ID));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockFetchWorkflowSnapshot).toHaveBeenCalledWith(NEW_WORKFLOW_ID, {
|
||||
cache: "no-store",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(mockSetWorkflowSnapshot).toHaveBeenCalledWith(
|
||||
NEW_WORKFLOW_ID,
|
||||
expect.objectContaining({
|
||||
workflowId: NEW_WORKFLOW_ID,
|
||||
workflowName: NEW_WORKFLOW_NAME,
|
||||
steps: [expect.objectContaining({ id: "analysis", title: "Analysis" })],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(mockState.kanban.workflowId).toBe("workflow-active");
|
||||
expect(mockHydrate).not.toHaveBeenCalled();
|
||||
expect(mockFetchWorkflowSnapshot).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries a failed placeholder fetch after the task page remounts", async () => {
|
||||
mockState.kanbanMulti.snapshots[NEW_WORKFLOW_ID] = {
|
||||
workflowId: NEW_WORKFLOW_ID,
|
||||
workflowName: NEW_WORKFLOW_NAME,
|
||||
steps: [],
|
||||
tasks: [],
|
||||
isPlaceholder: true,
|
||||
};
|
||||
mockSetWorkflowSnapshot.mockImplementationOnce((id: string, snapshot: unknown) => {
|
||||
mockState.kanbanMulti.snapshots[id] = snapshot;
|
||||
});
|
||||
mockFetchWorkflowSnapshot
|
||||
.mockRejectedValueOnce(new Error("temporary fetch failure"))
|
||||
.mockResolvedValueOnce({
|
||||
workflow: { id: NEW_WORKFLOW_ID, name: NEW_WORKFLOW_NAME },
|
||||
steps: [{ id: "analysis", name: "Analysis", position: 0 }],
|
||||
tasks: [],
|
||||
});
|
||||
|
||||
const firstMount = renderHook(() => useWorkflowSnapshotById("ws-A", NEW_WORKFLOW_ID));
|
||||
try {
|
||||
await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(mockSetWorkflowSnapshot).toHaveBeenCalledWith(
|
||||
NEW_WORKFLOW_ID,
|
||||
expect.objectContaining({ isPlaceholder: false, fetchFailed: true }),
|
||||
),
|
||||
);
|
||||
firstMount.unmount();
|
||||
|
||||
const secondMount = renderHook(() => useWorkflowSnapshotById("ws-A", NEW_WORKFLOW_ID));
|
||||
try {
|
||||
await waitFor(() => expect(mockFetchWorkflowSnapshot).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(mockSetWorkflowSnapshot).toHaveBeenCalledWith(
|
||||
NEW_WORKFLOW_ID,
|
||||
expect.objectContaining({
|
||||
workflowId: NEW_WORKFLOW_ID,
|
||||
steps: [expect.objectContaining({ id: "analysis", title: "Analysis" })],
|
||||
}),
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
secondMount.unmount();
|
||||
}
|
||||
} finally {
|
||||
firstMount.unmount();
|
||||
mockFetchWorkflowSnapshot.mockReset();
|
||||
mockFetchWorkflowSnapshot.mockResolvedValue({ steps: [], tasks: [] });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAllWorkflowSnapshots — fetch guards", () => {
|
||||
beforeEach(() => {
|
||||
resetMocks([{ id: "wf-A", workspaceId: "ws-A", name: "A" }]);
|
||||
|
||||
@@ -24,7 +24,7 @@ type Workflow = { id: string; name: string };
|
||||
type WorkspaceContextRequest = { workspaceId: string; generation: number };
|
||||
|
||||
function isBootHydratedSnapshot(snapshot: WorkflowSnapshotData | undefined): boolean {
|
||||
return !!snapshot && snapshot.isPlaceholder !== true;
|
||||
return !!snapshot && snapshot.isPlaceholder !== true && snapshot.fetchFailed !== true;
|
||||
}
|
||||
|
||||
function hasNewerLivePlacement(existing: KanbanTask, fetchStart: KanbanTask | undefined): boolean {
|
||||
@@ -273,7 +273,7 @@ async function fetchAndWriteSnapshot(
|
||||
|
||||
const workflowSnapshot = {
|
||||
workflowId: wf.id,
|
||||
workflowName: wf.name,
|
||||
workflowName: snapshot.workflow?.name ?? wf.name,
|
||||
steps,
|
||||
tasks,
|
||||
};
|
||||
@@ -303,11 +303,15 @@ async function fetchAndWriteSnapshot(
|
||||
}
|
||||
markFailed(wf.id, err);
|
||||
// A failed fetch must not leave a placeholder permanently "unknown": that
|
||||
// would block the final-step ensure forever. Transition to an explicit
|
||||
// known-but-empty state so the ensure proceeds ungated (safe default).
|
||||
// would block the final-step ensure forever. Keep it known-but-empty for
|
||||
// now, and mark it retryable for a later task-page mount.
|
||||
const current = store.getState().kanbanMulti.snapshots[wf.id];
|
||||
if (current?.isPlaceholder) {
|
||||
store.getState().setWorkflowSnapshot(wf.id, { ...current, isPlaceholder: false });
|
||||
store.getState().setWorkflowSnapshot(wf.id, {
|
||||
...current,
|
||||
isPlaceholder: false,
|
||||
fetchFailed: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,6 +325,74 @@ function mapSnapshotTask(task: Task, stepIds: Set<string>): KanbanTask | null {
|
||||
return toKanbanTask(task);
|
||||
}
|
||||
|
||||
export function useWorkflowSnapshotById(
|
||||
workspaceId: string | null,
|
||||
workflowId: string | null | undefined,
|
||||
) {
|
||||
const store = useAppStoreApi();
|
||||
const connectionStatus = useAppStore((state) => state.connection.status);
|
||||
const workspaceContextGeneration = useAppStore((state) => state.workspaceContextGeneration);
|
||||
const workspaceContextRetryVersion = useAppStore(
|
||||
(state) => state.workspaceContextRead?.retryVersion ?? 0,
|
||||
);
|
||||
const [refreshNonce, setRefreshNonce] = useState(0);
|
||||
const fetchGenRef = useRef(0);
|
||||
const lastRefreshNonceRef = useRef(refreshNonce);
|
||||
const lastConnectionStatusRef = useRef(connectionStatus);
|
||||
const lastContextGenerationRef = useRef(workspaceContextGeneration);
|
||||
const lastRecoveryRetryVersionRef = useRef(workspaceContextRetryVersion);
|
||||
const refresh = useCallback(() => setRefreshNonce((current) => current + 1), []);
|
||||
|
||||
useForegroundRefresh(refresh, Boolean(workspaceId && workflowId), `${workspaceId}:${workflowId}`);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId || !workflowId) return;
|
||||
|
||||
const forceRefresh =
|
||||
refreshNonce !== lastRefreshNonceRef.current ||
|
||||
connectionStatus !== lastConnectionStatusRef.current ||
|
||||
workspaceContextGeneration !== lastContextGenerationRef.current ||
|
||||
workspaceContextRetryVersion !== lastRecoveryRetryVersionRef.current;
|
||||
lastRefreshNonceRef.current = refreshNonce;
|
||||
lastConnectionStatusRef.current = connectionStatus;
|
||||
lastContextGenerationRef.current = workspaceContextGeneration;
|
||||
lastRecoveryRetryVersionRef.current = workspaceContextRetryVersion;
|
||||
|
||||
const state = store.getState();
|
||||
const currentSnapshot = state.kanbanMulti.snapshots[workflowId];
|
||||
const activeWorkflowHasSteps =
|
||||
state.kanban.workflowId === workflowId && state.kanban.steps.length > 0;
|
||||
if (!forceRefresh && (isBootHydratedSnapshot(currentSnapshot) || activeWorkflowHasSteps)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const myGen = fetchGenRef.current + 1;
|
||||
fetchGenRef.current = myGen;
|
||||
const request = { workspaceId, generation: state.workspaceContextGeneration };
|
||||
void fetchAndWriteSnapshot(
|
||||
{ id: workflowId, name: currentSnapshot?.workflowName ?? workflowId },
|
||||
store,
|
||||
fetchGenRef,
|
||||
myGen,
|
||||
request,
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (fetchGenRef.current === myGen) fetchGenRef.current += 1;
|
||||
};
|
||||
}, [
|
||||
connectionStatus,
|
||||
refreshNonce,
|
||||
store,
|
||||
workspaceContextGeneration,
|
||||
workspaceContextRetryVersion,
|
||||
workflowId,
|
||||
workspaceId,
|
||||
]);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- one hook owns snapshot refresh and generation cleanup
|
||||
export function useAllWorkflowSnapshots(workspaceId: string | null) {
|
||||
const store = useAppStoreApi();
|
||||
|
||||
@@ -68,6 +68,42 @@ describe("useWorkflowStepsById", () => {
|
||||
expect(result.current[0]).toMatchObject({ id: "x", name: "Triage" });
|
||||
});
|
||||
|
||||
it("updates ordered steps when a placeholder destination snapshot loads", () => {
|
||||
mockState = {
|
||||
kanban: {
|
||||
workflowId: ACTIVE_WORKFLOW_ID,
|
||||
steps: [{ id: "source", title: "Review", color: "#111", position: 0 }],
|
||||
},
|
||||
kanbanMulti: { snapshots: { "workflow-other": { steps: [] } } },
|
||||
};
|
||||
const { result, rerender } = renderHook(
|
||||
({ workflowId }: { workflowId: string }) => useWorkflowStepsById(workflowId),
|
||||
{ initialProps: { workflowId: "workflow-other" } },
|
||||
);
|
||||
expect(result.current).toEqual([]);
|
||||
|
||||
mockState = {
|
||||
kanban: {
|
||||
workflowId: ACTIVE_WORKFLOW_ID,
|
||||
steps: [{ id: "source", title: "Review", color: "#111", position: 0 }],
|
||||
},
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
"workflow-other": {
|
||||
steps: [
|
||||
{ id: "later", title: "Implement", color: "#222", position: 1 },
|
||||
{ id: "current", title: "Analysis", color: "#333", position: 0 },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
rerender({ workflowId: "workflow-other" });
|
||||
|
||||
expect(result.current.map((step) => step.id)).toEqual(["current", "later"]);
|
||||
expect(result.current.map((step) => step.name)).toEqual(["Analysis", "Implement"]);
|
||||
});
|
||||
|
||||
it("returns an empty list when neither the active workflow nor a snapshot resolves it", () => {
|
||||
mockState = {
|
||||
kanban: { workflowId: ACTIVE_WORKFLOW_ID, steps: [] },
|
||||
|
||||
@@ -26,9 +26,9 @@ function mapStep(step: StoreStep): WorkflowStepperStep {
|
||||
* Resolves the ordered step list for a workflow that is not necessarily the
|
||||
* board's active workflow: the previewed task's own workflow, per
|
||||
* `plugin-context-api.ts`'s rule. `kanban.steps` covers the active workflow;
|
||||
* `kanbanMulti.snapshots` (populated by `useAllWorkflowSnapshots` for every
|
||||
* workflow in the workspace) covers every other one, including a task on a
|
||||
* workflow the board is not currently filtered to.
|
||||
* `kanbanMulti.snapshots` covers other workflows. The workspace-wide board
|
||||
* uses `useAllWorkflowSnapshots`, while task pages fetch only their own
|
||||
* workflow through `useWorkflowSnapshotById`.
|
||||
*/
|
||||
export function useWorkflowStepsById(workflowId: string | null | undefined): WorkflowStepperStep[] {
|
||||
const activeWorkflowId = useAppStore((state) => state.kanban.workflowId);
|
||||
|
||||
@@ -219,6 +219,8 @@ export type WorkflowSnapshotData = {
|
||||
steps: KanbanState["steps"];
|
||||
tasks: KanbanState["tasks"];
|
||||
isPlaceholder?: boolean;
|
||||
/** A known-empty failed fetch is retryable after a task-page remount. */
|
||||
fetchFailed?: boolean;
|
||||
};
|
||||
|
||||
export type KanbanMultiState = {
|
||||
|
||||
Reference in New Issue
Block a user