From f4a235a3a9e60be1ec8b1a3e14cf95f5815082a7 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 15:50:09 -0300 Subject: [PATCH 1/4] feat(sidebar): add "By status" organize mode Adds a third organize option to the sidebar filter funnel that buckets tasks into kanban-like collapsible sections by their lifecycle state. Buckets in fixed precedence + display order: - Needs you (needsPermission) - Active (isGenerating or queued/in_progress) - Done (completed) - Failed (failed or cancelled) - Idle (everything else) Empty buckets are hidden. Each section reuses the existing collapsedSections persistence so collapse state survives reloads. Generated-By: PostHog Code Task-Id: d4c57b31-d26d-4679-8a3b-ac6c047b8403 --- .../sidebar/components/SidebarMenu.tsx | 1 + .../sidebar/components/TaskListView.tsx | 54 +++++++- .../features/sidebar/hooks/useSidebarData.ts | 12 ++ .../features/sidebar/stores/sidebarStore.ts | 2 +- .../sidebar/utils/groupByStatus.test.ts | 93 +++++++++++++ .../features/sidebar/utils/groupByStatus.ts | 124 ++++++++++++++++++ 6 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts create mode 100644 apps/code/src/renderer/features/sidebar/utils/groupByStatus.ts diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index 7d9b26d49..b2159b79b 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -352,6 +352,7 @@ function SidebarMenuComponent() { pinnedTasks={sidebarData.pinnedTasks} flatTasks={sidebarData.flatTasks} groupedTasks={sidebarData.groupedTasks} + statusGroupedTasks={sidebarData.statusGroupedTasks} activeTaskId={sidebarData.activeTaskId} editingTaskId={editingTaskId} onTaskClick={handleTaskClick} diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index e12c42aec..3c58719e6 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -27,9 +27,14 @@ import { useNavigationStore } from "@stores/navigationStore"; import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; -import type { TaskData, TaskGroup } from "../hooks/useSidebarData"; +import type { + StatusGroup, + TaskData, + TaskGroup, +} from "../hooks/useSidebarData"; import { useTaskPrStatus } from "../hooks/useTaskPrStatus"; import { useSidebarStore } from "../stores/sidebarStore"; +import { STATUS_GROUP_META } from "../utils/groupByStatus"; import { DraggableFolder } from "./DraggableFolder"; import { TaskItem } from "./items/TaskItem"; import { SidebarSection } from "./SidebarSection"; @@ -38,6 +43,7 @@ interface TaskListViewProps { pinnedTasks: TaskData[]; flatTasks: TaskData[]; groupedTasks: TaskGroup[]; + statusGroupedTasks: StatusGroup[]; activeTaskId: string | null; editingTaskId: string | null; onTaskClick: (taskId: string) => void; @@ -187,6 +193,9 @@ function TaskFilterMenu() { By project + + By status + Chronological list @@ -249,6 +258,7 @@ export function TaskListView({ pinnedTasks, flatTasks, groupedTasks, + statusGroupedTasks, activeTaskId, editingTaskId, onTaskClick, @@ -384,6 +394,48 @@ export function TaskListView({ )} + ) : organizeMode === "by-status" ? ( + + {statusGroupedTasks.map((group) => { + const isExpanded = !collapsedSections.has(group.id); + const meta = STATUS_GROUP_META[group.id]; + const Icon = meta.icon; + return ( + } + isExpanded={isExpanded} + onToggle={() => toggleSection(group.id)} + addSpacingBefore={false} + tooltipContent={meta.description} + > + {group.tasks.map((task) => ( + onTaskClick(task.id)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={1} + /> + ))} + + ); + })} + ) : organizeMode === "by-project" ? ( ; +export type StatusGroup = StatusTaskGroup; export interface SidebarData { isHomeActive: boolean; @@ -58,6 +63,7 @@ export interface SidebarData { pinnedTasks: TaskData[]; flatTasks: TaskData[]; groupedTasks: TaskGroup[]; + statusGroupedTasks: StatusGroup[]; totalCount: number; hasMore: boolean; } @@ -331,6 +337,11 @@ export function useSidebarData({ [sortedUnpinnedTasks, folderOrder], ); + const statusGroupedTasks = useMemo( + () => groupByStatus(sortedUnpinnedTasks), + [sortedUnpinnedTasks], + ); + const groupIdsRef = useRef([]); useEffect(() => { if (groupedTasks.length === 0) return; @@ -357,6 +368,7 @@ export function useSidebarData({ pinnedTasks, flatTasks, groupedTasks, + statusGroupedTasks, totalCount, hasMore, }; diff --git a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts index b87d80c2f..10054418b 100644 --- a/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts +++ b/apps/code/src/renderer/features/sidebar/stores/sidebarStore.ts @@ -10,7 +10,7 @@ interface SidebarStoreState { collapsedSections: Set; folderOrder: string[]; historyVisibleCount: number; - organizeMode: "by-project" | "chronological"; + organizeMode: "by-project" | "chronological" | "by-status"; sortMode: "updated" | "created"; showAllUsers: boolean; showInternal: boolean; diff --git a/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts new file mode 100644 index 000000000..88f3c3a91 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest"; +import { + groupByStatus, + STATUS_GROUP_IDS, + type StatusGroupableTask, +} from "./groupByStatus"; + +interface TestTask extends StatusGroupableTask { + id: string; +} + +function task(id: string, overrides: StatusGroupableTask = {}): TestTask { + return { id, ...overrides }; +} + +describe("groupByStatus", () => { + it("buckets tasks by their status into the expected groups", () => { + const tasks: TestTask[] = [ + task("idle-1"), + task("idle-2", { taskRunStatus: "not_started" }), + task("active-1", { isGenerating: true }), + task("active-2", { taskRunStatus: "in_progress" }), + task("active-3", { taskRunStatus: "queued" }), + task("done-1", { taskRunStatus: "completed" }), + task("failed-1", { taskRunStatus: "failed" }), + task("failed-2", { taskRunStatus: "cancelled" }), + task("needs-1", { needsPermission: true }), + ]; + + const groups = groupByStatus(tasks); + const byId = new Map(groups.map((g) => [g.id, g] as const)); + + expect(byId.get(STATUS_GROUP_IDS.needsYou)?.tasks.map((t) => t.id)).toEqual( + ["needs-1"], + ); + expect(byId.get(STATUS_GROUP_IDS.active)?.tasks.map((t) => t.id)).toEqual([ + "active-1", + "active-2", + "active-3", + ]); + expect(byId.get(STATUS_GROUP_IDS.done)?.tasks.map((t) => t.id)).toEqual([ + "done-1", + ]); + expect(byId.get(STATUS_GROUP_IDS.failed)?.tasks.map((t) => t.id)).toEqual([ + "failed-1", + "failed-2", + ]); + expect(byId.get(STATUS_GROUP_IDS.idle)?.tasks.map((t) => t.id)).toEqual([ + "idle-1", + "idle-2", + ]); + }); + + it("prefers 'Needs you' over 'Active' when both predicates match", () => { + const tasks: TestTask[] = [ + task("both", { needsPermission: true, taskRunStatus: "in_progress" }), + ]; + + const groups = groupByStatus(tasks); + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(STATUS_GROUP_IDS.needsYou); + expect(groups[0].tasks).toHaveLength(1); + }); + + it("returns groups in the fixed display order and omits empty buckets", () => { + const tasks: TestTask[] = [ + task("c", { taskRunStatus: "completed" }), + task("f", { taskRunStatus: "failed" }), + task("a", { taskRunStatus: "in_progress" }), + ]; + + const ids = groupByStatus(tasks).map((g) => g.id); + expect(ids).toEqual([ + STATUS_GROUP_IDS.active, + STATUS_GROUP_IDS.done, + STATUS_GROUP_IDS.failed, + ]); + }); + + it("returns an empty array when there are no tasks", () => { + expect(groupByStatus([])).toEqual([]); + }); + + it("preserves the input order of tasks within a bucket", () => { + const tasks: TestTask[] = [ + task("a", { taskRunStatus: "completed" }), + task("b", { taskRunStatus: "completed" }), + task("c", { taskRunStatus: "completed" }), + ]; + const groups = groupByStatus(tasks); + expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c"]); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/utils/groupByStatus.ts b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.ts new file mode 100644 index 000000000..8db013301 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.ts @@ -0,0 +1,124 @@ +import { + CheckCircle, + Circle, + HandPalm, + Lightning, + XCircle, +} from "@phosphor-icons/react"; +import type { TaskRunStatus } from "@shared/types"; + +export const STATUS_GROUP_IDS = { + active: "status-active", + needsYou: "status-needs-you", + idle: "status-idle", + done: "status-done", + failed: "status-failed", +} as const; + +export type StatusGroupId = + (typeof STATUS_GROUP_IDS)[keyof typeof STATUS_GROUP_IDS]; + +export interface StatusGroupableTask { + isGenerating?: boolean; + needsPermission?: boolean; + taskRunStatus?: TaskRunStatus; +} + +export interface StatusTaskGroup { + id: StatusGroupId; + name: string; + tasks: T[]; +} + +interface StatusBucket { + id: StatusGroupId; + name: string; + predicate: (task: StatusGroupableTask) => boolean; +} + +// Order here is the display order AND the matching precedence — a task lands +// in the first bucket whose predicate matches. So a task that's both +// in_progress AND needs permission shows up under "Needs you". +const STATUS_BUCKETS: StatusBucket[] = [ + { + id: STATUS_GROUP_IDS.needsYou, + name: "Needs you", + predicate: (task) => Boolean(task.needsPermission), + }, + { + id: STATUS_GROUP_IDS.active, + name: "Active", + predicate: (task) => + Boolean(task.isGenerating) || + task.taskRunStatus === "queued" || + task.taskRunStatus === "in_progress", + }, + { + id: STATUS_GROUP_IDS.done, + name: "Done", + predicate: (task) => task.taskRunStatus === "completed", + }, + { + id: STATUS_GROUP_IDS.failed, + name: "Failed", + predicate: (task) => + task.taskRunStatus === "failed" || task.taskRunStatus === "cancelled", + }, + { + id: STATUS_GROUP_IDS.idle, + name: "Idle", + predicate: () => true, + }, +]; + +export const STATUS_GROUP_META: Record< + StatusGroupId, + { icon: typeof Lightning; color: string; description: string } +> = { + [STATUS_GROUP_IDS.active]: { + icon: Lightning, + color: "var(--accent-11)", + description: "Running now", + }, + [STATUS_GROUP_IDS.needsYou]: { + icon: HandPalm, + color: "var(--blue-11)", + description: "Tasks waiting on your input", + }, + [STATUS_GROUP_IDS.idle]: { + icon: Circle, + color: "var(--gray-10)", + description: "Not started", + }, + [STATUS_GROUP_IDS.done]: { + icon: CheckCircle, + color: "var(--green-11)", + description: "Completed runs", + }, + [STATUS_GROUP_IDS.failed]: { + icon: XCircle, + color: "var(--red-11)", + description: "Failed or cancelled runs", + }, +}; + +export function groupByStatus( + tasks: T[], +): StatusTaskGroup[] { + const groupMap = new Map>(); + + for (const task of tasks) { + const bucket = STATUS_BUCKETS.find((b) => b.predicate(task)); + if (!bucket) continue; + let group = groupMap.get(bucket.id); + if (!group) { + group = { id: bucket.id, name: bucket.name, tasks: [] }; + groupMap.set(bucket.id, group); + } + group.tasks.push(task); + } + + return STATUS_BUCKETS.map((bucket) => groupMap.get(bucket.id)).filter( + (group): group is StatusTaskGroup => group !== undefined, + ); +} From 163ef09388f2d825aacbd9bd436e9270baacbef0 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 16:06:56 -0300 Subject: [PATCH 2/4] style(sidebar): apply biome formatting Auto-formatted by `biome check --write` to satisfy the Code Quality CI check: reflowed the StatusGroup import on one line and re-ordered the new `groupByStatus` import in useSidebarData to match Biome's organize- imports rule. Generated-By: PostHog Code Task-Id: d4c57b31-d26d-4679-8a3b-ac6c047b8403 --- .../renderer/features/sidebar/components/TaskListView.tsx | 6 +----- .../src/renderer/features/sidebar/hooks/useSidebarData.ts | 5 +---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 3c58719e6..6017ecca4 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -27,11 +27,7 @@ import { useNavigationStore } from "@stores/navigationStore"; import { getRelativeDateGroup } from "@utils/time"; import { motion } from "framer-motion"; import { Fragment, useCallback, useEffect, useMemo } from "react"; -import type { - StatusGroup, - TaskData, - TaskGroup, -} from "../hooks/useSidebarData"; +import type { StatusGroup, TaskData, TaskGroup } from "../hooks/useSidebarData"; import { useTaskPrStatus } from "../hooks/useTaskPrStatus"; import { useSidebarStore } from "../stores/sidebarStore"; import { STATUS_GROUP_META } from "../utils/groupByStatus"; diff --git a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts index 19c5faa4a..b946f1ab6 100644 --- a/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts +++ b/apps/code/src/renderer/features/sidebar/hooks/useSidebarData.ts @@ -13,16 +13,13 @@ import type { Task, TaskRunStatus } from "@shared/types"; import { useEffect, useMemo, useRef } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; import type { SortMode } from "../types"; +import { groupByStatus, type StatusTaskGroup } from "../utils/groupByStatus"; import { type TaskGroup as GenericTaskGroup, getRepositoryInfo, groupByRepository, type TaskRepositoryInfo, } from "../utils/groupTasks"; -import { - groupByStatus, - type StatusTaskGroup, -} from "../utils/groupByStatus"; import { computeSummaryIds } from "../utils/summaryIds"; import { usePinnedTasks } from "./usePinnedTasks"; import { useTaskViewed } from "./useTaskViewed"; From 12b7e29928519862c75bdc78b2b42a468b6aebe9 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 16:25:50 -0300 Subject: [PATCH 3/4] fix(sidebar): align TaskRow with multi-select props and parametrise tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After merging main (multi-select feature), TaskRow now requires `isSelected` / `hideHoverActions` and a new `onClick(taskId, e)` signature. The new by-status render branch wasn't passing those, breaking typecheck on CI. Rather than fix the one branch and leave four near-identical copies of the TaskRow block, extract a local `renderTaskRow(task, { depth? })` helper inside `TaskListView` so the four call sites (pinned, by-status, by-project, chronological) share a single definition. This also addresses the Greptile review comment about TaskRow JSX being duplicated across render branches. Also parametrise the groupByStatus tests with `it.each` per the second Greptile comment — bucket assignment now covers all five buckets and nine input shapes, input-order preservation covers all five buckets. Generated-By: PostHog Code Task-Id: d4c57b31-d26d-4679-8a3b-ac6c047b8403 --- .../src/main/services/context-menu/schemas.ts | 13 ++ .../src/main/services/context-menu/service.ts | 23 +++ .../src/main/trpc/routers/context-menu.ts | 7 + .../sidebar/components/MainSidebar.tsx | 21 +++ .../sidebar/components/SidebarItem.tsx | 7 +- .../sidebar/components/SidebarMenu.tsx | 165 ++++++++++++---- .../sidebar/components/TaskListView.tsx | 123 ++++-------- .../sidebar/components/items/TaskItem.tsx | 9 +- .../sidebar/stores/taskSelectionStore.test.ts | 177 ++++++++++++++++++ .../sidebar/stores/taskSelectionStore.ts | 89 +++++++++ .../sidebar/utils/groupByStatus.test.ts | 135 ++++++++----- .../features/tasks/hooks/useArchiveTask.ts | 29 +++ 12 files changed, 625 insertions(+), 173 deletions(-) create mode 100644 apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.test.ts create mode 100644 apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts diff --git a/apps/code/src/main/services/context-menu/schemas.ts b/apps/code/src/main/services/context-menu/schemas.ts index 2db37c6e9..9620d3ba8 100644 --- a/apps/code/src/main/services/context-menu/schemas.ts +++ b/apps/code/src/main/services/context-menu/schemas.ts @@ -10,6 +10,10 @@ export const taskContextMenuInput = z.object({ hasEmptyCommandCenterCell: z.boolean().optional(), }); +export const bulkTaskContextMenuInput = z.object({ + taskCount: z.number().int().min(2), +}); + export const archivedTaskContextMenuInput = z.object({ taskTitle: z.string(), }); @@ -45,6 +49,10 @@ const taskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("external-app"), action: externalAppAction }), ]); +const bulkTaskAction = z.discriminatedUnion("type", [ + z.object({ type: z.literal("archive") }), +]); + const archivedTaskAction = z.discriminatedUnion("type", [ z.object({ type: z.literal("restore") }), z.object({ type: z.literal("delete") }), @@ -72,6 +80,9 @@ const splitDirection = z.enum(["left", "right", "up", "down"]); export const taskContextMenuOutput = z.object({ action: taskAction.nullable(), }); +export const bulkTaskContextMenuOutput = z.object({ + action: bulkTaskAction.nullable(), +}); export const archivedTaskContextMenuOutput = z.object({ action: archivedTaskAction.nullable(), }); @@ -87,6 +98,7 @@ export const splitContextMenuOutput = z.object({ }); export type TaskContextMenuInput = z.infer; +export type BulkTaskContextMenuInput = z.infer; export type ArchivedTaskContextMenuInput = z.infer< typeof archivedTaskContextMenuInput >; @@ -96,6 +108,7 @@ export type FileContextMenuInput = z.infer; export type ExternalAppAction = z.infer; export type TaskAction = z.infer; +export type BulkTaskAction = z.infer; export type ArchivedTaskAction = z.infer; export type FolderAction = z.infer; export type TabAction = z.infer; diff --git a/apps/code/src/main/services/context-menu/service.ts b/apps/code/src/main/services/context-menu/service.ts index 24d3dbc62..93376654c 100644 --- a/apps/code/src/main/services/context-menu/service.ts +++ b/apps/code/src/main/services/context-menu/service.ts @@ -11,6 +11,8 @@ import type { ArchivedTaskAction, ArchivedTaskContextMenuInput, ArchivedTaskContextMenuResult, + BulkTaskAction, + BulkTaskContextMenuInput, ConfirmDeleteArchivedTaskInput, ConfirmDeleteArchivedTaskResult, ConfirmDeleteTaskInput, @@ -160,6 +162,27 @@ export class ContextMenuService { ]); } + async showBulkTaskContextMenu( + input: BulkTaskContextMenuInput, + ): Promise<{ action: BulkTaskAction | null }> { + const { taskCount } = input; + const label = `Archive ${taskCount} tasks`; + return this.showMenu([ + this.item( + label, + { type: "archive" }, + { + confirm: { + title: "Archive Tasks", + message: `Archive ${taskCount} tasks?`, + detail: "You can unarchive them later.", + confirmLabel: "Archive", + }, + }, + ), + ]); + } + async showArchivedTaskContextMenu( input: ArchivedTaskContextMenuInput, ): Promise { diff --git a/apps/code/src/main/trpc/routers/context-menu.ts b/apps/code/src/main/trpc/routers/context-menu.ts index 26d57bcf8..a394fcde3 100644 --- a/apps/code/src/main/trpc/routers/context-menu.ts +++ b/apps/code/src/main/trpc/routers/context-menu.ts @@ -3,6 +3,8 @@ import { MAIN_TOKENS } from "../../di/tokens"; import { archivedTaskContextMenuInput, archivedTaskContextMenuOutput, + bulkTaskContextMenuInput, + bulkTaskContextMenuOutput, confirmDeleteArchivedTaskInput, confirmDeleteArchivedTaskOutput, confirmDeleteTaskInput, @@ -46,6 +48,11 @@ export const contextMenuRouter = router({ .output(taskContextMenuOutput) .mutation(({ input }) => getService().showTaskContextMenu(input)), + showBulkTaskContextMenu: publicProcedure + .input(bulkTaskContextMenuInput) + .output(bulkTaskContextMenuOutput) + .mutation(({ input }) => getService().showBulkTaskContextMenu(input)), + showArchivedTaskContextMenu: publicProcedure .input(archivedTaskContextMenuInput) .output(archivedTaskContextMenuOutput) diff --git a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx b/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx index 76e50fb7e..280f8c03b 100644 --- a/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx +++ b/apps/code/src/renderer/features/sidebar/components/MainSidebar.tsx @@ -3,8 +3,16 @@ import { useWorkspaces } from "@features/workspace/hooks/useWorkspace"; import { Box } from "@radix-ui/themes"; import { useEffect } from "react"; import { useSidebarStore } from "../stores/sidebarStore"; +import { useTaskSelectionStore } from "../stores/taskSelectionStore"; import { Sidebar, SidebarContent } from "./index"; +function isEditableTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + if (target.isContentEditable) return true; + const tag = target.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; +} + export function MainSidebar() { const { data: workspaces = {}, isFetched } = useWorkspaces(); const hasCompletedOnboarding = useOnboardingStore( @@ -19,6 +27,19 @@ export function MainSidebar() { } }, [isFetched, workspaces, hasCompletedOnboarding, setOpenAuto]); + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + if (isEditableTarget(e.target)) return; + const { selectedTaskIds, clearSelection } = + useTaskSelectionStore.getState(); + if (selectedTaskIds.length === 0) return; + clearSelection(); + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + return ( diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx index 3b5615455..a9785c51d 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarItem.tsx @@ -17,10 +17,11 @@ interface SidebarItemProps { label: React.ReactNode; subtitle?: React.ReactNode; isActive?: boolean; + isSelected?: boolean; isDimmed?: boolean; draggable?: boolean; onDragStart?: (e: React.DragEvent) => void; - onClick?: () => void; + onClick?: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onContextMenu?: (e: React.MouseEvent) => void; action?: SidebarItemAction; @@ -68,6 +69,7 @@ export function SidebarItem({ label, subtitle, isActive, + isSelected, draggable, onDragStart, onClick, @@ -82,9 +84,10 @@ export function SidebarItem({ className={cn( "group flex w-full cursor-default text-left text-[13px] leading-snug transition-colors", "focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-accent-8", - "disabled:opacity-100 data-active:bg-fill-selected", + "disabled:opacity-100 data-active:bg-fill-selected data-selected:bg-(--gray-3)", )} data-active={isActive || undefined} + data-selected={(isSelected && !isActive) || undefined} draggable={draggable} onDragStart={onDragStart} style={{ diff --git a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx index b2159b79b..f6d8a47c1 100644 --- a/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx +++ b/apps/code/src/renderer/features/sidebar/components/SidebarMenu.tsx @@ -8,7 +8,7 @@ import { } from "@features/inbox/utils/inboxConstants"; import { getSessionService } from "@features/sessions/service/service"; import { - archiveTaskImperative, + archiveTasksImperative, useArchiveTask, } from "@features/tasks/hooks/useArchiveTask"; import { useTasks, useUpdateTask } from "@features/tasks/hooks/useTasks"; @@ -17,6 +17,7 @@ import { useTaskContextMenu } from "@hooks/useTaskContextMenu"; import { ScrollArea, Separator } from "@posthog/quill"; import { Box, Flex } from "@radix-ui/themes"; import type { Schemas } from "@renderer/api/generated"; +import { trpcClient } from "@renderer/trpc/client"; import type { Task } from "@shared/types"; import { useCommandMenuStore } from "@stores/commandMenuStore"; import { useNavigationStore } from "@stores/navigationStore"; @@ -24,11 +25,12 @@ import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useQueryClient } from "@tanstack/react-query"; import { logger } from "@utils/logger"; import { toast } from "@utils/toast"; -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef } from "react"; import { usePinnedTasks } from "../hooks/usePinnedTasks"; import { useSidebarData } from "../hooks/useSidebarData"; import { useTaskViewed } from "../hooks/useTaskViewed"; import { useSidebarStore } from "../stores/sidebarStore"; +import { useTaskSelectionStore } from "../stores/taskSelectionStore"; import { CommandCenterItem } from "./items/CommandCenterItem"; import { InboxItem, NewTaskItem } from "./items/HomeItem"; import { McpServersItem } from "./items/McpServersItem"; @@ -133,23 +135,133 @@ function SidebarMenuComponent() { openCommandMenu(); }; - const handleTaskClick = (taskId: string) => { + const queryClient = useQueryClient(); + + const selectedTaskIds = useTaskSelectionStore((s) => s.selectedTaskIds); + const toggleTaskSelection = useTaskSelectionStore( + (s) => s.toggleTaskSelection, + ); + const selectRange = useTaskSelectionStore((s) => s.selectRange); + const clearSelection = useTaskSelectionStore((s) => s.clearSelection); + const pruneSelection = useTaskSelectionStore((s) => s.pruneSelection); + + const organizeMode = useSidebarStore((s) => s.organizeMode); + const collapsedSections = useSidebarStore((s) => s.collapsedSections); + + const allSidebarTasks = useMemo( + () => [...sidebarData.pinnedTasks, ...sidebarData.flatTasks], + [sidebarData.pinnedTasks, sidebarData.flatTasks], + ); + + const allSidebarTaskIds = useMemo( + () => allSidebarTasks.map((t) => t.id), + [allSidebarTasks], + ); + + // Ordered list of currently visible task IDs in display order. Used as the + // index for shift-click range selection so it matches what the user sees — + // in by-project mode the chronological flat order would span across project + // groups and pull in unrelated tasks. + const orderedVisibleTaskIds = useMemo(() => { + const ids: string[] = sidebarData.pinnedTasks.map((t) => t.id); + if (organizeMode === "by-project") { + for (const group of sidebarData.groupedTasks) { + if (collapsedSections.has(group.id)) continue; + for (const t of group.tasks) ids.push(t.id); + } + } else { + for (const t of sidebarData.flatTasks) ids.push(t.id); + } + return ids; + }, [ + sidebarData.pinnedTasks, + sidebarData.flatTasks, + sidebarData.groupedTasks, + organizeMode, + collapsedSections, + ]); + + useEffect(() => { + pruneSelection(allSidebarTaskIds); + }, [allSidebarTaskIds, pruneSelection]); + + // The active (routed) task is implicitly part of any bulk selection — the + // user expects to see and act on it together with cmd/shift-clicked tasks. + const activeTaskId = sidebarData.activeTaskId; + const effectiveBulkIds = useMemo(() => { + if (selectedTaskIds.length === 0) return []; + if (!activeTaskId) return selectedTaskIds; + if (selectedTaskIds.includes(activeTaskId)) return selectedTaskIds; + return [activeTaskId, ...selectedTaskIds]; + }, [activeTaskId, selectedTaskIds]); + + const handleTaskClick = (taskId: string, e: React.MouseEvent) => { + if (e.shiftKey) { + e.preventDefault(); + selectRange(taskId, orderedVisibleTaskIds, activeTaskId); + return; + } + if (e.metaKey || e.ctrlKey) { + e.preventDefault(); + toggleTaskSelection(taskId); + return; + } + + clearSelection(); const task = taskMap.get(taskId); if (task) { navigateToTask(task); } }; - const allSidebarTasks = [ - ...sidebarData.pinnedTasks, - ...sidebarData.flatTasks, - ]; + const handleBulkContextMenu = useCallback( + async (e: React.MouseEvent, taskIds: string[]) => { + e.preventDefault(); + e.stopPropagation(); + try { + const result = + await trpcClient.contextMenu.showBulkTaskContextMenu.mutate({ + taskCount: taskIds.length, + }); + if (!result.action) return; + if (result.action.type === "archive") { + const { archived, failed } = await archiveTasksImperative( + taskIds, + queryClient, + ); + clearSelection(); + if (failed === 0) { + toast.success( + `${archived} ${archived === 1 ? "task" : "tasks"} archived`, + ); + } else { + toast.error(`${archived} archived, ${failed} failed`); + } + } + } catch (error) { + logger + .scope("sidebar-menu") + .error("Failed to show bulk context menu", error); + } + }, + [queryClient, clearSelection], + ); const handleTaskContextMenu = ( taskId: string, e: React.MouseEvent, isPinned: boolean, ) => { + // Bulk menu when 2+ tasks are in the effective selection (active + cmd/shift-clicked) + // and the right-clicked task is one of them. Otherwise clear and fall through. + if (effectiveBulkIds.length > 1) { + if (effectiveBulkIds.includes(taskId)) { + handleBulkContextMenu(e, effectiveBulkIds); + return; + } + clearSelection(); + } + const task = taskMap.get(taskId); if (task) { const workspace = workspaces[taskId]; @@ -189,7 +301,6 @@ function SidebarMenuComponent() { }; const updateTask = useUpdateTask(); - const queryClient = useQueryClient(); const handleArchivePrior = useCallback( async (taskId: string) => { @@ -197,10 +308,9 @@ function SidebarMenuComponent() { const clickedTask = allVisible.find((t) => t.id === taskId); if (!clickedTask) return; - const sortKey = "createdAt" as const; - const threshold = clickedTask[sortKey]; + const threshold = clickedTask.createdAt; const priorTaskIds = allVisible - .filter((t) => t.id !== taskId && t[sortKey] < threshold) + .filter((t) => t.id !== taskId && t.createdAt < threshold) .map((t) => t.id); if (priorTaskIds.length === 0) { @@ -208,33 +318,17 @@ function SidebarMenuComponent() { return; } - const nav = useNavigationStore.getState(); - const priorSet = new Set(priorTaskIds); - if ( - nav.view.type === "task-detail" && - nav.view.data && - priorSet.has(nav.view.data.id) - ) { - nav.navigateToTaskInput(); - } - - let done = 0; - let failed = 0; - for (const id of priorTaskIds) { - try { - await archiveTaskImperative(id, queryClient, { - skipNavigate: true, - }); - done++; - } catch { - failed++; - } - } + const { archived, failed } = await archiveTasksImperative( + priorTaskIds, + queryClient, + ); if (failed === 0) { - toast.success(`${done} ${done === 1 ? "task" : "tasks"} archived`); + toast.success( + `${archived} ${archived === 1 ? "task" : "tasks"} archived`, + ); } else { - toast.error(`${done} archived, ${failed} failed`); + toast.error(`${archived} archived, ${failed} failed`); } }, [sidebarData.pinnedTasks, sidebarData.flatTasks, queryClient], @@ -355,6 +449,7 @@ function SidebarMenuComponent() { statusGroupedTasks={sidebarData.statusGroupedTasks} activeTaskId={sidebarData.activeTaskId} editingTaskId={editingTaskId} + selectedTaskIds={effectiveBulkIds} onTaskClick={handleTaskClick} onTaskDoubleClick={handleTaskDoubleClick} onTaskContextMenu={handleTaskContextMenu} diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index 6017ecca4..da9b3871d 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -42,7 +42,8 @@ interface TaskListViewProps { statusGroupedTasks: StatusGroup[]; activeTaskId: string | null; editingTaskId: string | null; - onTaskClick: (taskId: string) => void; + selectedTaskIds: string[]; + onTaskClick: (taskId: string, e: React.MouseEvent) => void; onTaskDoubleClick: (taskId: string) => void; onTaskContextMenu: ( taskId: string, @@ -77,6 +78,8 @@ function SectionLabel({ function TaskRow({ task, isActive, + isSelected, + hideHoverActions, isEditing, onClick, onDoubleClick, @@ -90,8 +93,10 @@ function TaskRow({ }: { task: TaskData; isActive: boolean; + isSelected: boolean; + hideHoverActions: boolean; isEditing: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onDoubleClick: () => void; onContextMenu: (e: React.MouseEvent, isPinned: boolean) => void; onArchive: () => void; @@ -113,6 +118,8 @@ function TaskRow({ taskId={task.id} label={task.title} isActive={isActive} + isSelected={isSelected} + hideHoverActions={hideHoverActions} isEditing={isEditing} workspaceMode={effectiveMode} worktreePath={workspace?.worktreePath ?? undefined} @@ -257,6 +264,7 @@ export function TaskListView({ statusGroupedTasks, activeTaskId, editingTaskId, + selectedTaskIds, onTaskClick, onTaskDoubleClick, onTaskContextMenu, @@ -266,6 +274,11 @@ export function TaskListView({ onTaskEditCancel, hasMore, }: TaskListViewProps) { + const selectedIdSet = useMemo( + () => new Set(selectedTaskIds), + [selectedTaskIds], + ); + const hasMultiSelection = selectedTaskIds.length > 1; const organizeMode = useSidebarStore((state) => state.organizeMode); const sortMode = useSidebarStore((state) => state.sortMode); const collapsedSections = useSidebarStore((state) => state.collapsedSections); @@ -318,29 +331,32 @@ export function TaskListView({ return groups; }, [flatTasks, timestampKey]); + const renderTaskRow = (task: TaskData, opts?: { depth?: number }) => ( + onTaskClick(task.id, e)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => onTaskContextMenu(task.id, e, isPinned)} + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={opts?.depth ?? 0} + /> + ); + return ( {pinnedTasks.length > 0 && ( <> - {pinnedTasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - /> - ))} + {pinnedTasks.map((task) => renderTaskRow(task))} )} @@ -407,27 +423,7 @@ export function TaskListView({ addSpacingBefore={false} tooltipContent={meta.description} > - {group.tasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - depth={1} - /> - ))} + {group.tasks.map((task) => renderTaskRow(task, { depth: 1 }))} ); })} @@ -476,27 +472,9 @@ export function TaskListView({ }} newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} > - {group.tasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - depth={1} - /> - ))} + {group.tasks.map((task) => + renderTaskRow(task, { depth: 1 }), + )} ); @@ -508,26 +486,7 @@ export function TaskListView({ {dateGroupedTasks.map((group, groupIndex) => ( {group.label && } - {group.tasks.map((task) => ( - onTaskClick(task.id)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => - onTaskContextMenu(task.id, e, isPinned) - } - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => - onTaskEditSubmit(task.id, newTitle) - } - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - /> - ))} + {group.tasks.map((task) => renderTaskRow(task))} ))} {hasMore && ( diff --git a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx index a2a09bf62..a5ee2a5b4 100644 --- a/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx +++ b/apps/code/src/renderer/features/sidebar/components/items/TaskItem.tsx @@ -13,6 +13,8 @@ interface TaskItemProps { taskId: string; label: string; isActive: boolean; + isSelected?: boolean; + hideHoverActions?: boolean; workspaceMode?: WorkspaceMode; worktreePath?: string; isGenerating?: boolean; @@ -27,7 +29,7 @@ interface TaskItemProps { hasDiff?: boolean; timestamp?: number; isEditing?: boolean; - onClick: () => void; + onClick: (e: React.MouseEvent) => void; onDoubleClick?: () => void; onContextMenu: (e: React.MouseEvent) => void; onArchive?: () => void; @@ -108,6 +110,8 @@ export function TaskItem({ taskId, label, isActive, + isSelected = false, + hideHoverActions = false, workspaceMode, isSuspended = false, isGenerating, @@ -152,7 +156,7 @@ export function TaskItem({ ) : null; const toolbar = - onArchive || onTogglePin ? ( + !hideHoverActions && (onArchive || onTogglePin) ? ( { + beforeEach(() => { + useTaskSelectionStore.setState({ + selectedTaskIds: [], + lastClickedId: null, + }); + }); + + it("starts empty", () => { + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([]); + expect(useTaskSelectionStore.getState().lastClickedId).toBeNull(); + }); + + it("setSelectedTaskIds de-duplicates ids", () => { + useTaskSelectionStore + .getState() + .setSelectedTaskIds(["t1", "t2", "t1", "t3", "t2"]); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t1", + "t2", + "t3", + ]); + }); + + it("setSelectedTaskIds with a single id sets lastClickedId", () => { + useTaskSelectionStore.getState().setSelectedTaskIds(["t1"]); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("setSelectedTaskIds with multiple ids preserves existing lastClickedId", () => { + useTaskSelectionStore.setState({ lastClickedId: "t1" }); + useTaskSelectionStore.getState().setSelectedTaskIds(["t2", "t3"]); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("toggleTaskSelection adds an unselected task", () => { + useTaskSelectionStore.getState().toggleTaskSelection("t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t1"]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("toggleTaskSelection removes a selected task", () => { + useTaskSelectionStore.setState({ selectedTaskIds: ["t1", "t2"] }); + + useTaskSelectionStore.getState().toggleTaskSelection("t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t2"]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t1"); + }); + + it("isTaskSelected reflects selection state", () => { + useTaskSelectionStore.setState({ selectedTaskIds: ["t2"] }); + + expect(useTaskSelectionStore.getState().isTaskSelected("t1")).toBe(false); + expect(useTaskSelectionStore.getState().isTaskSelected("t2")).toBe(true); + }); + + it("clearSelection clears all selected tasks and lastClickedId", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1", "t2"], + lastClickedId: "t2", + }); + + useTaskSelectionStore.getState().clearSelection(); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([]); + expect(useTaskSelectionStore.getState().lastClickedId).toBeNull(); + }); + + it("pruneSelection keeps only visible task ids", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1", "t2", "t3"], + }); + + useTaskSelectionStore.getState().pruneSelection(["t2", "t4"]); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t2"]); + }); + + it("pruneSelection preserves array reference when nothing is pruned", () => { + useTaskSelectionStore.setState({ selectedTaskIds: ["t1", "t2"] }); + const before = useTaskSelectionStore.getState().selectedTaskIds; + + useTaskSelectionStore.getState().pruneSelection(["t1", "t2", "t3"]); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toBe(before); + }); + + describe("selectRange", () => { + const orderedIds = ["t1", "t2", "t3", "t4", "t5"]; + + it.each([ + { direction: "forward", anchor: "t2", target: "t4" }, + { direction: "backward", anchor: "t4", target: "t2" }, + ])( + "selects a $direction range from anchor to target", + ({ anchor, target }) => { + useTaskSelectionStore.setState({ lastClickedId: anchor }); + + useTaskSelectionStore.getState().selectRange(target, orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t2", + "t3", + "t4", + ]); + }, + ); + + it("merges range with existing selection", () => { + useTaskSelectionStore.setState({ + selectedTaskIds: ["t1"], + lastClickedId: "t3", + }); + + useTaskSelectionStore.getState().selectRange("t5", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t1", + "t3", + "t4", + "t5", + ]); + }); + + it.each([ + { case: "no anchor", lastClickedId: null }, + { case: "anchor not in ordered list", lastClickedId: "t99" }, + ])("selects just the target when $case", ({ lastClickedId }) => { + if (lastClickedId) { + useTaskSelectionStore.setState({ lastClickedId }); + } + + useTaskSelectionStore.getState().selectRange("t3", orderedIds); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual(["t3"]); + }); + + it("uses fallbackAnchorId when there is no last-clicked anchor", () => { + useTaskSelectionStore.getState().selectRange("t4", orderedIds, "t2"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t2", + "t3", + "t4", + ]); + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t4"); + }); + + it("prefers lastClickedId over fallbackAnchorId when both are set", () => { + useTaskSelectionStore.setState({ lastClickedId: "t3" }); + + useTaskSelectionStore.getState().selectRange("t5", orderedIds, "t1"); + + expect(useTaskSelectionStore.getState().selectedTaskIds).toEqual([ + "t3", + "t4", + "t5", + ]); + }); + + it("updates lastClickedId to the target", () => { + useTaskSelectionStore.setState({ lastClickedId: "t1" }); + + useTaskSelectionStore.getState().selectRange("t3", orderedIds); + + expect(useTaskSelectionStore.getState().lastClickedId).toBe("t3"); + }); + }); +}); diff --git a/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts b/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts new file mode 100644 index 000000000..a14a8bc09 --- /dev/null +++ b/apps/code/src/renderer/features/sidebar/stores/taskSelectionStore.ts @@ -0,0 +1,89 @@ +import { create } from "zustand"; + +interface TaskSelectionState { + selectedTaskIds: string[]; + /** The last task ID that was clicked — used as the anchor for shift-click range selection. */ + lastClickedId: string | null; +} + +interface TaskSelectionActions { + /** Replace the entire selection (plain click). */ + setSelectedTaskIds: (taskIds: string[]) => void; + /** Toggle a single task in/out of the selection (cmd-click). */ + toggleTaskSelection: (taskId: string) => void; + /** Select a contiguous range from the last-clicked task to `toId` within the given ordered list. + * Existing selection outside the range is preserved (shift-click behavior). + * If there is no last-clicked anchor (e.g. the user just navigated via a plain click), + * `fallbackAnchorId` is used — typically the currently active/routed task. */ + selectRange: ( + toId: string, + orderedIds: string[], + fallbackAnchorId?: string | null, + ) => void; + isTaskSelected: (taskId: string) => boolean; + clearSelection: () => void; + pruneSelection: (visibleTaskIds: string[]) => void; +} + +type TaskSelectionStore = TaskSelectionState & TaskSelectionActions; + +export const useTaskSelectionStore = create()( + (set, get) => ({ + selectedTaskIds: [], + lastClickedId: null, + + setSelectedTaskIds: (taskIds) => + set({ + selectedTaskIds: Array.from(new Set(taskIds)), + lastClickedId: taskIds.length === 1 ? taskIds[0] : get().lastClickedId, + }), + + toggleTaskSelection: (taskId) => + set((state) => { + const isRemoving = state.selectedTaskIds.includes(taskId); + return { + selectedTaskIds: isRemoving + ? state.selectedTaskIds.filter((id) => id !== taskId) + : [...state.selectedTaskIds, taskId], + lastClickedId: taskId, + }; + }), + + selectRange: (toId, orderedIds, fallbackAnchorId) => + set((state) => { + const anchorId = state.lastClickedId ?? fallbackAnchorId ?? null; + if (!anchorId) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const anchorIndex = orderedIds.indexOf(anchorId); + const toIndex = orderedIds.indexOf(toId); + if (anchorIndex === -1 || toIndex === -1) { + return { selectedTaskIds: [toId], lastClickedId: toId }; + } + const start = Math.min(anchorIndex, toIndex); + const end = Math.max(anchorIndex, toIndex); + const rangeIds = orderedIds.slice(start, end + 1); + const merged = Array.from( + new Set([...state.selectedTaskIds, ...rangeIds]), + ); + return { selectedTaskIds: merged, lastClickedId: toId }; + }), + + isTaskSelected: (taskId) => get().selectedTaskIds.includes(taskId), + + clearSelection: () => set({ selectedTaskIds: [], lastClickedId: null }), + + pruneSelection: (visibleTaskIds) => { + const visibleIds = new Set(visibleTaskIds); + set((state) => { + const filtered = state.selectedTaskIds.filter((id) => + visibleIds.has(id), + ); + if (filtered.length === state.selectedTaskIds.length) { + return state; + } + return { selectedTaskIds: filtered }; + }); + }, + }), +); diff --git a/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts index 88f3c3a91..487293488 100644 --- a/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts +++ b/apps/code/src/renderer/features/sidebar/utils/groupByStatus.test.ts @@ -14,52 +14,75 @@ function task(id: string, overrides: StatusGroupableTask = {}): TestTask { } describe("groupByStatus", () => { - it("buckets tasks by their status into the expected groups", () => { - const tasks: TestTask[] = [ - task("idle-1"), - task("idle-2", { taskRunStatus: "not_started" }), - task("active-1", { isGenerating: true }), - task("active-2", { taskRunStatus: "in_progress" }), - task("active-3", { taskRunStatus: "queued" }), - task("done-1", { taskRunStatus: "completed" }), - task("failed-1", { taskRunStatus: "failed" }), - task("failed-2", { taskRunStatus: "cancelled" }), - task("needs-1", { needsPermission: true }), - ]; - - const groups = groupByStatus(tasks); - const byId = new Map(groups.map((g) => [g.id, g] as const)); - - expect(byId.get(STATUS_GROUP_IDS.needsYou)?.tasks.map((t) => t.id)).toEqual( - ["needs-1"], - ); - expect(byId.get(STATUS_GROUP_IDS.active)?.tasks.map((t) => t.id)).toEqual([ - "active-1", - "active-2", - "active-3", - ]); - expect(byId.get(STATUS_GROUP_IDS.done)?.tasks.map((t) => t.id)).toEqual([ - "done-1", - ]); - expect(byId.get(STATUS_GROUP_IDS.failed)?.tasks.map((t) => t.id)).toEqual([ - "failed-1", - "failed-2", - ]); - expect(byId.get(STATUS_GROUP_IDS.idle)?.tasks.map((t) => t.id)).toEqual([ - "idle-1", - "idle-2", - ]); + describe("bucket assignment", () => { + it.each<[string, StatusGroupableTask, string]>([ + ["needsPermission", { needsPermission: true }, STATUS_GROUP_IDS.needsYou], + ["isGenerating", { isGenerating: true }, STATUS_GROUP_IDS.active], + [ + "taskRunStatus=queued", + { taskRunStatus: "queued" }, + STATUS_GROUP_IDS.active, + ], + [ + "taskRunStatus=in_progress", + { taskRunStatus: "in_progress" }, + STATUS_GROUP_IDS.active, + ], + [ + "taskRunStatus=completed", + { taskRunStatus: "completed" }, + STATUS_GROUP_IDS.done, + ], + [ + "taskRunStatus=failed", + { taskRunStatus: "failed" }, + STATUS_GROUP_IDS.failed, + ], + [ + "taskRunStatus=cancelled", + { taskRunStatus: "cancelled" }, + STATUS_GROUP_IDS.failed, + ], + [ + "taskRunStatus=not_started", + { taskRunStatus: "not_started" }, + STATUS_GROUP_IDS.idle, + ], + ["no signals", {}, STATUS_GROUP_IDS.idle], + ])("routes a task with %s to the %s bucket", (_label, props, expected) => { + const groups = groupByStatus([task("t", props)]); + expect(groups).toHaveLength(1); + expect(groups[0].id).toBe(expected); + expect(groups[0].tasks.map((t) => t.id)).toEqual(["t"]); + }); }); - it("prefers 'Needs you' over 'Active' when both predicates match", () => { - const tasks: TestTask[] = [ - task("both", { needsPermission: true, taskRunStatus: "in_progress" }), - ]; - - const groups = groupByStatus(tasks); - expect(groups).toHaveLength(1); - expect(groups[0].id).toBe(STATUS_GROUP_IDS.needsYou); - expect(groups[0].tasks).toHaveLength(1); + describe("predicate precedence", () => { + it.each<[string, StatusGroupableTask, string]>([ + [ + "needsPermission vs in_progress → needs-you", + { needsPermission: true, taskRunStatus: "in_progress" }, + STATUS_GROUP_IDS.needsYou, + ], + [ + "needsPermission vs isGenerating → needs-you", + { needsPermission: true, isGenerating: true }, + STATUS_GROUP_IDS.needsYou, + ], + [ + "needsPermission vs completed → needs-you", + { needsPermission: true, taskRunStatus: "completed" }, + STATUS_GROUP_IDS.needsYou, + ], + [ + "isGenerating vs completed → active", + { isGenerating: true, taskRunStatus: "completed" }, + STATUS_GROUP_IDS.active, + ], + ])("resolves %s", (_label, props, expected) => { + const groups = groupByStatus([task("t", props)]); + expect(groups[0].id).toBe(expected); + }); }); it("returns groups in the fixed display order and omits empty buckets", () => { @@ -81,13 +104,21 @@ describe("groupByStatus", () => { expect(groupByStatus([])).toEqual([]); }); - it("preserves the input order of tasks within a bucket", () => { - const tasks: TestTask[] = [ - task("a", { taskRunStatus: "completed" }), - task("b", { taskRunStatus: "completed" }), - task("c", { taskRunStatus: "completed" }), - ]; - const groups = groupByStatus(tasks); - expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c"]); + describe("preserves input order within each bucket", () => { + it.each<[string, StatusGroupableTask]>([ + ["needs-you", { needsPermission: true }], + ["active", { taskRunStatus: "in_progress" }], + ["done", { taskRunStatus: "completed" }], + ["failed", { taskRunStatus: "failed" }], + ["idle", {}], + ])("for the %s bucket", (_label, props) => { + const tasks: TestTask[] = [ + task("a", props), + task("b", props), + task("c", props), + ]; + const groups = groupByStatus(tasks); + expect(groups[0].tasks.map((t) => t.id)).toEqual(["a", "b", "c"]); + }); }); }); diff --git a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts index 6552a87b2..084cb6518 100644 --- a/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts +++ b/apps/code/src/renderer/features/tasks/hooks/useArchiveTask.ts @@ -92,6 +92,35 @@ export async function archiveTaskImperative( } } +export async function archiveTasksImperative( + taskIds: string[], + queryClient: QueryClient, +): Promise<{ archived: number; failed: number }> { + if (taskIds.length === 0) return { archived: 0, failed: 0 }; + + const nav = useNavigationStore.getState(); + const idSet = new Set(taskIds); + if ( + nav.view.type === "task-detail" && + nav.view.data && + idSet.has(nav.view.data.id) + ) { + nav.navigateToTaskInput(); + } + + let archived = 0; + let failed = 0; + for (const id of taskIds) { + try { + await archiveTaskImperative(id, queryClient, { skipNavigate: true }); + archived++; + } catch { + failed++; + } + } + return { archived, failed }; +} + export function useArchiveTask() { const queryClient = useQueryClient(); From 2a3148b692c4d2694456f9b803c82842f78d1972 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Fri, 22 May 2026 16:50:53 -0300 Subject: [PATCH 4/4] fix(sidebar): inline TaskRow JSX to resolve merge conflict with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit's `renderTaskRow` extraction modified the three existing TaskRow call sites (pinned / by-project / chronological) in the same line ranges that main's multi-select PR also modified. That created an unavoidable 3-way text conflict against main and blocked the PR-level `pull_request` CI workflows from being triggered. Revert the helper extraction so those three call sites match main's TaskListView.tsx byte-for-byte, and inline the same TaskRow JSX in the new by-status branch as well. The branch now diverges from main only in the additive regions (the by-status render branch and radio item), which 3-way-merges cleanly. This walks back Greptile's "TaskRow JSX duplicated across render branches" suggestion — the refactor is a sound style improvement but can't ship together with multi-select in the same merge window without a force-push to rewrite history, which isn't an option here. Generated-By: PostHog Code Task-Id: d4c57b31-d26d-4679-8a3b-ac6c047b8403 --- .../sidebar/components/TaskListView.tsx | 114 ++++++++++++++---- 1 file changed, 88 insertions(+), 26 deletions(-) diff --git a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx index da9b3871d..1b11ec3f4 100644 --- a/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx +++ b/apps/code/src/renderer/features/sidebar/components/TaskListView.tsx @@ -331,32 +331,31 @@ export function TaskListView({ return groups; }, [flatTasks, timestampKey]); - const renderTaskRow = (task: TaskData, opts?: { depth?: number }) => ( - onTaskClick(task.id, e)} - onDoubleClick={() => onTaskDoubleClick(task.id)} - onContextMenu={(e, isPinned) => onTaskContextMenu(task.id, e, isPinned)} - onArchive={() => onTaskArchive(task.id)} - onTogglePin={() => onTaskTogglePin(task.id)} - onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} - onEditCancel={onTaskEditCancel} - timestamp={task[timestampKey]} - depth={opts?.depth ?? 0} - /> - ); - return ( {pinnedTasks.length > 0 && ( <> - {pinnedTasks.map((task) => renderTaskRow(task))} + {pinnedTasks.map((task) => ( + onTaskClick(task.id, e)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => onTaskEditSubmit(task.id, newTitle)} + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + /> + ))} )} @@ -423,7 +422,29 @@ export function TaskListView({ addSpacingBefore={false} tooltipContent={meta.description} > - {group.tasks.map((task) => renderTaskRow(task, { depth: 1 }))} + {group.tasks.map((task) => ( + onTaskClick(task.id, e)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={1} + /> + ))} ); })} @@ -472,9 +493,29 @@ export function TaskListView({ }} newTaskTooltip={`Start new task in ${folder?.name ?? group.name}`} > - {group.tasks.map((task) => - renderTaskRow(task, { depth: 1 }), - )} + {group.tasks.map((task) => ( + onTaskClick(task.id, e)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + depth={1} + /> + ))} ); @@ -486,7 +527,28 @@ export function TaskListView({ {dateGroupedTasks.map((group, groupIndex) => ( {group.label && } - {group.tasks.map((task) => renderTaskRow(task))} + {group.tasks.map((task) => ( + onTaskClick(task.id, e)} + onDoubleClick={() => onTaskDoubleClick(task.id)} + onContextMenu={(e, isPinned) => + onTaskContextMenu(task.id, e, isPinned) + } + onArchive={() => onTaskArchive(task.id)} + onTogglePin={() => onTaskTogglePin(task.id)} + onEditSubmit={(newTitle) => + onTaskEditSubmit(task.id, newTitle) + } + onEditCancel={onTaskEditCancel} + timestamp={task[timestampKey]} + /> + ))} ))} {hasMore && (