feat(web): collapse subtasks in sidebar (#662)

This commit is contained in:
Carlos Florêncio
2026-04-23 18:40:58 +01:00
committed by GitHub
parent b77b2802ce
commit e1d21e39a9
10 changed files with 455 additions and 61 deletions
+56 -3
View File
@@ -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 (
<div
@@ -317,17 +328,59 @@ export const TaskItem = memo(function TaskItem({
issueInfo={issueInfo}
reserveMenuSpace={!hasDiffStats}
/>
{showSubtaskToggle && (
<SubtaskToggle
taskId={taskId}
count={subtaskCount!}
collapsed={!!subtasksCollapsed}
onToggle={onToggleSubtasks!}
/>
)}
{hasDiffStats && <DiffStatsRight diffStats={diffStats!} menuOpen={effectiveMenuOpen} />}
<TaskMenuButton visible={effectiveMenuOpen} />
<TaskMenuButton visible={effectiveMenuOpen} shiftLeft={showSubtaskToggle} />
</div>
);
});
function TaskMenuButton({ visible }: { visible: boolean }) {
function SubtaskToggle({
taskId,
count,
collapsed,
onToggle,
}: {
taskId?: string;
count: number;
collapsed: boolean;
onToggle: () => void;
}) {
return (
<button
type="button"
data-testid="sidebar-subtask-toggle"
data-task-id={taskId}
aria-label={collapsed ? "Expand subtasks" : "Collapse subtasks"}
aria-expanded={!collapsed}
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
onKeyDown={(e) => e.stopPropagation()}
className="self-center flex items-center gap-0.5 shrink-0 cursor-pointer text-[11px] text-muted-foreground/60 hover:text-foreground"
>
<IconChevronDown className={cn("h-3 w-3 transition-transform", collapsed && "-rotate-90")} />
<span>{count}</span>
</button>
);
}
function TaskMenuButton({ visible, shiftLeft }: { visible: boolean; shiftLeft?: boolean }) {
return (
<div
className={cn(
"absolute right-1 inset-y-0 flex items-center gap-0.5 transition-opacity duration-100",
"absolute inset-y-0 flex items-center gap-0.5 transition-opacity duration-100",
// When a subtask toggle is present on the right, push the menu button
// left of it so the two controls don't overlap on hover.
shiftLeft ? "right-10" : "right-1",
visible ? "opacity-100" : "opacity-0 group-hover:opacity-100",
)}
>
@@ -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}
+116 -44
View File
@@ -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 (
<div className="animate-pulse">
@@ -119,6 +127,74 @@ function GroupHeader({
);
}
type TaskRowProps = {
task: TaskSwitcherItem;
isSubTask?: boolean;
subtaskToggle?: SubtaskToggleInfo;
stepsByWorkflowId?: Record<string, StepDef[]>;
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 (
<TaskItemWithContextMenu
task={task}
steps={taskSteps}
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onMoveToStep={onMoveToStep}
isDeleting={deletingTaskId === task.id}
>
<TaskItem
title={task.title}
state={task.state}
sessionState={task.sessionState}
isArchived={task.isArchived}
isSelected={isSelected}
diffStats={task.diffStats}
isRemoteExecutor={task.isRemoteExecutor}
remoteExecutorType={task.remoteExecutorType}
remoteExecutorName={task.remoteExecutorName}
taskId={task.id}
primarySessionId={task.primarySessionId ?? null}
updatedAt={task.updatedAt}
repositories={task.repositories}
prInfo={task.prInfo}
issueInfo={task.issueInfo}
isSubTask={isSubTask}
subtaskCount={subtaskToggle?.subtaskCount}
subtasksCollapsed={subtaskToggle?.subtasksCollapsed}
onToggleSubtasks={subtaskToggle?.onToggleSubtasks}
onClick={() => onSelectTask(task.id)}
isDeleting={deletingTaskId === task.id}
/>
</TaskItemWithContextMenu>
);
}
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 (
<TaskItemWithContextMenu
key={task.id}
task={task}
steps={taskSteps}
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onMoveToStep={onMoveToStep}
isDeleting={deletingTaskId === task.id}
>
<TaskItem
title={task.title}
state={task.state}
sessionState={task.sessionState}
isArchived={task.isArchived}
isSelected={isSelected}
diffStats={task.diffStats}
isRemoteExecutor={task.isRemoteExecutor}
remoteExecutorType={task.remoteExecutorType}
remoteExecutorName={task.remoteExecutorName}
taskId={task.id}
primarySessionId={task.primarySessionId ?? null}
updatedAt={task.updatedAt}
repositories={task.repositories}
prInfo={task.prInfo}
issueInfo={task.issueInfo}
isSubTask={isSubTask}
onClick={() => onSelectTask(task.id)}
isDeleting={deletingTaskId === task.id}
/>
</TaskItemWithContextMenu>
);
}
const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []);
const rowProps = {
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
onSelectTask,
onRenameTask,
onArchiveTask,
onDeleteTask,
onMoveToStep,
deletingTaskId,
};
return (
<div>
@@ -205,12 +259,26 @@ function GroupSection({
/>
)}
{!isCollapsed &&
group.tasks.map((task) => (
<div key={task.id}>
{renderItem(task)}
{subTasksByParentId.get(task.id)?.map((sub) => renderItem(sub, true))}
</div>
))}
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 (
<div key={task.id}>
<TaskRow task={task} subtaskToggle={toggleInfo} {...rowProps} />
{!subsHidden &&
subs?.map((sub) => <TaskRow key={sub.id} task={sub} isSubTask {...rowProps} />)}
</div>
);
})}
</div>
);
}
@@ -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}
@@ -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);
});
});
+27
View File
@@ -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<string[]>(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";
+3
View File
@@ -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<DefaultState>): Default
initialState.sessionFailureNotification ?? defaultState.sessionFailureNotification,
bottomTerminal: { ...defaultState.bottomTerminal, ...initialState.bottomTerminal },
sidebarViews: { ...defaultState.sidebarViews, ...initialState.sidebarViews },
collapsedSubtaskParents:
initialState.collapsedSubtaskParents ?? defaultState.collapsedSubtaskParents,
};
}
+3
View File
@@ -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;
};
@@ -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<UISlice>()(
// 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]);
});
});
+45 -14
View File
@@ -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<typeof createUISlice>[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<UISlice, [["zustand/immer", never]], []
get,
) => ({
...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<UISlice, [["zustand/immer", never]], []
draft.documentPanel.activeDocumentBySessionId[sessionId] = doc;
setLocalStorage(`active-document-${sessionId}`, doc as ActiveDocument | null);
}),
setSystemHealth: (response) =>
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;
+4
View File
@@ -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<AppState>) {
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)),