fix(web): show tasks from all workflows in the mobile task-switcher sheet (#1025)
* fix(web): show tasks from all workflows in the mobile task-switcher sheet The mobile sheet read tasks via useTasks(workflowId), which gated on kanban.workflowId === workflowId and returned [] otherwise. On a fresh /t/[id] load the gate often failed (or the snapshot fetch was still in flight / silently failing) and the sheet rendered "No tasks yet." Align mobile with desktop: read from kanbanMulti.snapshots via a new shared useWorkspaceSidebarTasks hook that does the per-workspace aggregation (incl. the active-kanban fallback), and pass workflows + stepsByWorkflowId through so the existing TaskSwitcher grouping kicks in. The desktop sidebar now delegates to the same hook. * refactor(web): address review comments on PR #1025 - Extract findTaskInSnapshots to task-select-helpers.ts (shared between desktop sidebar and mobile sheet). Mobile passes kanban.tasks as the optional fallback so it still locates tasks that arrived via WS before the snapshot fetch landed. - Build workflowNameById from the already-scoped `workflows` array instead of the raw, unscoped kanbanMulti.snapshots. Stops the memo from re-running on snapshot updates from other workspaces and removes the risk of a stale workflow name leaking across a workspace switch. Same fix in both the desktop sidebar and the mobile sheet. - Collapse the duplicated workflow filter in useWorkspaceSidebarTasks into a single `filteredWorkflows` memo. * fix(web): empty scope when workspaceId is unresolved in mobile/desktop sidebar hook When `useWorkspaceSidebarTasks` was called with a null workspaceId (the initial SSR / pre-hydration state on /t/[id]) it would widen to every workflow in the store, briefly leaking snapshots from previously-active workspaces into the sidebar. Return an empty scope until the workspace resolves instead, matching the "no cross-workspace bleed" intent of the original useScopedAggregation comment. Adds a regression test, plus splits the test file's describe block so the linter's 100-lines-per-function cap doesn't trip. * refactor(web): consolidate findTaskInSnapshots and drop unused useSheetData.steps - Reuse the canonical helper in `lib/kanban/find-task.ts` instead of duplicating it in `task-select-helpers.ts`. Extend the canonical one with an optional `fallbackTasks` parameter so the mobile sheet can still locate tasks via `state.kanban.tasks` when the snapshot fetch hasn't landed yet — desktop call sites continue to omit the fallback. - `useSheetData` was returning the raw active-kanban `steps` slice next to the already-mapped `dialogSteps`. The sheet component only consumes `dialogSteps`; drop the unused field. * test(web): add unit tests for findTaskInSnapshots fallback path * fix(web): pass active-kanban fallback into desktop sidebar task lookups useWorkspaceSidebarTasks can render tasks from the active kanban slice before kanbanMulti.snapshots catches up, but the desktop sidebar's archive/delete/select handlers were searching snapshots only — leaving fallback-only tasks unresolved during the snapshot race window. Pass state.kanban.tasks into findTaskInSnapshots in all three handlers so the dialogs find the right title and selectTaskWithLayout sees the task. Also trims the multi-line comment in lib/kanban/find-task.ts to a single line per CLAUDE.md style guidance. --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -6,7 +6,7 @@ import { replaceTaskUrl } from "@/lib/links";
|
||||
import { fetchWorkflowSnapshot, listWorkflows } from "@/lib/api";
|
||||
import { launchSession } from "@/lib/services/session-launch-service";
|
||||
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { getSessionInfoForTask } from "@/lib/utils/session-info";
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type WorkflowSnapshot,
|
||||
} from "@/lib/types/http";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import { resolvePreferredSessionId } from "../task-select-helpers";
|
||||
|
||||
// Map workflow snapshot to kanban state on workspace switch.
|
||||
@@ -84,7 +85,58 @@ function sortByUpdatedAtDesc<T extends { updated_at?: string | null }>(items: T[
|
||||
});
|
||||
}
|
||||
|
||||
export function useSheetData(workspaceId: string | null, workflowId: string | null) {
|
||||
type SheetItemCtx = {
|
||||
repositoryPathsById: Map<string, string | undefined>;
|
||||
workflowNameById: Map<string, string>;
|
||||
stepTitleById: Map<string, string>;
|
||||
sessionsByTaskId: Parameters<typeof getSessionInfoForTask>[1];
|
||||
gitStatusByEnvId: Parameters<typeof getSessionInfoForTask>[2];
|
||||
envIdBySessionId: Parameters<typeof getSessionInfoForTask>[3];
|
||||
messagesBySession: Parameters<typeof hasPendingClarificationForSession>[0];
|
||||
};
|
||||
|
||||
function toSheetItem(
|
||||
task: KanbanState["tasks"][number] & { _workflowId: string },
|
||||
ctx: SheetItemCtx,
|
||||
) {
|
||||
const sessionInfo = getSessionInfoForTask(
|
||||
task.id,
|
||||
ctx.sessionsByTaskId,
|
||||
ctx.gitStatusByEnvId,
|
||||
ctx.envIdBySessionId,
|
||||
);
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
state: task.state as TaskState | undefined,
|
||||
sessionState:
|
||||
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined),
|
||||
description: task.description,
|
||||
workflowId: task._workflowId,
|
||||
workflowName: ctx.workflowNameById.get(task._workflowId),
|
||||
workflowStepId: task.workflowStepId,
|
||||
workflowStepTitle: ctx.stepTitleById.get(task.workflowStepId),
|
||||
repositoryPath: task.repositoryId
|
||||
? ctx.repositoryPathsById.get(toRepositoryId(task.repositoryId))
|
||||
: undefined,
|
||||
diffStats: sessionInfo.diffStats,
|
||||
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
|
||||
isRemoteExecutor: task.isRemoteExecutor,
|
||||
remoteExecutorType: task.primaryExecutorType ?? undefined,
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
primarySessionId: task.primarySessionId ?? null,
|
||||
hasPendingClarification: hasPendingClarificationForSession(
|
||||
ctx.messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
hasPendingPermission: hasPendingPermissionForSession(
|
||||
ctx.messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useSheetData(workspaceId: string | null) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
const sessionsById = useAppStore((state) => state.taskSessions.items);
|
||||
@@ -92,7 +144,13 @@ export function useSheetData(workspaceId: string | null, workflowId: string | nu
|
||||
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
|
||||
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
||||
const messagesBySession = useAppStore((state) => state.messages.bySession);
|
||||
const { tasks, isLoading: tasksLoading } = useTasks(workflowId);
|
||||
const {
|
||||
allTasks,
|
||||
allSteps,
|
||||
stepsByWorkflowId,
|
||||
workflows,
|
||||
isLoading: tasksLoading,
|
||||
} = useWorkspaceSidebarTasks(workspaceId);
|
||||
const steps = useAppStore((state) => state.kanban.steps);
|
||||
const workspaces = useAppStore((state) => state.workspaces.items);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
@@ -104,46 +162,23 @@ export function useSheetData(workspaceId: string | null, workflowId: string | nu
|
||||
|
||||
const tasksWithRepositories = useMemo(() => {
|
||||
const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : [];
|
||||
const repositoryPathsById = new Map(
|
||||
repositories.map((repo: Repository) => [repo.id, repo.local_path]),
|
||||
);
|
||||
return tasks.map((task: KanbanState["tasks"][number]) => {
|
||||
const sessionInfo = getSessionInfoForTask(
|
||||
task.id,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
);
|
||||
return {
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
state: task.state as TaskState | undefined,
|
||||
sessionState:
|
||||
sessionInfo.sessionState ?? (task.primarySessionState as TaskSessionState | undefined),
|
||||
description: task.description,
|
||||
workflowStepId: task.workflowStepId,
|
||||
repositoryPath: task.repositoryId
|
||||
? repositoryPathsById.get(toRepositoryId(task.repositoryId))
|
||||
: undefined,
|
||||
diffStats: sessionInfo.diffStats,
|
||||
updatedAt: sessionInfo.updatedAt ?? task.updatedAt,
|
||||
isRemoteExecutor: task.isRemoteExecutor,
|
||||
remoteExecutorType: task.primaryExecutorType ?? undefined,
|
||||
remoteExecutorName: task.primaryExecutorName ?? undefined,
|
||||
primarySessionId: task.primarySessionId ?? null,
|
||||
hasPendingClarification: hasPendingClarificationForSession(
|
||||
messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
hasPendingPermission: hasPendingPermissionForSession(
|
||||
messagesBySession,
|
||||
task.primarySessionId,
|
||||
),
|
||||
};
|
||||
});
|
||||
const ctx: SheetItemCtx = {
|
||||
repositoryPathsById: new Map(
|
||||
repositories.map((repo: Repository) => [repo.id, repo.local_path]),
|
||||
),
|
||||
workflowNameById: new Map(workflows.map((w) => [w.id, w.name])),
|
||||
stepTitleById: new Map(allSteps.map((s) => [s.id, s.title])),
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
envIdBySessionId,
|
||||
messagesBySession,
|
||||
};
|
||||
return allTasks.map((task) => toSheetItem(task, ctx));
|
||||
}, [
|
||||
repositoriesByWorkspace,
|
||||
tasks,
|
||||
allTasks,
|
||||
allSteps,
|
||||
workflows,
|
||||
workspaceId,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
@@ -165,9 +200,10 @@ export function useSheetData(workspaceId: string | null, workflowId: string | nu
|
||||
return {
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
steps,
|
||||
workspaces,
|
||||
// Skeleton while snapshot hydrates kanban — otherwise shows "No tasks yet." even when tasks exist.
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
// Skeleton while the first snapshot fetch is in flight — otherwise shows "No tasks yet." even when tasks exist.
|
||||
tasksLoading,
|
||||
tasksWithRepositories,
|
||||
dialogSteps,
|
||||
@@ -407,7 +443,8 @@ function useSheetDeleteActions(
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = store.getState().kanban.tasks.find((t) => t.id === taskId);
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
setDeletingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[store],
|
||||
@@ -458,7 +495,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
|
||||
const handleSelectTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const state = store.getState();
|
||||
const task = state.kanban.tasks.find((t) => t.id === taskId);
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
if (task?.primarySessionId) {
|
||||
const targetSessionId = resolvePreferredSessionId(
|
||||
taskId,
|
||||
@@ -487,7 +524,8 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = store.getState().kanban.tasks.find((t) => t.id === taskId);
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[store],
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { TaskSwitcher } from "../task-switcher";
|
||||
import type { TaskSwitcherItem } from "../task-switcher";
|
||||
import type { StepDef } from "../task-switcher-context-menu";
|
||||
import type { TaskMoveWorkflow } from "../task-move-context-menu";
|
||||
import { applyView } from "@/lib/sidebar/apply-view";
|
||||
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
|
||||
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
|
||||
@@ -24,6 +26,8 @@ type SessionTaskSwitcherSheetProps = {
|
||||
|
||||
function MobileTaskList({
|
||||
tasks,
|
||||
workflows,
|
||||
stepsByWorkflowId,
|
||||
activeTaskId,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
@@ -33,6 +37,8 @@ function MobileTaskList({
|
||||
isLoading,
|
||||
}: {
|
||||
tasks: TaskSwitcherItem[];
|
||||
workflows: TaskMoveWorkflow[];
|
||||
stepsByWorkflowId: Record<string, StepDef[]>;
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
@@ -62,6 +68,8 @@ function MobileTaskList({
|
||||
return (
|
||||
<TaskSwitcher
|
||||
grouped={grouped}
|
||||
workflows={workflows}
|
||||
stepsByWorkflowId={stepsByWorkflowId}
|
||||
activeTaskId={activeTaskId}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
@@ -85,7 +93,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
workflowId,
|
||||
}: SessionTaskSwitcherSheetProps) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const data = useSheetData(workspaceId, workflowId);
|
||||
const data = useSheetData(workspaceId);
|
||||
const actions = useSheetActions(workspaceId, onOpenChange);
|
||||
|
||||
return (
|
||||
@@ -120,6 +128,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2">
|
||||
<MobileTaskList
|
||||
tasks={data.tasksWithRepositories}
|
||||
workflows={data.workflows}
|
||||
stepsByWorkflowId={data.stepsByWorkflowId}
|
||||
activeTaskId={data.activeTaskId}
|
||||
selectedTaskId={data.selectedTaskId}
|
||||
onSelectTask={actions.handleSelectTask}
|
||||
|
||||
@@ -18,11 +18,12 @@ import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
|
||||
import { SidebarDialogs } from "./task-session-sidebar-dialogs";
|
||||
import { PanelRoot, PanelBody } from "./panel-primitives";
|
||||
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
|
||||
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
||||
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
|
||||
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
|
||||
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
|
||||
import { buildSwitchToSession, selectTaskWithLayout } from "./task-select-helpers";
|
||||
import { getSessionInfoForTask } from "@/lib/utils/session-info";
|
||||
import { getWebSocketClient } from "@/lib/ws/connection";
|
||||
@@ -33,7 +34,6 @@ import {
|
||||
hasPendingClarificationForSession,
|
||||
hasPendingPermissionForSession,
|
||||
} from "@/lib/utils/pending-clarification";
|
||||
import { aggregateSidebarTasks } from "./task-session-sidebar-aggregate";
|
||||
|
||||
/**
|
||||
* Stabilize a derived array of primary session IDs so the reference only
|
||||
@@ -55,18 +55,6 @@ function useStablePrimarySessionIds(
|
||||
return useMemo(() => (key ? key.split("\0") : []), [key]);
|
||||
}
|
||||
|
||||
/** Find a task across all workflow snapshots */
|
||||
function findTaskInSnapshots(
|
||||
snapshots: Record<string, { tasks: KanbanState["tasks"] }>,
|
||||
taskId: string,
|
||||
): KanbanState["tasks"][number] | undefined {
|
||||
for (const snapshot of Object.values(snapshots)) {
|
||||
const task = snapshot.tasks.find((t: KanbanState["tasks"][number]) => t.id === taskId);
|
||||
if (task) return task;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Look up git status directly via primarySessionId, bypassing the session list. */
|
||||
function getGitStatusForTask(
|
||||
task: { primarySessionId?: string | null },
|
||||
@@ -223,51 +211,6 @@ function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarI
|
||||
};
|
||||
}
|
||||
|
||||
// Restrict snapshots and the active-kanban fallback to the current workspace.
|
||||
// Snapshots can briefly persist from a previously-active workspace (SSR
|
||||
// hydration on first sidebar mount, in-flight task.created WS events that
|
||||
// raced the workspace switch, etc.), and those would otherwise leak into
|
||||
// the sidebar after switching workspaces.
|
||||
function useScopedAggregation(workspaceId: string | null) {
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const activeKanbanWorkflowId = useAppStore((state) => state.kanban.workflowId);
|
||||
const activeKanbanTasks = useAppStore((state) => state.kanban.tasks);
|
||||
const activeKanbanSteps = useAppStore((state) => state.kanban.steps);
|
||||
|
||||
const workspaceWorkflowIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
workflows.filter((w) => !workspaceId || w.workspaceId === workspaceId).map((w) => w.id),
|
||||
),
|
||||
[workflows, workspaceId],
|
||||
);
|
||||
const scopedSnapshots = useMemo(() => {
|
||||
const result: typeof snapshots = {};
|
||||
for (const [wfId, snap] of Object.entries(snapshots)) {
|
||||
if (workspaceWorkflowIds.has(wfId)) result[wfId] = snap;
|
||||
}
|
||||
return result;
|
||||
}, [snapshots, workspaceWorkflowIds]);
|
||||
const fallbackWorkflowId =
|
||||
activeKanbanWorkflowId && workspaceWorkflowIds.has(activeKanbanWorkflowId)
|
||||
? activeKanbanWorkflowId
|
||||
: null;
|
||||
|
||||
const aggregated = useMemo(
|
||||
() =>
|
||||
aggregateSidebarTasks(
|
||||
scopedSnapshots,
|
||||
fallbackWorkflowId,
|
||||
activeKanbanTasks,
|
||||
activeKanbanSteps,
|
||||
),
|
||||
[scopedSnapshots, fallbackWorkflowId, activeKanbanTasks, activeKanbanSteps],
|
||||
);
|
||||
|
||||
return { aggregated, scopedSnapshots, snapshots };
|
||||
}
|
||||
|
||||
function useSidebarData(workspaceId: string | null) {
|
||||
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
||||
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
||||
@@ -275,8 +218,6 @@ function useSidebarData(workspaceId: string | null) {
|
||||
const sessionsByTaskId = useAppStore((state) => state.taskSessionsByTask.itemsByTaskId);
|
||||
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
|
||||
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
|
||||
const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId);
|
||||
const messagesBySession = useAppStore((state) => state.messages.bySession);
|
||||
@@ -287,10 +228,13 @@ function useSidebarData(workspaceId: string | null) {
|
||||
return activeTaskId;
|
||||
}, [activeSessionId, activeTaskId, sessionsById]);
|
||||
|
||||
const { aggregated, scopedSnapshots, snapshots } = useScopedAggregation(workspaceId);
|
||||
const { allTasks, allSteps, stepsByWorkflowId } = aggregated;
|
||||
|
||||
const isLoadingWorkflow = isMultiLoading && Object.keys(snapshots).length === 0;
|
||||
const {
|
||||
allTasks,
|
||||
allSteps,
|
||||
stepsByWorkflowId,
|
||||
workflows,
|
||||
isLoading: isLoadingWorkflow,
|
||||
} = useWorkspaceSidebarTasks(workspaceId);
|
||||
|
||||
const tasksWithRepositories = useMemo(() => {
|
||||
const repositories = workspaceId ? (repositoriesByWorkspace[workspaceId] ?? []) : [];
|
||||
@@ -303,9 +247,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
]),
|
||||
);
|
||||
const titleById = new Map(allTasks.map((t) => [t.id, t.title]));
|
||||
const workflowNameById = new Map(
|
||||
Object.entries(scopedSnapshots).map(([wfId, snap]) => [wfId, snap.workflowName]),
|
||||
);
|
||||
const workflowNameById = new Map(workflows.map((w) => [w.id, w.name]));
|
||||
const stepTitleById = new Map(allSteps.map((s) => [s.id, s.title]));
|
||||
const mapCtx = {
|
||||
sessionsByTaskId,
|
||||
@@ -331,7 +273,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
repositoriesByWorkspace,
|
||||
allTasks,
|
||||
allSteps,
|
||||
scopedSnapshots,
|
||||
workflows,
|
||||
workspaceId,
|
||||
sessionsByTaskId,
|
||||
gitStatusByEnvId,
|
||||
@@ -353,9 +295,7 @@ function useSidebarData(workspaceId: string | null) {
|
||||
isLoadingWorkflow,
|
||||
tasksWithRepositories,
|
||||
primarySessionIds,
|
||||
workflows: workflows
|
||||
.filter((workflow) => !workspaceId || workflow.workspaceId === workspaceId)
|
||||
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })),
|
||||
workflows,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -424,7 +364,8 @@ function useArchiveActions(store: StoreApi) {
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId);
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
setArchivingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[store],
|
||||
@@ -459,7 +400,8 @@ function useDeleteActions(
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId);
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
setDeletingTask({ id: taskId, title: task?.title ?? "this task" });
|
||||
},
|
||||
[store],
|
||||
@@ -514,10 +456,11 @@ function useSidebarActions(store: StoreApi) {
|
||||
|
||||
const handleSelectTask = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = findTaskInSnapshots(store.getState().kanbanMulti.snapshots, taskId);
|
||||
const state = store.getState();
|
||||
const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks);
|
||||
selectTaskWithLayout({
|
||||
taskId,
|
||||
task,
|
||||
task: task ?? undefined,
|
||||
store,
|
||||
switchToSession,
|
||||
loadTaskSessionsForTask,
|
||||
@@ -601,7 +544,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
|
||||
workspaceId,
|
||||
}: TaskSessionSidebarProps) {
|
||||
const store = useAppStoreApi();
|
||||
useAllWorkflowSnapshots(workspaceId);
|
||||
useRepositories(workspaceId);
|
||||
useWorkspacePRs(workspaceId);
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
const mockUseAllWorkflowSnapshots = vi.fn();
|
||||
|
||||
type Snapshot = {
|
||||
workflowId: string;
|
||||
workflowName: string;
|
||||
steps: Array<{ id: string; title: string; color: string; position: number }>;
|
||||
tasks: Array<{ id: string; workflowStepId: string; title: string; position: number }>;
|
||||
};
|
||||
|
||||
type MockState = {
|
||||
kanbanMulti: {
|
||||
snapshots: Record<string, Snapshot>;
|
||||
isLoading: boolean;
|
||||
};
|
||||
workflows: { items: Array<{ id: string; workspaceId: string; name: string; hidden?: boolean }> };
|
||||
kanban: {
|
||||
workflowId: string | null;
|
||||
tasks: Array<{ id: string; workflowStepId: string; title: string; position: number }>;
|
||||
steps: Array<{ id: string; title: string; color: string; position: number }>;
|
||||
};
|
||||
};
|
||||
|
||||
let mockState: MockState = {
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
};
|
||||
|
||||
vi.mock("@/components/state-provider", () => ({
|
||||
useAppStore: (selector: (s: MockState) => unknown) => selector(mockState),
|
||||
useAppStoreApi: () => ({ getState: () => mockState }),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({
|
||||
useAllWorkflowSnapshots: (workspaceId: string | null) => mockUseAllWorkflowSnapshots(workspaceId),
|
||||
}));
|
||||
|
||||
import { useWorkspaceSidebarTasks } from "./use-workspace-sidebar-tasks";
|
||||
|
||||
function setMockState(patch: Partial<MockState>) {
|
||||
mockState = {
|
||||
kanbanMulti: { ...mockState.kanbanMulti, ...(patch.kanbanMulti ?? {}) },
|
||||
workflows: { ...mockState.workflows, ...(patch.workflows ?? {}) },
|
||||
kanban: { ...mockState.kanban, ...(patch.kanban ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
function makeSnapshot(
|
||||
workflowId: string,
|
||||
workflowName: string,
|
||||
taskIds: string[],
|
||||
stepId = "step-1",
|
||||
): Snapshot {
|
||||
return {
|
||||
workflowId,
|
||||
workflowName,
|
||||
steps: [{ id: stepId, title: "Step 1", color: "bg-blue-500", position: 0 }],
|
||||
tasks: taskIds.map((id, i) => ({ id, workflowStepId: stepId, title: id, position: i })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("useWorkspaceSidebarTasks", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = {
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
};
|
||||
});
|
||||
|
||||
it("fires useAllWorkflowSnapshots with the workspaceId", () => {
|
||||
renderHook(() => useWorkspaceSidebarTasks("ws-1"));
|
||||
expect(mockUseAllWorkflowSnapshots).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("aggregates tasks from every workflow snapshot scoped to the workspace", () => {
|
||||
setMockState({
|
||||
workflows: {
|
||||
items: [
|
||||
{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" },
|
||||
{ id: "wf-B", workspaceId: "ws-1", name: "Beta" },
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
"wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1", "t-a2"]),
|
||||
"wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]),
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1"));
|
||||
const ids = result.current.allTasks.map((t) => t.id);
|
||||
expect(ids).toEqual(["t-a1", "t-a2", "t-b1"]);
|
||||
// Tagged with their workflow so downstream UI can group.
|
||||
expect(result.current.allTasks[0]._workflowId).toBe("wf-A");
|
||||
expect(result.current.allTasks[2]._workflowId).toBe("wf-B");
|
||||
expect(Object.keys(result.current.stepsByWorkflowId).sort()).toEqual(["wf-A", "wf-B"]);
|
||||
expect(result.current.workflows.map((w) => w.id)).toEqual(["wf-A", "wf-B"]);
|
||||
});
|
||||
|
||||
it("returns an empty scope when workspaceId is null (no cross-workspace leak)", () => {
|
||||
setMockState({
|
||||
workflows: {
|
||||
items: [
|
||||
{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" },
|
||||
{ id: "wf-B", workspaceId: "ws-2", name: "Beta" },
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
"wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]),
|
||||
"wf-B": makeSnapshot("wf-B", "Beta", ["t-b1"]),
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSidebarTasks(null));
|
||||
expect(result.current.allTasks).toEqual([]);
|
||||
expect(result.current.workflows).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out snapshots from other workspaces (stale hydration)", () => {
|
||||
setMockState({
|
||||
workflows: {
|
||||
items: [
|
||||
{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" },
|
||||
{ id: "wf-X", workspaceId: "ws-other", name: "Stale" },
|
||||
],
|
||||
},
|
||||
kanbanMulti: {
|
||||
snapshots: {
|
||||
"wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]),
|
||||
"wf-X": makeSnapshot("wf-X", "Stale", ["t-x1"]),
|
||||
},
|
||||
isLoading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1"));
|
||||
expect(result.current.allTasks.map((t) => t.id)).toEqual(["t-a1"]);
|
||||
expect(result.current.workflows.map((w) => w.id)).toEqual(["wf-A"]);
|
||||
});
|
||||
|
||||
it("falls back to the active kanban slice for tasks not yet in snapshots", () => {
|
||||
// Snapshot for wf-A hasn't loaded yet, but the page-level useTasks call
|
||||
// already populated `kanban.tasks` for the current workflow.
|
||||
setMockState({
|
||||
workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] },
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
kanban: {
|
||||
workflowId: "wf-A",
|
||||
tasks: [{ id: "t-a1", workflowStepId: "step-1", title: "A1", position: 0 }],
|
||||
steps: [{ id: "step-1", title: "Step 1", color: "bg-blue-500", position: 0 }],
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceSidebarTasks("ws-1"));
|
||||
expect(result.current.allTasks.map((t) => t.id)).toEqual(["t-a1"]);
|
||||
expect(result.current.allTasks[0]._workflowId).toBe("wf-A");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useWorkspaceSidebarTasks — loading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState = {
|
||||
kanbanMulti: { snapshots: {}, isLoading: false },
|
||||
workflows: { items: [] },
|
||||
kanban: { workflowId: null, tasks: [], steps: [] },
|
||||
};
|
||||
});
|
||||
|
||||
it("reports loading only on the first fetch, not on refreshes", () => {
|
||||
setMockState({
|
||||
workflows: { items: [{ id: "wf-A", workspaceId: "ws-1", name: "Alpha" }] },
|
||||
kanbanMulti: { snapshots: {}, isLoading: true },
|
||||
});
|
||||
expect(renderHook(() => useWorkspaceSidebarTasks("ws-1")).result.current.isLoading).toBe(true);
|
||||
|
||||
setMockState({
|
||||
kanbanMulti: {
|
||||
snapshots: { "wf-A": makeSnapshot("wf-A", "Alpha", ["t-a1"]) },
|
||||
isLoading: true,
|
||||
},
|
||||
});
|
||||
expect(renderHook(() => useWorkspaceSidebarTasks("ws-1")).result.current.isLoading).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { useMemo } from "react";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
||||
import {
|
||||
aggregateSidebarTasks,
|
||||
type AggregatedSidebarTasks,
|
||||
} from "@/components/task/task-session-sidebar-aggregate";
|
||||
import type { TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
|
||||
|
||||
export type WorkspaceSidebarTasksResult = AggregatedSidebarTasks & {
|
||||
workflows: TaskMoveWorkflow[];
|
||||
isLoading: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared data source for the desktop sidebar and the mobile task-switcher sheet.
|
||||
*
|
||||
* Fires `useAllWorkflowSnapshots` to populate `kanbanMulti.snapshots` for every
|
||||
* workflow in the workspace, then aggregates them (with a fallback to the
|
||||
* single active `kanban` slice for tasks that arrived via WS before their
|
||||
* snapshot resolved). Snapshots from other workspaces are filtered out so a
|
||||
* stale hydration doesn't leak across workspace switches.
|
||||
*/
|
||||
export function useWorkspaceSidebarTasks(workspaceId: string | null): WorkspaceSidebarTasksResult {
|
||||
useAllWorkflowSnapshots(workspaceId);
|
||||
|
||||
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
|
||||
const workflows = useAppStore((state) => state.workflows.items);
|
||||
const activeKanbanWorkflowId = useAppStore((state) => state.kanban.workflowId);
|
||||
const activeKanbanTasks = useAppStore((state) => state.kanban.tasks);
|
||||
const activeKanbanSteps = useAppStore((state) => state.kanban.steps);
|
||||
|
||||
// While `workspaceId` is unresolved (initial SSR / pre-hydration), return an
|
||||
// empty scope rather than every workflow in the store — otherwise snapshots
|
||||
// from previously-active workspaces would briefly bleed into the sidebar.
|
||||
const filteredWorkflows = useMemo(
|
||||
() => (workspaceId ? workflows.filter((w) => w.workspaceId === workspaceId) : []),
|
||||
[workflows, workspaceId],
|
||||
);
|
||||
const workspaceWorkflowIds = useMemo(
|
||||
() => new Set(filteredWorkflows.map((w) => w.id)),
|
||||
[filteredWorkflows],
|
||||
);
|
||||
|
||||
const scopedSnapshots = useMemo(() => {
|
||||
const result: typeof snapshots = {};
|
||||
for (const [wfId, snap] of Object.entries(snapshots)) {
|
||||
if (workspaceWorkflowIds.has(wfId)) result[wfId] = snap;
|
||||
}
|
||||
return result;
|
||||
}, [snapshots, workspaceWorkflowIds]);
|
||||
|
||||
const fallbackWorkflowId =
|
||||
activeKanbanWorkflowId && workspaceWorkflowIds.has(activeKanbanWorkflowId)
|
||||
? activeKanbanWorkflowId
|
||||
: null;
|
||||
|
||||
const aggregated = useMemo(
|
||||
() =>
|
||||
aggregateSidebarTasks(
|
||||
scopedSnapshots,
|
||||
fallbackWorkflowId,
|
||||
activeKanbanTasks,
|
||||
activeKanbanSteps,
|
||||
),
|
||||
[scopedSnapshots, fallbackWorkflowId, activeKanbanTasks, activeKanbanSteps],
|
||||
);
|
||||
|
||||
const workspaceWorkflows = useMemo<TaskMoveWorkflow[]>(
|
||||
() => filteredWorkflows.map((w) => ({ id: w.id, name: w.name, hidden: w.hidden })),
|
||||
[filteredWorkflows],
|
||||
);
|
||||
|
||||
// Only flash a skeleton on the very first fetch (no snapshots yet); refreshes
|
||||
// shouldn't blow away the existing list.
|
||||
const isLoading = isMultiLoading && Object.keys(scopedSnapshots).length === 0;
|
||||
|
||||
return {
|
||||
...aggregated,
|
||||
workflows: workspaceWorkflows,
|
||||
isLoading,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findTaskInSnapshots } from "./find-task";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
|
||||
type Task = KanbanState["tasks"][number];
|
||||
|
||||
function task(id: string): Task {
|
||||
return { id, workflowStepId: "s", title: id, position: 0 };
|
||||
}
|
||||
|
||||
describe("findTaskInSnapshots", () => {
|
||||
it("finds a task in a snapshot", () => {
|
||||
const snap = { tasks: [task("t1"), task("t2")] };
|
||||
expect(findTaskInSnapshots("t2", { wf: snap })?.id).toBe("t2");
|
||||
});
|
||||
|
||||
it("searches across every loaded snapshot", () => {
|
||||
const snaps = { wfA: { tasks: [task("t1")] }, wfB: { tasks: [task("t2")] } };
|
||||
expect(findTaskInSnapshots("t2", snaps)?.id).toBe("t2");
|
||||
});
|
||||
|
||||
it("falls back to fallbackTasks when no snapshot matches", () => {
|
||||
expect(findTaskInSnapshots("t-new", {}, [task("t-new")])?.id).toBe("t-new");
|
||||
});
|
||||
|
||||
it("prefers a snapshot match over fallbackTasks", () => {
|
||||
// If both exist, the snapshot version wins so the mobile sheet doesn't
|
||||
// surface a stale active-kanban entry over a fresher multi-snapshot one.
|
||||
const snap = { tasks: [{ ...task("t1"), title: "from-snapshot" }] };
|
||||
const fallback = [{ ...task("t1"), title: "from-fallback" }];
|
||||
expect(findTaskInSnapshots("t1", { wf: snap }, fallback)?.title).toBe("from-snapshot");
|
||||
});
|
||||
|
||||
it("returns null when the task is missing from snapshots and fallback", () => {
|
||||
expect(findTaskInSnapshots("missing", { wf: { tasks: [task("t1")] } })).toBeNull();
|
||||
expect(findTaskInSnapshots("missing", {}, [task("t1")])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,16 +2,15 @@ import type { KanbanState } from "@/lib/state/slices";
|
||||
|
||||
type Task = KanbanState["tasks"][number];
|
||||
|
||||
// findTaskInSnapshots searches the multi-workflow snapshot map for a task by id.
|
||||
// Used by callers that need to resolve cross-workflow tasks (e.g. PR-review
|
||||
// boards, multi-workflow swimlanes) which do not live in `kanban.tasks`.
|
||||
// `fallbackTasks` is consulted when no snapshot matches (e.g. WS-arrived tasks before their snapshot lands).
|
||||
export function findTaskInSnapshots(
|
||||
taskId: string,
|
||||
snapshots: Record<string, { tasks: KanbanState["tasks"] }>,
|
||||
fallbackTasks?: KanbanState["tasks"],
|
||||
): Task | null {
|
||||
for (const snapshot of Object.values(snapshots)) {
|
||||
const found = snapshot.tasks.find((t) => t.id === taskId);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
return fallbackTasks?.find((t) => t.id === taskId) ?? null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user