fix(web): keep "All Workflows" filter on task nav and workflow.created (#854)

This commit is contained in:
Carlos Florêncio
2026-05-08 21:23:48 +01:00
committed by GitHub
parent dfcaa24be3
commit 9664e13f07
9 changed files with 96 additions and 16 deletions
@@ -198,7 +198,8 @@ const tabComponents: Record<string, React.FunctionComponent<IDockviewPanelHeader
function SidebarContent({ panelId }: { panelId: string }) {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const workflowId = useAppStore((state) => state.workflows.activeId);
// Read kanban.workflowId (task snapshot), not workflows.activeId (homepage filter), to preserve "All Workflows" across task navigation.
const workflowId = useAppStore((state) => state.kanban.workflowId);
const workspaceName = useAppStore((state) => {
const ws = state.workspaces.items.find((w: { id: string }) => w.id === workspaceId);
return ws?.name ?? "Workspace";
@@ -251,8 +251,9 @@ export function RightHeaderActions(props: IDockviewHeaderActionsProps) {
function SidebarRightActions() {
const workspaceId = useAppStore((state) => state.workspaces.activeId);
const workflowId = useAppStore((state) => state.workflows.activeId);
const kanban = useAppStore((state) => state.kanban);
// Use kanban.workflowId (task context) not workflows.activeId so "All Workflows" isn't clobbered when viewing a task.
const workflowId = kanban.workflowId;
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
const activeTaskTitle = useAppStore((state) => {
const id = state.tasks.activeTaskId;
@@ -199,6 +199,7 @@ function TopBarLeft({
<Link
href="/"
className="cursor-pointer text-muted-foreground hover:text-foreground transition-colors"
data-testid="task-breadcrumb-home"
>
<IconHome className="h-4 w-4" />
</Link>
@@ -33,6 +33,7 @@ async function selectWorkflowFilter(page: Page, optionLabel: string): Promise<vo
test.describe("Kanban workflow filter", () => {
let workflowBId: string | null = null;
let betaTaskId: string | null = null;
// Pull `testPage` so its fixture (which runs `e2eReset` and resets user
// settings) is set up before this hook seeds workflows/tasks — otherwise
@@ -48,10 +49,11 @@ test.describe("Kanban workflow filter", () => {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await apiClient.createTask(seedData.workspaceId, BETA_TASK, {
const beta = await apiClient.createTask(seedData.workspaceId, BETA_TASK, {
workflow_id: workflowB.id,
workflow_step_id: startB.id,
});
betaTaskId = beta.id;
});
test.afterEach(async ({ apiClient, seedData }) => {
@@ -59,6 +61,7 @@ test.describe("Kanban workflow filter", () => {
await apiClient.deleteWorkflow(workflowBId).catch(() => {});
workflowBId = null;
}
betaTaskId = null;
await apiClient.saveUserSettings({
workspace_id: seedData.workspaceId,
workflow_filter_id: seedData.workflowId,
@@ -118,4 +121,39 @@ test.describe("Kanban workflow filter", () => {
timeout: TASK_VISIBLE_TIMEOUT,
});
});
// Regression: SSR wrote workflows.activeId from the task's workflow_id, clobbering the "All Workflows" filter on return. Pins the cross-page flow.
test("'All Workflows' selection survives navigating into a task and back", async ({
testPage,
}) => {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await selectWorkflowFilter(testPage, "All Workflows");
await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({
timeout: TASK_VISIBLE_TIMEOUT,
});
await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({
timeout: TASK_VISIBLE_TIMEOUT,
});
// Visit a task that belongs to Workflow B — the SSR build path that used
// to poison the global filter.
if (!betaTaskId) throw new Error("beta task was not seeded");
await testPage.goto(`/t/${betaTaskId}`);
await expect(testPage).toHaveURL(new RegExp(`/t/${betaTaskId}`));
// Breadcrumb = client-side nav: goto("/") re-runs SSR and re-resolves activeId, masking the bug.
await testPage.getByTestId("task-breadcrumb-home").click();
await expect(testPage).toHaveURL(/\/$|\?/);
await expect(kanban.board).toBeVisible();
await expect(kanban.taskCardByTitle(ALPHA_TASK)).toBeVisible({
timeout: TASK_VISIBLE_TIMEOUT,
});
await expect(kanban.taskCardByTitle(BETA_TASK)).toBeVisible({
timeout: TASK_VISIBLE_TIMEOUT,
});
});
});
+2 -1
View File
@@ -30,5 +30,6 @@ export function resolveDesiredWorkflowId({
) {
return settingsWorkflowId;
}
return visibleWorkflows.length === 1 ? visibleWorkflows[0].id : null;
if (visibleWorkflows.length === 1) return visibleWorkflows[0].id;
return null;
}
+3 -2
View File
@@ -22,6 +22,7 @@ import type {
import type { Terminal } from "@/hooks/domains/session/use-terminals";
import { snapshotToState, taskToState } from "@/lib/ssr/mapper";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { AppState } from "@/lib/state/store";
function buildWorktreeState(allSessions: TaskSession[]) {
const sessionsWithWorktrees = allSessions.filter((s) => s.worktree_id);
@@ -104,6 +105,7 @@ function buildResourceState(p: BuildSessionPageStateParams) {
})),
activeId: task.workspace_id,
},
// Don't write activeId — null means "All Workflows"; task context lives in kanban.workflowId.
workflows: {
items: workflows.map((w) => ({
id: w.id,
@@ -111,8 +113,7 @@ function buildResourceState(p: BuildSessionPageStateParams) {
name: w.name,
hidden: w.hidden,
})),
activeId: task.workflow_id,
},
} as Partial<AppState>["workflows"],
repositories: {
itemsByWorkspaceId: { [task.workspace_id]: repositories },
loadingByWorkspaceId: { [task.workspace_id]: false },
@@ -13,16 +13,13 @@ export const createKanbanSlice: StateCreator<
[["zustand/immer", never]],
[],
KanbanSlice
> = (set, get) => ({
> = (set) => ({
...defaultKanbanState,
setActiveWorkflow: (workflowId) => {
if (get().workflows.activeId === workflowId) {
return;
}
setActiveWorkflow: (workflowId) =>
set((draft) => {
if (draft.workflows.activeId === workflowId) return;
draft.workflows.activeId = workflowId;
});
},
}),
setWorkflows: (workflows) =>
set((draft) => {
draft.workflows.items = workflows;
@@ -35,6 +35,47 @@ function updatedMessage(payload: WorkflowPayload): BackendMessageMap["workflow.u
};
}
function createdMessage(payload: WorkflowPayload): BackendMessageMap["workflow.created"] {
return {
id: "msg-1",
type: "notification",
action: "workflow.created",
payload,
timestamp: "2026-01-01T00:00:00Z",
};
}
describe("workflow.created handler — preserves user filter", () => {
it("does not promote a new workflow when activeId is null ('All Workflows')", () => {
const store = makeStore(
[{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }],
null,
);
const handlers = registerWorkflowsHandlers(store);
handlers["workflow.created"]?.(
createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }),
);
expect(store.getState().workflows.activeId).toBeNull();
expect(store.getState().workflows.items.map((i) => i.id)).toEqual(["wf-2", "wf-1"]);
});
it("leaves an existing activeId untouched when a new workflow appears", () => {
const store = makeStore(
[{ id: "wf-1", workspaceId: "ws-1", name: "Existing", hidden: false }],
"wf-1",
);
const handlers = registerWorkflowsHandlers(store);
handlers["workflow.created"]?.(
createdMessage({ id: "wf-2", workspace_id: "ws-1", name: "Brand New" }),
);
expect(store.getState().workflows.activeId).toBe("wf-1");
});
});
describe("workflow.updated handler — hidden flag reconciles activeId", () => {
it("clears activeId to next visible workflow when active becomes hidden", () => {
const store = makeStore(
+2 -3
View File
@@ -23,6 +23,7 @@ function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppSta
if (state.workspaces.activeId !== payload.workspace_id) return state;
if (state.workflows.items.some((item) => item.id === payload.id)) return state;
const isHidden = Boolean(payload.hidden);
// Never use `??` here: null is a valid "All Workflows" selection, not a missing value.
return {
...state,
workflows: {
@@ -35,9 +36,7 @@ function applyWorkflowCreated(state: AppState, payload: WorkflowPayload): AppSta
},
...state.workflows.items,
],
// Hidden workflows must never be promoted to the active selection;
// they are system-only and would surface in the workflow picker.
activeId: state.workflows.activeId ?? (isHidden ? null : payload.id),
activeId: state.workflows.activeId,
},
};
}