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}