feat(web): collapse subtasks in sidebar (#662)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user