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}
|
||||
|
||||
Reference in New Issue
Block a user