From e1d21e39a92909e34bf4261a902a80e31f8bf5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Thu, 23 Apr 2026 18:40:58 +0100 Subject: [PATCH] feat(web): collapse subtasks in sidebar (#662) --- apps/web/components/task/task-item.tsx | 59 ++++++- .../components/task/task-session-sidebar.tsx | 4 + apps/web/components/task/task-switcher.tsx | 160 +++++++++++++----- .../task/sidebar-subtask-collapse.spec.ts | 141 +++++++++++++++ apps/web/lib/local-storage.ts | 27 +++ apps/web/lib/state/default-state.ts | 3 + apps/web/lib/state/slices/ui/types.ts | 3 + apps/web/lib/state/slices/ui/ui-slice.test.ts | 56 ++++++ apps/web/lib/state/slices/ui/ui-slice.ts | 59 +++++-- apps/web/lib/state/store.ts | 4 + 10 files changed, 455 insertions(+), 61 deletions(-) create mode 100644 apps/web/e2e/tests/task/sidebar-subtask-collapse.spec.ts create mode 100644 apps/web/lib/state/slices/ui/ui-slice.test.ts diff --git a/apps/web/components/task/task-item.tsx b/apps/web/components/task/task-item.tsx index 4122455612..21ce6ddd0f 100644 --- a/apps/web/components/task/task-item.tsx +++ b/apps/web/components/task/task-item.tsx @@ -2,6 +2,7 @@ import { memo } from "react"; import { + IconChevronDown, IconCircleCheck, IconCircleDashed, IconDots, @@ -42,6 +43,12 @@ type TaskItemProps = { primarySessionId?: string | null; parentTaskTitle?: string; isSubTask?: boolean; + /** Number of subtasks under this parent task. Only set for parent rows. */ + subtaskCount?: number; + /** Whether the subtasks of this parent are currently hidden. */ + subtasksCollapsed?: boolean; + /** Toggles subtask visibility when the chevron is clicked. */ + onToggleSubtasks?: () => void; repositories?: string[]; prInfo?: { number: number; state: string }; issueInfo?: { url: string; number: number }; @@ -269,6 +276,9 @@ export const TaskItem = memo(function TaskItem({ taskId, primarySessionId, isSubTask, + subtaskCount, + subtasksCollapsed, + onToggleSubtasks, repositories, prInfo, issueInfo, @@ -276,6 +286,7 @@ export const TaskItem = memo(function TaskItem({ const effectiveMenuOpen = menuOpen || isDeleting === true; const isInProgress = computeIsInProgress(state, sessionState); const hasDiffStats = !!diffStats && (diffStats.additions > 0 || diffStats.deletions > 0); + const showSubtaskToggle = !!subtaskCount && subtaskCount > 0 && !!onToggleSubtasks; return (
+ {showSubtaskToggle && ( + + )} {hasDiffStats && } - +
); }); -function TaskMenuButton({ visible }: { visible: boolean }) { +function SubtaskToggle({ + taskId, + count, + collapsed, + onToggle, +}: { + taskId?: string; + count: number; + collapsed: boolean; + onToggle: () => void; +}) { + return ( + + ); +} + +function TaskMenuButton({ visible, shiftLeft }: { visible: boolean; shiftLeft?: boolean }) { return (
diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index b061691ef2..6666690ff4 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -600,6 +600,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ }, [tasksWithRepositories, preparingTaskId]); const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed); + const collapsedSubtaskParents = useAppStore((state) => state.collapsedSubtaskParents); + const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed); const effectiveView = useEffectiveSidebarView(); const grouped = useMemo( () => applyView(displayTasks, effectiveView), @@ -617,6 +619,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ selectedTaskId={selectedTaskId} collapsedGroupKeys={effectiveView.collapsedGroups} onToggleGroup={(groupKey) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey)} + collapsedSubtaskParentIds={collapsedSubtaskParents} + onToggleSubtasks={toggleSubtaskCollapsed} onSelectTask={handleSelectTask} onRenameTask={handleRenameTask} onArchiveTask={handleArchiveTask} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 02c7d921f1..5d606c7d60 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -62,6 +62,8 @@ type TaskSwitcherProps = { selectedTaskId: string | null; collapsedGroupKeys?: string[]; onToggleGroup?: (groupKey: string) => void; + collapsedSubtaskParentIds?: string[]; + onToggleSubtasks?: (parentTaskId: string) => void; onSelectTask: (taskId: string) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; @@ -72,6 +74,12 @@ type TaskSwitcherProps = { totalTaskCount?: number; }; +type SubtaskToggleInfo = { + subtaskCount: number; + subtasksCollapsed: boolean; + onToggleSubtasks: () => void; +}; + function TaskSwitcherSkeleton() { return (
@@ -119,6 +127,74 @@ function GroupHeader({ ); } +type TaskRowProps = { + task: TaskSwitcherItem; + isSubTask?: boolean; + subtaskToggle?: SubtaskToggleInfo; + stepsByWorkflowId?: Record; + activeTaskId: string | null; + selectedTaskId: string | null; + onSelectTask: (taskId: string) => void; + onRenameTask?: (taskId: string, currentTitle: string) => void; + onArchiveTask?: (taskId: string) => void; + onDeleteTask?: (taskId: string) => void; + onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; + deletingTaskId?: string | null; +}; + +function TaskRow({ + task, + isSubTask, + subtaskToggle, + stepsByWorkflowId, + activeTaskId, + selectedTaskId, + onSelectTask, + onRenameTask, + onArchiveTask, + onDeleteTask, + onMoveToStep, + deletingTaskId, +}: TaskRowProps) { + const isSelected = task.id === selectedTaskId || task.id === activeTaskId; + const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined; + return ( + + onSelectTask(task.id)} + isDeleting={deletingTaskId === task.id} + /> + + ); +} + function GroupSection({ group, subTasksByParentId, @@ -127,6 +203,8 @@ function GroupSection({ selectedTaskId, isCollapsed, onToggleCollapsed, + collapsedSubtaskParentIds, + onToggleSubtasks, showHeader, onSelectTask, onRenameTask, @@ -142,6 +220,8 @@ function GroupSection({ selectedTaskId: string | null; isCollapsed: boolean; onToggleCollapsed: () => void; + collapsedSubtaskParentIds?: string[]; + onToggleSubtasks?: (parentTaskId: string) => void; showHeader: boolean; onSelectTask: (taskId: string) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; @@ -154,44 +234,18 @@ function GroupSection({ (sum, t) => sum + 1 + (subTasksByParentId.get(t.id)?.length ?? 0), 0, ); - - function renderItem(task: TaskSwitcherItem, isSubTask?: boolean) { - const isSelected = task.id === selectedTaskId || task.id === activeTaskId; - const taskSteps = task.workflowId ? stepsByWorkflowId?.[task.workflowId] : undefined; - return ( - - onSelectTask(task.id)} - isDeleting={deletingTaskId === task.id} - /> - - ); - } + const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []); + const rowProps = { + stepsByWorkflowId, + activeTaskId, + selectedTaskId, + onSelectTask, + onRenameTask, + onArchiveTask, + onDeleteTask, + onMoveToStep, + deletingTaskId, + }; return (
@@ -205,12 +259,26 @@ function GroupSection({ /> )} {!isCollapsed && - group.tasks.map((task) => ( -
- {renderItem(task)} - {subTasksByParentId.get(task.id)?.map((sub) => renderItem(sub, true))} -
- ))} + group.tasks.map((task) => { + const subs = subTasksByParentId.get(task.id); + const hasSubs = !!subs?.length; + const subsHidden = hasSubs && !!onToggleSubtasks && collapsedSubs.has(task.id); + const toggleInfo: SubtaskToggleInfo | undefined = + hasSubs && onToggleSubtasks + ? { + subtaskCount: subs!.length, + subtasksCollapsed: subsHidden, + onToggleSubtasks: () => onToggleSubtasks(task.id), + } + : undefined; + return ( +
+ + {!subsHidden && + subs?.map((sub) => )} +
+ ); + })}
); } @@ -351,6 +419,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ selectedTaskId, collapsedGroupKeys = [], onToggleGroup, + collapsedSubtaskParentIds, + onToggleSubtasks, onSelectTask, onRenameTask, onArchiveTask, @@ -383,6 +453,8 @@ export const TaskSwitcher = memo(function TaskSwitcher({ selectedTaskId={selectedTaskId} isCollapsed={collapsedSet.has(group.key)} onToggleCollapsed={() => onToggleGroup?.(group.key)} + collapsedSubtaskParentIds={collapsedSubtaskParentIds} + onToggleSubtasks={onToggleSubtasks} showHeader={showHeaders} onSelectTask={onSelectTask} onRenameTask={onRenameTask} diff --git a/apps/web/e2e/tests/task/sidebar-subtask-collapse.spec.ts b/apps/web/e2e/tests/task/sidebar-subtask-collapse.spec.ts new file mode 100644 index 0000000000..fee3c0c581 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-subtask-collapse.spec.ts @@ -0,0 +1,141 @@ +/** + * E2E tests for collapsing subtasks in the left sidebar. + * + * Covers: + * - Chevron toggle appears on parent tasks that have subtasks + * - Clicking the chevron hides/shows the subtask rows + * - Clicking the chevron does NOT select the parent task + * - Collapsed state survives page refresh (sessionStorage) + * - Collapsed state survives navigating to another task + */ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Sidebar subtasks — collapse/expand", () => { + test("chevron hides and restores subtasks, and survives reload + navigation", async ({ + testPage, + apiClient, + seedData, + }) => { + const parent = await apiClient.createTask(seedData.workspaceId, "Collapse Parent Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await apiClient.createTask(seedData.workspaceId, "Collapse Child One", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + parent_id: parent.id, + }); + await apiClient.createTask(seedData.workspaceId, "Collapse Child Two", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + parent_id: parent.id, + }); + + // A second root task for the navigation-survives check. + const other = await apiClient.createTask(seedData.workspaceId, "Collapse Unrelated Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${parent.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await expect(session.sidebar).toBeVisible({ timeout: 10_000 }); + + // Subtasks are visible initially. + await expect(session.sidebar.getByText("Collapse Child One")).toBeVisible({ timeout: 10_000 }); + await expect(session.sidebar.getByText("Collapse Child Two")).toBeVisible({ timeout: 10_000 }); + + // Chevron button appears on the parent row. + const chevron = session.sidebar.locator( + `[data-testid='sidebar-subtask-toggle'][data-task-id='${parent.id}']`, + ); + await expect(chevron).toBeVisible({ timeout: 5_000 }); + await expect(chevron).toHaveAttribute("aria-expanded", "true"); + + // Clicking the chevron collapses the subtasks without selecting the parent. + const urlBefore = testPage.url(); + await chevron.click(); + + await expect(session.sidebar.getByText("Collapse Child One")).not.toBeVisible({ + timeout: 5_000, + }); + await expect(session.sidebar.getByText("Collapse Child Two")).not.toBeVisible({ + timeout: 5_000, + }); + await expect(chevron).toHaveAttribute("aria-expanded", "false"); + // URL must be unchanged — chevron click must not navigate. + expect(testPage.url()).toBe(urlBefore); + + // Parent itself is still visible. + await expect(session.sidebar.getByText("Collapse Parent Task")).toBeVisible({ timeout: 5_000 }); + + // Reload — sessionStorage keeps the collapsed state. + await testPage.reload(); + await session.waitForLoad(); + await expect( + session.sidebar.locator( + `[data-testid='sidebar-subtask-toggle'][data-task-id='${parent.id}']`, + ), + ).toHaveAttribute("aria-expanded", "false", { timeout: 10_000 }); + await expect(session.sidebar.getByText("Collapse Child One")).not.toBeVisible({ + timeout: 5_000, + }); + + // Navigate to another task — state is still collapsed. + await testPage.goto(`/t/${other.id}`); + await session.waitForLoad(); + await expect( + session.sidebar.locator( + `[data-testid='sidebar-subtask-toggle'][data-task-id='${parent.id}']`, + ), + ).toHaveAttribute("aria-expanded", "false", { timeout: 10_000 }); + await expect(session.sidebar.getByText("Collapse Child One")).not.toBeVisible({ + timeout: 5_000, + }); + + // Expanding restores the subtasks — and at this point the active task is + // `other`, not `parent`, so a bubbled parent-row click (if any) would + // change the URL. Capture it and assert it doesn't change after the + // chevron click, which exercises the no-navigation invariant on an + // unselected parent row. + const chevronAgain = session.sidebar.locator( + `[data-testid='sidebar-subtask-toggle'][data-task-id='${parent.id}']`, + ); + const urlBeforeExpand = testPage.url(); + await chevronAgain.click(); + expect(testPage.url()).toBe(urlBeforeExpand); + await expect(chevronAgain).toHaveAttribute("aria-expanded", "true"); + await expect(session.sidebar.getByText("Collapse Child One")).toBeVisible({ timeout: 5_000 }); + await expect(session.sidebar.getByText("Collapse Child Two")).toBeVisible({ timeout: 5_000 }); + }); + + test("tasks without subtasks do not render a chevron toggle", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTask(seedData.workspaceId, "Lonely Task No Subtasks", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + + await expect(session.sidebar.getByText("Lonely Task No Subtasks")).toBeVisible({ + timeout: 10_000, + }); + // No toggle should exist for this task. + await expect( + session.sidebar.locator(`[data-testid='sidebar-subtask-toggle'][data-task-id='${task.id}']`), + ).toHaveCount(0); + }); +}); diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 53b5120532..082d9e10d9 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -633,6 +633,12 @@ export function cleanupTaskStorage(taskId: string, sessionIds: string[]): void { // Plan notification (localStorage, keyed per task inside a Record) setPlanLastSeen(taskId, null); + // Sidebar collapsed-subtask set (sessionStorage, array keyed by parent taskId) + const collapsed = getStoredCollapsedSubtaskParents(); + if (collapsed.includes(taskId)) { + setStoredCollapsedSubtaskParents(collapsed.filter((id) => id !== taskId)); + } + // Session-keyed storage — clean all sessions belonging to the task for (const sessionId of sessionIds) { removeSessionMaximizeState(sessionId); @@ -690,6 +696,27 @@ export function removeStoredSidebarDraft(): void { removeLocalStorage(SIDEBAR_DRAFT_KEY); } +// --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) --- + +const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks"; + +/** + * Get the list of parent task IDs whose subtasks are collapsed in the sidebar. + * Tab-scoped (sessionStorage) so it survives reload/task switches but not tab close. + */ +export function getStoredCollapsedSubtaskParents(): string[] { + const raw = getSessionStorage(COLLAPSED_SUBTASKS_KEY, []) as unknown; + if (!Array.isArray(raw)) return []; + return raw.filter((id): id is string => typeof id === "string"); +} + +/** + * Save the list of parent task IDs whose subtasks are collapsed in the sidebar. + */ +export function setStoredCollapsedSubtaskParents(ids: string[]): void { + setSessionStorage(COLLAPSED_SUBTASKS_KEY, ids); +} + // --- Task creation draft persistence (sessionStorage, per workspace) --- const TASK_CREATE_DRAFT_KEY = "kandev.taskCreateDraft"; diff --git a/apps/web/lib/state/default-state.ts b/apps/web/lib/state/default-state.ts index d37e088a74..232552a87b 100644 --- a/apps/web/lib/state/default-state.ts +++ b/apps/web/lib/state/default-state.ts @@ -74,6 +74,7 @@ export const defaultState = { sessionFailureNotification: defaultUIState.sessionFailureNotification, bottomTerminal: defaultUIState.bottomTerminal, sidebarViews: defaultUIState.sidebarViews, + collapsedSubtaskParents: defaultUIState.collapsedSubtaskParents, }; export type DefaultState = typeof defaultState; @@ -155,5 +156,7 @@ export function mergeInitialState(initialState?: Partial): Default initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification, bottomTerminal: { ...defaultState.bottomTerminal, ...initialState.bottomTerminal }, sidebarViews: { ...defaultState.sidebarViews, ...initialState.sidebarViews }, + collapsedSubtaskParents: + initialState.collapsedSubtaskParents ?? defaultState.collapsedSubtaskParents, }; } diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index a631f8da25..a803c8283a 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -110,6 +110,8 @@ export type UISliceState = { sessionFailureNotification: SessionFailureNotification | null; bottomTerminal: BottomTerminalState; sidebarViews: SidebarSliceState; + /** Parent task IDs whose subtasks are collapsed in the sidebar. Tab-scoped (sessionStorage). */ + collapsedSubtaskParents: string[]; }; export type UISliceActions = { @@ -157,6 +159,7 @@ export type UISliceActions = { renameSidebarView: (viewId: string, name: string) => void; duplicateSidebarView: (viewId: string, name: string) => void; toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => void; + toggleSubtaskCollapsed: (parentTaskId: string) => void; clearSidebarSyncError: () => void; migrateLocalViewsToBackend: () => void; }; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts new file mode 100644 index 0000000000..3c59821537 --- /dev/null +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -0,0 +1,56 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createUISlice } from "./ui-slice"; +import type { UISlice } from "./types"; + +function makeStore() { + return create()( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + immer((...a) => ({ ...(createUISlice as any)(...a) })), + ); +} + +const KEY = "kandev.sidebar.collapsedSubtasks"; +const TASK_A = "task-a"; +const TASK_B = "task-b"; + +describe("toggleSubtaskCollapsed", () => { + beforeEach(() => { + window.sessionStorage.clear(); + }); + + it("hydrates initial state from sessionStorage", () => { + window.sessionStorage.setItem(KEY, JSON.stringify(["task-hydrated"])); + const store = makeStore(); + expect(store.getState().collapsedSubtaskParents).toEqual(["task-hydrated"]); + }); + + it("adds a parent id on first toggle and persists it", () => { + const store = makeStore(); + store.getState().toggleSubtaskCollapsed(TASK_A); + + expect(store.getState().collapsedSubtaskParents).toEqual([TASK_A]); + expect(JSON.parse(window.sessionStorage.getItem(KEY) ?? "null")).toEqual([TASK_A]); + }); + + it("removes a parent id on second toggle", () => { + const store = makeStore(); + store.getState().toggleSubtaskCollapsed(TASK_A); + store.getState().toggleSubtaskCollapsed(TASK_A); + + expect(store.getState().collapsedSubtaskParents).toEqual([]); + expect(JSON.parse(window.sessionStorage.getItem(KEY) ?? "null")).toEqual([]); + }); + + it("tracks multiple parents independently", () => { + const store = makeStore(); + store.getState().toggleSubtaskCollapsed(TASK_A); + store.getState().toggleSubtaskCollapsed(TASK_B); + + expect(store.getState().collapsedSubtaskParents).toEqual([TASK_A, TASK_B]); + + store.getState().toggleSubtaskCollapsed(TASK_A); + expect(store.getState().collapsedSubtaskParents).toEqual([TASK_B]); + }); +}); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 6256563f55..5e20f5e935 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -1,11 +1,13 @@ import type { StateCreator } from "zustand"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { + getStoredCollapsedSubtaskParents, getStoredSidebarActiveViewId, getStoredSidebarDraft, getStoredSidebarUserViews, removeStoredSidebarDraft, setLocalStorage, + setStoredCollapsedSubtaskParents, setStoredSidebarActiveViewId, setStoredSidebarDraft, setStoredSidebarUserViews, @@ -19,6 +21,7 @@ import type { SortSpec, } from "./sidebar-view-types"; import { toApiSidebarView } from "./sidebar-view-wire"; +import type { SystemHealthResponse } from "@/lib/types/health"; import type { ActiveDocument, UISlice, UISliceState } from "./types"; function loadSidebarState(): UISliceState["sidebarViews"] { @@ -90,6 +93,7 @@ export const defaultUIState: UISliceState = { sessionFailureNotification: null, bottomTerminal: { isOpen: false, pendingCommand: null }, sidebarViews: loadSidebarState(), + collapsedSubtaskParents: [], }; type ImmerSet = Parameters[0]; @@ -388,6 +392,42 @@ function cloneView(v: SidebarView): SidebarView { }; } +function buildSystemHealthActions(set: ImmerSet) { + return { + setSystemHealth: (response: SystemHealthResponse) => + set((draft) => { + draft.systemHealth.issues = response.issues; + draft.systemHealth.healthy = response.healthy; + draft.systemHealth.loaded = true; + }), + setSystemHealthLoading: (loading: boolean) => + set((draft) => { + draft.systemHealth.loading = loading; + }), + invalidateSystemHealth: () => + set((draft) => { + draft.systemHealth.loaded = false; + }), + }; +} + +function buildCollapsedSubtaskActions(set: ImmerSet, get: () => UISlice) { + return { + // Tab-scoped collapse of a parent task's subtasks. Persisted via + // sessionStorage (survives reload / task switch within the tab, resets on + // tab close). Not per-view and not synced to the backend — purely visual. + toggleSubtaskCollapsed: (parentTaskId: string) => { + set((draft) => { + const list = draft.collapsedSubtaskParents; + const idx = list.indexOf(parentTaskId); + if (idx === -1) list.push(parentTaskId); + else list.splice(idx, 1); + }); + setStoredCollapsedSubtaskParents(get().collapsedSubtaskParents); + }, + }; +} + function buildConfigChatActions(set: ImmerSet) { return { openConfigChat: (sessionId: string, workspaceId: string) => @@ -445,11 +485,16 @@ export const createUISlice: StateCreator ({ ...defaultUIState, + // Hydrate from sessionStorage at slice creation (runs in the browser, after + // the default static state) so tests and SSR both see a fresh read. + collapsedSubtaskParents: getStoredCollapsedSubtaskParents(), ...buildPreviewActions(set), ...buildMobileActions(set), ...buildBottomTerminalActions(set), ...buildConfigChatActions(set), ...buildSidebarViewActions(set, get), + ...buildCollapsedSubtaskActions(set, get), + ...buildSystemHealthActions(set), setRightPanelActiveTab: (sessionId, tab) => set((draft) => { draft.rightPanel.activeTabBySessionId[sessionId] = tab; @@ -469,20 +514,6 @@ export const createUISlice: StateCreator - set((draft) => { - draft.systemHealth.issues = response.issues; - draft.systemHealth.healthy = response.healthy; - draft.systemHealth.loaded = true; - }), - setSystemHealthLoading: (loading) => - set((draft) => { - draft.systemHealth.loading = loading; - }), - invalidateSystemHealth: () => - set((draft) => { - draft.systemHealth.loaded = false; - }), openQuickChat: (sessionId, workspaceId) => set((draft) => { draft.quickChat.isOpen = true; diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index d14055b017..434dccbb2a 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -216,6 +216,7 @@ export type AppState = { sessionFailureNotification: (typeof defaultUIState)["sessionFailureNotification"]; bottomTerminal: (typeof defaultUIState)["bottomTerminal"]; sidebarViews: (typeof defaultUIState)["sidebarViews"]; + collapsedSubtaskParents: (typeof defaultUIState)["collapsedSubtaskParents"]; // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; @@ -430,6 +431,7 @@ export type AppState = { renameSidebarView: UIA["renameSidebarView"]; duplicateSidebarView: UIA["duplicateSidebarView"]; toggleSidebarGroupCollapsed: UIA["toggleSidebarGroupCollapsed"]; + toggleSubtaskCollapsed: UIA["toggleSubtaskCollapsed"]; clearSidebarSyncError: UIA["clearSidebarSyncError"]; migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"]; }; @@ -518,6 +520,8 @@ export function createAppStore(initialState?: Partial) { quickChat: merged.quickChat, sessionFailureNotification: merged.sessionFailureNotification, bottomTerminal: merged.bottomTerminal, + // Note: collapsedSubtaskParents is intentionally not overridden here — + // createUISlice hydrates it from sessionStorage and we want that to win. // Add hydrate method // eslint-disable-next-line @typescript-eslint/no-explicit-any hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)),