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)),