diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 1c17df3aa7..791ac0049e 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -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(items: T[ }); } -export function useSheetData(workspaceId: string | null, workflowId: string | null) { +type SheetItemCtx = { + repositoryPathsById: Map; + workflowNameById: Map; + stepTitleById: Map; + sessionsByTaskId: Parameters[1]; + gitStatusByEnvId: Parameters[2]; + envIdBySessionId: Parameters[3]; + messagesBySession: Parameters[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], diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 5daf56b2f6..4df0bd14f4 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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; activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; @@ -62,6 +68,8 @@ function MobileTaskList({ return ( (key ? key.split("\0") : []), [key]); } -/** Find a task across all workflow snapshots */ -function findTaskInSnapshots( - snapshots: Record, - 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): 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); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts new file mode 100644 index 0000000000..81af0e751f --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.test.ts @@ -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; + 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 = { + 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); + }); +}); diff --git a/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts new file mode 100644 index 0000000000..0f1c3ad2ae --- /dev/null +++ b/apps/web/hooks/domains/kanban/use-workspace-sidebar-tasks.ts @@ -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( + () => 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, + }; +} diff --git a/apps/web/lib/kanban/find-task.test.ts b/apps/web/lib/kanban/find-task.test.ts new file mode 100644 index 0000000000..8c44f1d732 --- /dev/null +++ b/apps/web/lib/kanban/find-task.test.ts @@ -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(); + }); +}); diff --git a/apps/web/lib/kanban/find-task.ts b/apps/web/lib/kanban/find-task.ts index 3931b39105..36f54b10f7 100644 --- a/apps/web/lib/kanban/find-task.ts +++ b/apps/web/lib/kanban/find-task.ts @@ -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, + 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; }