fix(tasks): refresh top bar after workflow migration (#3927)

This commit is contained in:
Carlos Florêncio
2026-09-25 15:54:32 +01:00
committed by GitHub
parent 8d5388a1f0
commit 852a867fd5
16 changed files with 1081 additions and 70 deletions
@@ -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,
+33 -33
View File
@@ -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);
+8 -3
View File
@@ -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 = {