fix(web): keep "All Workflows" filter on task nav and workflow.created (#854)
This commit is contained in:
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user