feat: add filter/group/sort and saved views to task sidebar (#644)

* feat(web): add sidebar view types and apply-view engine

Introduces the typed representation of a sidebar view (filter clauses,
sort spec, group key) and the pure engine that applies a view to a list
of tasks to produce a filtered, sorted, grouped result.

* feat(web): add built-in sidebar view presets

Provides the four shipped presets (All tasks, No PR reviews, Active,
Archived). The 'No PR reviews' preset filters out PR-watcher tasks that
would otherwise crowd the sidebar.

* feat(web): persist sidebar views in ui store

Adds sidebar view state to the ui slice with draft semantics for saved
views, localStorage persistence of active view + user-created views, and
root store wiring.

* feat(web): add filter dimension registry and clause editor

Registry of filterable task dimensions (status, workspace, workflow, task
type, etc.) plus the primitive UI pieces — clause editor, sort picker,
group picker, and a hook that supplies operator and value options for a
selected dimension.

* feat(web): add sidebar filter bar, popover, and view manager

Composes the filter primitives into the user-facing surfaces: the top
filter bar, the popover that hosts clause editing and sort/group
selection, the chips that switch between views, and the manager for
saving, renaming, and deleting user views.

* refactor(web): extract NewTaskButton component

Pulls the Create Task button and its command-palette registration into a
standalone memoized component in preparation for reuse across sidebar
surfaces.

* feat(web): integrate filter/view system into task sidebar

Wires the filter bar, view chips, and apply-view engine into the task
sidebar (desktop and mobile sheet). Updates task-switcher to consume the
grouped list from the engine and adds mock data for tests and previews.

* test(web): add e2e coverage for sidebar filter/view system

Page object for the filter popover plus Playwright specs covering built-in
presets, clause editing, sort/group, saved-view draft semantics, and
persistence across reloads.

* refactor(web): trim sidebar built-in views to a single seeded default

Drop the "No PR reviews", "Active", and "Archived" built-in view presets
and the isBuiltIn protection flag. The sidebar now seeds a single "All
tasks" view on first load; users can rename, overwrite, or delete it like
any other view. Deletion is guarded against removing the last remaining
view so the user can never lock themselves out.

* fix(web): humanize executor type labels in sidebar filter and grouping

The executor-type filter dropdown and group-by label were rendering raw
backend identifiers like "worktree" and "local_docker". Route both
through the existing getExecutorLabel() helper so the sidebar shows
"Worktree" and "Local Docker" like the rest of the app.

* feat(web): replace "Has PR" filter with "Is PR review"

The old dimension matched any task with an associated PR, including
tasks a user manually linked. The new dimension isolates tasks created
by the GitHub PR-review watcher specifically, which is what the filter
is useful for — hiding a wall of auto-generated review tasks.

The flag is derived from the review_watch_id metadata key the backend
already stamps onto watcher-created tasks, threaded through the kanban
snapshot and into the sidebar TaskSwitcherItem. A small migration drops
unknown dimensions (including the old "hasPR") from stored views so the
popover does not crash when reading legacy data.

* fix(web): tighten sidebar filter popover layout

Reduce the popover width (w-96 → w-[22rem]), trim section padding
(p-3 → p-2.5) and label margins (mb-2 → mb-1.5), and drop the
"No filters applied." placeholder. Extract a shared SectionLabel to
keep the three section headers visually aligned.

* feat(web): show workflow step color dot in sidebar filter dropdown

Surface the step color on the workflow-step filter's value dropdown so
each option renders with the same coloured circle used elsewhere (task
switcher, kanban column, command panel). Adds an optional color field
on filter value options; only workflow-step options populate it today.

* feat(web): replace cramped in/not-in filter editor with a multiselect dropdown

The previous "in/not in" value input rendered selected values as tiny
10px pills inside a fixed-height row that overflowed and truncated as
the list of options grew. Replace it with a combobox-style multiselect
(Popover + Command) that shows a compact summary ("N selected" or the
single label with its color dot) and opens a searchable, scrollable
list with check indicators on click.

* fix(web): show workflow and step names (not UUIDs) in sidebar grouping

Group-by Workflow and Workflow Step rendered raw UUID strings as group
headers because `TaskSwitcherItem` did not carry the resolved names.
Pass `workflowName` and `workflowStepTitle` through `toSidebarItem` and
prefer them in the group extractors.

* fix(web): drop redundant true/false selector on boolean sidebar filters

Boolean dimensions (PR review, Archived, Has diff) previously exposed
both an operator (is / is not) and a value selector (true / false),
which gives four user inputs for two real states. Hide the value input
for boolean clauses and always store `true`; negation is expressed via
the operator. Rename "Is PR review" to "PR review" since the dimension
label no longer needs to read as a question.

* refactor(web): drop Sort by Repository and relabel State to Status

Sorting by repository duplicates what Group by Repository already
offers, and "State" leaks a backend term into the picker. Remove the
repository sort key (comparator, type, picker option) and rename the
state option to "Status". Stored views referencing the removed key are
migrated back to `state` at load time so the popover does not crash.

* fix(web): tighten sidebar filter popover section spacing

The FILTERS, SORT, and GROUP BY section labels each sat above their
controls with both padded containers (p-2.5) and a 6px bottom margin,
leaving visible dead space above each header. Reduce container padding
to p-2 and label margin to mb-1 so sections read as a compact stack.

* chore(web): apply prettier formatting drift in sidebar-filter area

* revert(web): undo store.ts prettier reformat to stay under max-lines cap

The packed single-line UIA declaration in store.ts is load-bearing for
the 600-line ESLint cap: the previous chore commit unpacked it to 9
lines and pushed the file to 609 lines, turning the max-lines warning
into a lint failure. Restore the packed layout (the /* prettier-ignore */
comment is there for exactly this reason).

* refactor(web): label boolean filter ops as Show/Hide instead of is/is not

* fix(web): pull section labels flush to sidebar filter popover borders

* refactor(web): inline rename and save-as into sidebar view header row

* refactor(web): split ViewHeaderRow into mode, display, and action subcomponents

* feat(backend): persist sidebar views in user settings

Adds a SidebarViews field to UserSettings (stored in the existing
users.settings JSON blob) plus validation in the service layer:
views are capped at 50, ids and names must be non-empty and unique.

* feat(web): hydrate and sync sidebar views to backend

Moves the source of truth for saved sidebar views from localStorage
to the user settings endpoint. Views hydrate from SSR on first load;
create/rename/overwrite/delete/duplicate/toggle-collapsed all PATCH
the backend with optimistic updates and revert-on-failure. The
active view id and draft remain per-device in localStorage. A
sync bridge surfaces PATCH errors as toasts and runs a one-time
migration to upload any pre-existing local views.

As a side effect, localStorage writes and PATCH side effects are
moved out of immer producers to avoid proxy-serialization risk.

* chore(web): apply prettier to store.ts sidebar actions block

* fix(web): run migrateView on SSR-hydrated sidebar views

Defensively drop clauses with unknown dimensions and reset stale sort
keys when views come from the backend, matching the localStorage load
path. Also notes the default-state module and sidebar views in the
store structure docs.

* docs: note default-state module and sidebar views in store structure

* fix(web): type sidebar view storage wrappers properly

Drop the as-unknown-as-SidebarView and as-never casts at call sites by
making the local-storage getters/setters accept the domain type and do
the JsonValue cast once inside the wrapper.

* refactor(web): stop PATCHing on sidebar group collapse toggles

collapsedGroups is per-device visual state; routing it through
mutateViews made every expand/collapse click fire a user-settings
PATCH. Keep it local (in-memory + localStorage cache); the next real
view mutation picks up whatever state is current.

* fix(web): skip sidebar view migration when settings hydrated from server

After SSR hydration the in-memory views are already the backend's
truth, so migrating re-POSTs the same data every page load. Gate the
one-shot migration on userSettings.loaded — only run if the server
didn't answer and the slice fell back to localStorage.
This commit is contained in:
José Almeida
2026-04-20 13:44:24 +01:00
committed by GitHub
parent 31140c8b9f
commit f6f45b5777
50 changed files with 3050 additions and 334 deletions
@@ -5,6 +5,8 @@ import { IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Button } from "@kandev/ui/button";
import { TaskSwitcher } from "../task-switcher";
import type { TaskSwitcherItem } from "../task-switcher";
import { applyView } from "@/lib/sidebar/apply-view";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog";
@@ -379,6 +381,46 @@ function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolea
};
}
function MobileTaskList({
tasks,
activeTaskId,
selectedTaskId,
onSelectTask,
onArchiveTask,
onDeleteTask,
deletingTaskId,
isLoading,
}: {
tasks: TaskSwitcherItem[];
activeTaskId: string | null;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onArchiveTask: (taskId: string) => void;
onDeleteTask: (taskId: string) => Promise<void> | void;
deletingTaskId: string | null;
isLoading?: boolean;
}) {
const sidebarSlice = useAppStore((s) => s.sidebarViews);
const view = useMemo(() => {
const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId);
return active ?? sidebarSlice.views[0];
}, [sidebarSlice]);
const grouped = useMemo(() => applyView(tasks, view), [tasks, view]);
return (
<TaskSwitcher
grouped={grouped}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
deletingTaskId={deletingTaskId}
isLoading={isLoading}
totalTaskCount={tasks.length}
/>
);
}
export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
open,
onOpenChange,
@@ -389,7 +431,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
const {
activeTaskId,
selectedTaskId,
steps,
workspaces,
kanbanIsLoading,
tasksWithRepositories,
@@ -438,13 +479,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<TaskSwitcher
<MobileTaskList
tasks={tasksWithRepositories}
steps={steps.map((step: KanbanState["steps"][number]) => ({
id: step.id,
title: step.title,
color: step.color,
}))}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={handleSelectTask}
@@ -0,0 +1,82 @@
"use client";
import { memo, useMemo, useState } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { useAppStore } from "@/components/state-provider";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import type { CommandItem } from "@/lib/commands/types";
import type { Task } from "@/lib/types/http";
type NewTaskButtonProps = {
workspaceId: string | null;
workflowId: string | null;
steps: Array<{
id: string;
title: string;
color?: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
onSuccess: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
};
export const NewTaskButton = memo(function NewTaskButton({
workspaceId,
workflowId,
steps,
onSuccess,
}: NewTaskButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts);
const commands = useMemo<CommandItem[]>(
() => [
{
id: "task-create",
label: "Create New Task",
group: "Tasks",
icon: <IconPlus className="size-3.5" />,
shortcut: newTaskShortcut,
keywords: ["new", "create", "task", "add"],
action: () => setDialogOpen(true),
priority: 0,
},
],
[newTaskShortcut],
);
useRegisterCommands(commands);
return (
<>
<Button
size="sm"
variant="outline"
className="h-6 gap-1 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
Task
</Button>
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={onSuccess}
/>
</>
);
});
@@ -0,0 +1,195 @@
"use client";
import { IconX } from "@tabler/icons-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import { cn } from "@/lib/utils";
import type {
FilterClause,
FilterDimension,
FilterOp,
FilterValue,
} from "@/lib/state/slices/ui/sidebar-view-types";
import { DIMENSION_METAS, getDimensionMeta, getOpLabel } from "./filter-dimension-registry";
import { useFilterValueOptions } from "./use-filter-value-options";
import { FilterMultiSelect } from "./filter-multi-select";
type ValueOption = { value: string; label: string; color?: string };
function OptionLabel({ option }: { option: ValueOption }) {
return (
<span className="flex items-center gap-1.5">
{option.color && <span className={cn("block h-2 w-2 shrink-0 rounded-full", option.color)} />}
<span className="truncate">{option.label}</span>
</span>
);
}
type Props = {
clause: FilterClause;
onChange: (next: FilterClause) => void;
onRemove: () => void;
};
function normaliseValueForDimension(
value: FilterValue,
meta: ReturnType<typeof getDimensionMeta>,
op: FilterOp,
): FilterValue {
if (meta.valueKind === "boolean") {
// Boolean clauses use the operator ("is"/"is not") to express negation; value is always `true`.
return true;
}
if (meta.valueKind === "enum") {
const multi = op === "in" || op === "not_in";
if (multi) {
if (Array.isArray(value)) return value;
return value ? [String(value)] : [];
}
return Array.isArray(value) ? (value[0] ?? "") : String(value);
}
return Array.isArray(value) ? (value[0] ?? "") : String(value);
}
export function FilterClauseEditor({ clause, onChange, onRemove }: Props) {
const meta = getDimensionMeta(clause.dimension);
const enumOptions = useFilterValueOptions(clause.dimension);
const availableOptions = meta.enumOptions ?? enumOptions;
function handleDimensionChange(next: FilterDimension) {
const nextMeta = getDimensionMeta(next);
onChange({
...clause,
dimension: next,
op: nextMeta.defaultOp,
value: nextMeta.defaultValue,
});
}
function handleOpChange(next: FilterOp) {
onChange({
...clause,
op: next,
value: normaliseValueForDimension(clause.value, meta, next),
});
}
function handleValueChange(next: FilterValue) {
onChange({ ...clause, value: next });
}
return (
<div
className="flex items-center gap-1.5 py-1"
data-testid="filter-clause-row"
data-clause-id={clause.id}
>
<Select
value={clause.dimension}
onValueChange={(v) => handleDimensionChange(v as FilterDimension)}
>
<SelectTrigger
size="sm"
className="h-7 flex-1 text-xs"
data-testid="filter-dimension-select"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DIMENSION_METAS.map((m) => (
<SelectItem key={m.dimension} value={m.dimension} className="text-xs">
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={clause.op} onValueChange={(v) => handleOpChange(v as FilterOp)}>
<SelectTrigger size="sm" className="h-7 w-24 text-xs" data-testid="filter-op-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{meta.ops.map((op) => (
<SelectItem key={op} value={op} className="text-xs">
{getOpLabel(op, meta.valueKind)}
</SelectItem>
))}
</SelectContent>
</Select>
<ValueInput clause={clause} options={availableOptions} onChange={handleValueChange} />
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 cursor-pointer text-muted-foreground hover:text-foreground"
onClick={onRemove}
data-testid="filter-clause-remove"
aria-label="Remove filter"
>
<IconX className="h-3.5 w-3.5" />
</Button>
</div>
);
}
function ValueInput({
clause,
options,
onChange,
}: {
clause: FilterClause;
options: ValueOption[];
onChange: (v: FilterValue) => void;
}) {
const meta = getDimensionMeta(clause.dimension);
if (meta.valueKind === "boolean") {
// Value is implicit (always `true`); operator ("is"/"is not") expresses the predicate.
return null;
}
if (meta.valueKind === "text") {
return (
<Input
value={String(clause.value ?? "")}
onChange={(e) => onChange(e.target.value)}
placeholder={meta.placeholder ?? "Value"}
className="h-7 flex-1 text-xs"
data-testid="filter-value-input"
/>
);
}
const multi = clause.op === "in" || clause.op === "not_in";
if (multi) {
const selected = Array.isArray(clause.value) ? clause.value.map(String) : [];
return (
<FilterMultiSelect options={options} selected={selected} onChange={(v) => onChange(v)} />
);
}
const current = String(clause.value ?? "");
return (
<Select value={current} onValueChange={(v) => onChange(v)}>
<SelectTrigger size="sm" className="h-7 flex-1 text-xs" data-testid="filter-value-select">
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent>
{options.length === 0 && (
<SelectItem value="__empty__" disabled className="text-xs">
No options
</SelectItem>
)}
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
<OptionLabel option={opt} />
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,120 @@
import type { FilterDimension, FilterOp } from "@/lib/state/slices/ui/sidebar-view-types";
export type DimensionValueKind = "boolean" | "enum" | "text";
export type DimensionMeta = {
dimension: FilterDimension;
label: string;
valueKind: DimensionValueKind;
ops: FilterOp[];
enumOptions?: Array<{ value: string; label: string }>;
placeholder?: string;
defaultOp: FilterOp;
defaultValue: string | string[] | boolean;
};
const STATE_OPTIONS = [
{ value: "review", label: "Review" },
{ value: "in_progress", label: "In progress" },
{ value: "backlog", label: "Backlog" },
];
export const DIMENSION_METAS: DimensionMeta[] = [
{
dimension: "isPRReview",
label: "PR review",
valueKind: "boolean",
ops: ["is", "is_not"],
defaultOp: "is",
defaultValue: true,
},
{
dimension: "archived",
label: "Archived",
valueKind: "boolean",
ops: ["is", "is_not"],
defaultOp: "is",
defaultValue: true,
},
{
dimension: "hasDiff",
label: "Has diff",
valueKind: "boolean",
ops: ["is", "is_not"],
defaultOp: "is",
defaultValue: true,
},
{
dimension: "state",
label: "State",
valueKind: "enum",
ops: ["in", "not_in", "is", "is_not"],
enumOptions: STATE_OPTIONS,
defaultOp: "in",
defaultValue: ["review", "in_progress"],
},
{
dimension: "workflow",
label: "Workflow",
valueKind: "enum",
ops: ["is", "is_not", "in", "not_in"],
defaultOp: "is",
defaultValue: "",
},
{
dimension: "workflowStep",
label: "Workflow step",
valueKind: "enum",
ops: ["is", "is_not", "in", "not_in"],
defaultOp: "is",
defaultValue: "",
},
{
dimension: "executorType",
label: "Executor type",
valueKind: "enum",
ops: ["is", "is_not", "in", "not_in"],
defaultOp: "is",
defaultValue: "",
},
{
dimension: "repository",
label: "Repository",
valueKind: "enum",
ops: ["is", "is_not", "in", "not_in"],
defaultOp: "is",
defaultValue: "",
},
{
dimension: "titleMatch",
label: "Title",
valueKind: "text",
ops: ["matches", "not_matches"],
placeholder: "Substring...",
defaultOp: "matches",
defaultValue: "",
},
];
export function getDimensionMeta(dim: FilterDimension): DimensionMeta {
const meta = DIMENSION_METAS.find((m) => m.dimension === dim);
if (!meta) throw new Error(`Unknown filter dimension: ${dim}`);
return meta;
}
export const OP_LABELS: Record<FilterOp, string> = {
is: "is",
is_not: "is not",
in: "in",
not_in: "not in",
matches: "contains",
not_matches: "does not contain",
};
export function getOpLabel(op: FilterOp, valueKind: DimensionValueKind): string {
if (valueKind === "boolean") {
if (op === "is") return "Show";
if (op === "is_not") return "Hide";
}
return OP_LABELS[op];
}
@@ -0,0 +1,115 @@
"use client";
import { useState } from "react";
import { IconChevronDown } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@kandev/ui/command";
import { cn } from "@/lib/utils";
export type MultiSelectOption = { value: string; label: string; color?: string };
type Props = {
options: MultiSelectOption[];
selected: string[];
onChange: (next: string[]) => void;
placeholder?: string;
searchPlaceholder?: string;
};
export function FilterMultiSelect({
options,
selected,
onChange,
placeholder = "Select values",
searchPlaceholder = "Search…",
}: Props) {
const [open, setOpen] = useState(false);
const selectedSet = new Set(selected);
const labelByValue = new Map(options.map((o) => [o.value, o.label]));
const colorByValue = new Map(options.map((o) => [o.value, o.color]));
function toggle(value: string) {
const next = new Set(selectedSet);
if (next.has(value)) next.delete(value);
else next.add(value);
onChange([...next]);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-testid="filter-value-multi"
className="flex h-7 min-w-0 flex-1 cursor-pointer items-center gap-1 rounded-md border border-input bg-transparent px-2 text-xs transition-colors hover:bg-accent/40"
>
<MultiSelectSummary
selected={selected}
labelByValue={labelByValue}
colorByValue={colorByValue}
placeholder={placeholder}
/>
<IconChevronDown className="ml-auto h-3 w-3 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent
className="w-[14rem] p-0"
align="start"
data-testid="filter-value-multi-popover"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandList>
<CommandEmpty>No options.</CommandEmpty>
{options.map((opt) => {
const checked = selectedSet.has(opt.value);
return (
<CommandItem
key={opt.value}
value={opt.label}
onSelect={() => toggle(opt.value)}
data-checked={checked}
data-testid="filter-value-multi-option"
data-value={opt.value}
data-active={checked}
>
{opt.color && (
<span className={cn("mr-1 block h-2 w-2 shrink-0 rounded-full", opt.color)} />
)}
<span className="truncate">{opt.label}</span>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
function MultiSelectSummary({
selected,
labelByValue,
colorByValue,
placeholder,
}: {
selected: string[];
labelByValue: Map<string, string>;
colorByValue: Map<string, string | undefined>;
placeholder: string;
}) {
if (selected.length === 0) {
return <span className="truncate text-muted-foreground">{placeholder}</span>;
}
if (selected.length === 1) {
const value = selected[0];
const color = colorByValue.get(value);
return (
<span className="flex min-w-0 items-center gap-1.5">
{color && <span className={cn("block h-2 w-2 shrink-0 rounded-full", color)} />}
<span className="truncate">{labelByValue.get(value) ?? value}</span>
</span>
);
}
return <span className="truncate">{selected.length} selected</span>;
}
@@ -0,0 +1,35 @@
"use client";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import type { GroupKey } from "@/lib/state/slices/ui/sidebar-view-types";
const GROUP_OPTIONS: Array<{ key: GroupKey; label: string }> = [
{ key: "none", label: "None" },
{ key: "repository", label: "Repository" },
{ key: "workflow", label: "Workflow" },
{ key: "workflowStep", label: "Workflow step" },
{ key: "executorType", label: "Executor type" },
{ key: "state", label: "State" },
];
type Props = {
value: GroupKey;
onChange: (next: GroupKey) => void;
};
export function GroupPicker({ value, onChange }: Props) {
return (
<Select value={value} onValueChange={(v) => onChange(v as GroupKey)}>
<SelectTrigger size="sm" className="h-7 w-full text-xs" data-testid="group-key-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{GROUP_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,70 @@
"use client";
import { useMemo, useState } from "react";
import { IconAdjustments } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import type { CommandItem } from "@/lib/commands/types";
import { SidebarViewChips } from "./sidebar-view-chips";
import { SidebarFilterPopover } from "./sidebar-filter-popover";
export function SidebarFilterBar() {
const [open, setOpen] = useState(false);
const draft = useAppStore((s) => s.sidebarViews.draft);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const views = useAppStore((s) => s.sidebarViews.views);
const setActiveView = useAppStore((s) => s.setSidebarActiveView);
const hasDraft = !!draft && draft.baseViewId === activeViewId;
const commands = useMemo<CommandItem[]>(() => {
const list: CommandItem[] = [
{
id: "sidebar-open-filter",
label: "Open sidebar filters",
group: "Sidebar",
keywords: ["filter", "sort", "group", "view", "sidebar"],
action: () => setOpen(true),
},
];
for (const view of views) {
list.push({
id: `sidebar-switch-view-${view.id}`,
label: `Switch sidebar view: ${view.name}`,
group: "Sidebar",
keywords: ["view", "switch", "sidebar", view.name.toLowerCase()],
action: () => setActiveView(view.id),
});
}
return list;
}, [views, setActiveView]);
useRegisterCommands(commands);
return (
<div data-testid="sidebar-filter-bar" className="flex items-center gap-1 border-b px-2 py-1">
<SidebarViewChips />
<SidebarFilterPopover
open={open}
onOpenChange={setOpen}
trigger={
<Button
type="button"
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 cursor-pointer"
data-testid="sidebar-filter-gear"
aria-label="Sidebar filters"
>
<IconAdjustments className="h-4 w-4" />
{hasDraft && (
<span
className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-amber-500"
data-testid="sidebar-filter-gear-indicator"
/>
)}
</Button>
}
/>
</div>
);
}
@@ -0,0 +1,171 @@
"use client";
import { useMemo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import { Button } from "@kandev/ui/button";
import { useAppStore } from "@/components/state-provider";
import type {
FilterClause,
GroupKey,
SidebarView,
SidebarViewDraft,
SortSpec,
} from "@/lib/state/slices/ui/sidebar-view-types";
import { DIMENSION_METAS } from "./filter-dimension-registry";
import { FilterClauseEditor } from "./filter-clause-editor";
import { SortPicker } from "./sort-picker";
import { GroupPicker } from "./group-picker";
import { ViewHeaderRow } from "./view-manager";
type Props = {
trigger: React.ReactNode;
open: boolean;
onOpenChange: (open: boolean) => void;
};
function makeClauseId(): string {
return `c-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
}
type Current = { filters: FilterClause[]; sort: SortSpec; group: GroupKey };
function resolveCurrent(
activeView: SidebarView | undefined,
storedDraft: SidebarViewDraft | null,
): Current {
if (storedDraft && activeView && storedDraft.baseViewId === activeView.id) {
return { filters: storedDraft.filters, sort: storedDraft.sort, group: storedDraft.group };
}
if (!activeView) {
return { filters: [], sort: { key: "state", direction: "asc" }, group: "none" };
}
return { filters: activeView.filters, sort: activeView.sort, group: activeView.group };
}
export function SidebarFilterPopover({ trigger, open, onOpenChange }: Props) {
const views = useAppStore((s) => s.sidebarViews.views);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const storedDraft = useAppStore((s) => s.sidebarViews.draft);
const updateDraft = useAppStore((s) => s.updateSidebarDraft);
const saveAs = useAppStore((s) => s.saveSidebarDraftAs);
const saveOverwrite = useAppStore((s) => s.saveSidebarDraftOverwrite);
const discard = useAppStore((s) => s.discardSidebarDraft);
const deleteView = useAppStore((s) => s.deleteSidebarView);
const renameView = useAppStore((s) => s.renameSidebarView);
const activeView: SidebarView | undefined = useMemo(
() => views.find((v) => v.id === activeViewId),
[views, activeViewId],
);
const current = useMemo(() => resolveCurrent(activeView, storedDraft), [storedDraft, activeView]);
const hasDraft = !!storedDraft && activeView?.id === storedDraft.baseViewId;
function handleAddFilter() {
const defaultDim = DIMENSION_METAS[0];
const newClause: FilterClause = {
id: makeClauseId(),
dimension: defaultDim.dimension,
op: defaultDim.defaultOp,
value: defaultDim.defaultValue,
};
updateDraft({ filters: [...current.filters, newClause] });
}
function handleChangeClause(next: FilterClause) {
updateDraft({
filters: current.filters.map((c) => (c.id === next.id ? next : c)),
});
}
function handleRemoveClause(id: string) {
updateDraft({ filters: current.filters.filter((c) => c.id !== id) });
}
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>{trigger}</PopoverTrigger>
<PopoverContent className="w-[22rem] p-0" align="end" data-testid="sidebar-filter-popover">
<div className="border-b p-2">
<ViewHeaderRow
activeView={activeView}
hasDraft={hasDraft}
canDelete={views.length > 1}
onSaveOverwrite={saveOverwrite}
onSaveAs={saveAs}
onRename={renameView}
onDiscard={discard}
onDelete={() => activeView && deleteView(activeView.id)}
/>
</div>
<FilterSection
filters={current.filters}
onAdd={handleAddFilter}
onChange={handleChangeClause}
onRemove={handleRemoveClause}
/>
<div className="border-b px-2 pt-0 pb-2">
<SectionLabel>Sort</SectionLabel>
<SortPicker value={current.sort} onChange={(sort) => updateDraft({ sort })} />
</div>
<div className="px-2 pt-0 pb-2">
<SectionLabel>Group by</SectionLabel>
<GroupPicker value={current.group} onChange={(group) => updateDraft({ group })} />
</div>
</PopoverContent>
</Popover>
);
}
const SECTION_LABEL_CLASS =
"text-[11px] font-medium uppercase leading-none tracking-wide text-muted-foreground";
function SectionLabel({ children }: { children: React.ReactNode }) {
return <span className={`-mt-1 mb-1 block ${SECTION_LABEL_CLASS}`}>{children}</span>;
}
function FilterSection({
filters,
onAdd,
onChange,
onRemove,
}: {
filters: FilterClause[];
onAdd: () => void;
onChange: (next: FilterClause) => void;
onRemove: (id: string) => void;
}) {
return (
<div className="border-b px-2 pt-0 pb-2">
<div className="-mt-1 mb-1 flex items-center justify-between">
<span className={SECTION_LABEL_CLASS}>Filters</span>
<Button
type="button"
size="sm"
variant="ghost"
className="-my-1 h-6 cursor-pointer text-xs"
onClick={onAdd}
data-testid="filter-add-button"
>
<IconPlus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
{filters.length > 0 && (
<div className="space-y-0.5">
{filters.map((clause) => (
<FilterClauseEditor
key={clause.id}
clause={clause}
onChange={onChange}
onRemove={() => onRemove(clause.id)}
/>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,40 @@
"use client";
import { cn } from "@/lib/utils";
import { useAppStore } from "@/components/state-provider";
export function SidebarViewChips() {
const views = useAppStore((s) => s.sidebarViews.views);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const setActive = useAppStore((s) => s.setSidebarActiveView);
return (
<div
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
data-testid="sidebar-view-chip-row"
>
{views.map((view) => {
const active = view.id === activeViewId;
return (
<button
type="button"
key={view.id}
onClick={() => setActive(view.id)}
data-testid="sidebar-view-chip"
data-view-id={view.id}
data-active={active}
className={cn(
"h-6 shrink-0 cursor-pointer rounded-md border px-2 text-[11px] transition-colors",
active
? "border-primary/40 bg-primary/10 text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
title={view.name}
>
<span className="truncate max-w-[120px]">{view.name}</span>
</button>
);
})}
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import { IconArrowUp, IconArrowDown } from "@tabler/icons-react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Button } from "@kandev/ui/button";
import type { SortKey, SortSpec } from "@/lib/state/slices/ui/sidebar-view-types";
const SORT_OPTIONS: Array<{ key: SortKey; label: string }> = [
{ key: "state", label: "Status" },
{ key: "updatedAt", label: "Updated" },
{ key: "createdAt", label: "Created" },
{ key: "title", label: "Title" },
];
type Props = {
value: SortSpec;
onChange: (next: SortSpec) => void;
};
export function SortPicker({ value, onChange }: Props) {
return (
<div className="flex items-center gap-1.5">
<Select value={value.key} onValueChange={(v) => onChange({ ...value, key: v as SortKey })}>
<SelectTrigger size="sm" className="h-7 flex-1 text-xs" data-testid="sort-key-select">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((opt) => (
<SelectItem key={opt.key} value={opt.key} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 cursor-pointer"
onClick={() =>
onChange({ ...value, direction: value.direction === "asc" ? "desc" : "asc" })
}
data-testid="sort-direction-toggle"
data-direction={value.direction}
aria-label={`Sort direction ${value.direction}`}
>
{value.direction === "asc" ? (
<IconArrowUp className="h-3.5 w-3.5" />
) : (
<IconArrowDown className="h-3.5 w-3.5" />
)}
</Button>
</div>
);
}
@@ -0,0 +1,60 @@
"use client";
import { useMemo } from "react";
import { useAppStore } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types";
import { getExecutorLabel } from "@/lib/executor-icons";
type Option = { value: string; label: string; color?: string };
type Snapshots = AppState["kanbanMulti"]["snapshots"];
type ReposByWorkspace = AppState["repositories"]["itemsByWorkspaceId"];
function workflowOptions(snapshots: Snapshots): Option[] {
return Object.entries(snapshots).map(([id, snap]) => ({
value: id,
label: snap.workflowName || id,
}));
}
function workflowStepOptions(snapshots: Snapshots): Option[] {
const seen = new Map<string, { label: string; color: string }>();
for (const snap of Object.values(snapshots)) {
for (const step of snap.steps) {
if (!seen.has(step.id)) seen.set(step.id, { label: step.title, color: step.color });
}
}
return [...seen.entries()].map(([value, { label, color }]) => ({ value, label, color }));
}
function executorTypeOptions(snapshots: Snapshots): Option[] {
const seen = new Set<string>();
for (const snap of Object.values(snapshots)) {
for (const task of snap.tasks) {
if (task.primaryExecutorType) seen.add(task.primaryExecutorType);
}
}
return [...seen].sort().map((v) => ({ value: v, label: getExecutorLabel(v) }));
}
function repositoryOptions(repositoriesByWorkspace: ReposByWorkspace): Option[] {
const repos = Object.values(repositoriesByWorkspace).flat();
return repos.map((r) => {
const slug =
r.provider_owner && r.provider_name ? `${r.provider_owner}/${r.provider_name}` : r.local_path;
return { value: slug, label: slug };
});
}
export function useFilterValueOptions(dimension: FilterDimension): Option[] {
const snapshots = useAppStore((s) => s.kanbanMulti.snapshots);
const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
return useMemo(() => {
if (dimension === "workflow") return workflowOptions(snapshots);
if (dimension === "workflowStep") return workflowStepOptions(snapshots);
if (dimension === "executorType") return executorTypeOptions(snapshots);
if (dimension === "repository") return repositoryOptions(repositoriesByWorkspace);
return [];
}, [dimension, snapshots, repositoriesByWorkspace]);
}
@@ -0,0 +1,251 @@
"use client";
import { useState } from "react";
import { Input } from "@kandev/ui/input";
import { Button } from "@kandev/ui/button";
import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
type HeaderMode = "view" | "rename" | "saveAs";
type HeaderProps = {
activeView: SidebarView | undefined;
hasDraft: boolean;
canDelete: boolean;
onSaveOverwrite: () => void;
onSaveAs: (name: string) => void;
onRename: (id: string, name: string) => void;
onDiscard: () => void;
onDelete: () => void;
};
export function ViewHeaderRow(props: HeaderProps) {
const [mode, setMode] = useState<HeaderMode>("view");
const [nameDraft, setNameDraft] = useState("");
const isEditing = mode !== "view";
function enterRename() {
if (!props.activeView) return;
setNameDraft(props.activeView.name);
setMode("rename");
}
function enterSaveAs() {
setNameDraft("");
setMode("saveAs");
}
function exit() {
setMode("view");
setNameDraft("");
}
function submit() {
const trimmed = nameDraft.trim();
if (!trimmed) return;
if (mode === "rename" && props.activeView) props.onRename(props.activeView.id, trimmed);
else if (mode === "saveAs") props.onSaveAs(trimmed);
exit();
}
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-1 items-center gap-2 text-xs">
<span className="text-muted-foreground">{mode === "saveAs" ? "Save as:" : "View:"}</span>
{isEditing ? (
<NameInput
mode={mode}
value={nameDraft}
onChange={setNameDraft}
onSubmit={submit}
onCancel={exit}
/>
) : (
<NameDisplay activeView={props.activeView} hasDraft={props.hasDraft} />
)}
</div>
<div className="flex items-center gap-1">
{isEditing ? (
<EditingActions
mode={mode}
canSubmit={!!nameDraft.trim()}
onSubmit={submit}
onCancel={exit}
/>
) : (
<ViewActions {...props} onRename={enterRename} onSaveAs={enterSaveAs} />
)}
</div>
</div>
);
}
function NameDisplay({
activeView,
hasDraft,
}: {
activeView: SidebarView | undefined;
hasDraft: boolean;
}) {
return (
<>
<span className="font-medium" data-testid="sidebar-filter-active-view-name">
{activeView?.name ?? "—"}
</span>
{hasDraft && (
<span
className="h-1.5 w-1.5 rounded-full bg-amber-500"
data-testid="sidebar-filter-dirty-indicator"
title="Unsaved changes"
/>
)}
</>
);
}
function NameInput({
mode,
value,
onChange,
onSubmit,
onCancel,
}: {
mode: HeaderMode;
value: string;
onChange: (v: string) => void;
onSubmit: () => void;
onCancel: () => void;
}) {
return (
<Input
autoFocus
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
if (e.key === "Escape") onCancel();
}}
placeholder={mode === "saveAs" ? "New view name" : undefined}
className="h-6 flex-1 text-xs"
data-testid={mode === "rename" ? "view-rename-input" : "view-save-as-name-input"}
/>
);
}
function EditingActions({
mode,
canSubmit,
onSubmit,
onCancel,
}: {
mode: HeaderMode;
canSubmit: boolean;
onSubmit: () => void;
onCancel: () => void;
}) {
return (
<>
<Button
type="button"
size="sm"
className="h-6 cursor-pointer text-xs"
onClick={onSubmit}
disabled={!canSubmit}
data-testid={mode === "rename" ? "view-rename-confirm" : "view-save-as-confirm"}
>
{mode === "rename" ? "Save" : "Create"}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 cursor-pointer text-xs"
onClick={onCancel}
>
Cancel
</Button>
</>
);
}
function ViewActions({
activeView,
hasDraft,
canDelete,
onSaveOverwrite,
onSaveAs,
onRename,
onDiscard,
onDelete,
}: {
activeView: SidebarView | undefined;
hasDraft: boolean;
canDelete: boolean;
onSaveOverwrite: () => void;
onSaveAs: () => void;
onRename: () => void;
onDiscard: () => void;
onDelete: () => void;
}) {
const canOverwrite = hasDraft && !!activeView;
return (
<>
{canOverwrite && (
<Button
type="button"
size="sm"
variant="outline"
className="h-6 cursor-pointer text-xs"
onClick={onSaveOverwrite}
data-testid="view-save-button"
>
Save
</Button>
)}
{hasDraft && (
<Button
type="button"
size="sm"
variant="outline"
className="h-6 cursor-pointer text-xs"
onClick={onSaveAs}
data-testid="view-save-as-button"
>
Save as…
</Button>
)}
{hasDraft && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 cursor-pointer text-xs"
onClick={onDiscard}
data-testid="view-discard-button"
>
Discard
</Button>
)}
{!hasDraft && activeView && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 cursor-pointer text-xs"
onClick={onRename}
data-testid="view-rename-button"
>
Rename
</Button>
)}
{!hasDraft && activeView && canDelete && (
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 cursor-pointer text-xs text-destructive"
onClick={onDelete}
data-testid="view-delete-button"
>
Delete
</Button>
)}
</>
);
}
@@ -0,0 +1,24 @@
import type { TaskSwitcherItem } from "./task-switcher";
// Set to true to render mock data covering all sidebar edge cases (prototype mode)
export const MOCK_SIDEBAR = false;
const MOCK_REPO = "kdlbs/kandev";
const n = Date.now();
const mins = (m: number) => new Date(n - m * 60 * 1000).toISOString();
const hrs = (h: number) => new Date(n - h * 60 * 60 * 1000).toISOString();
const base = { primarySessionId: null as null, isArchived: false } as const;
/* prettier-ignore */
export const MOCK_ITEMS: TaskSwitcherItem[] = [
{ ...base, id: "mock-1", title: "Full stack authentication migration", state: "IN_PROGRESS", sessionState: "RUNNING", repositories: [MOCK_REPO, "kdlbs/kandev-web", "kdlbs/infra"], diffStats: { additions: 88, deletions: 12 }, updatedAt: mins(2), createdAt: hrs(3) },
{ ...base, id: "mock-1a", title: "Migrate auth endpoints to new provider", state: "IN_PROGRESS", sessionState: "RUNNING", diffStats: { additions: 24, deletions: 8 }, parentTaskId: "mock-1", updatedAt: mins(1), createdAt: hrs(2) },
{ ...base, id: "mock-1b", title: "Update frontend auth flows", parentTaskId: "mock-1", createdAt: hrs(1) },
{ ...base, id: "mock-2", title: "Fix task sidebar layout", state: "IN_PROGRESS", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, diffStats: { additions: 3, deletions: 1 }, updatedAt: mins(5), createdAt: hrs(4), prInfo: { number: 547, state: "Open" } },
{ ...base, id: "mock-2a", title: "Extract RepoGroupHeader component", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, parentTaskId: "mock-2", diffStats: { additions: 45, deletions: 3 }, updatedAt: mins(10), createdAt: hrs(3) },
{ ...base, id: "mock-3", title: "Refactor token usage in CLI", repositoryPath: MOCK_REPO, createdAt: hrs(5) },
{ ...base, id: "mock-4", title: "Update dependencies", state: "COMPLETED", sessionState: "COMPLETED", repositoryPath: MOCK_REPO, diffStats: { additions: 466, deletions: 124 }, updatedAt: hrs(2), createdAt: hrs(6), prInfo: { number: 138, state: "Merged" } },
{ ...base, id: "mock-5", title: "Implement feature X with full test coverage", state: "IN_PROGRESS", sessionState: "RUNNING", repositoryPath: "myorg/other-repo", diffStats: { additions: 11, deletions: 3 }, updatedAt: mins(0.5), createdAt: hrs(1) },
{ ...base, id: "mock-5a", title: "Add unit tests", repositoryPath: "myorg/other-repo", parentTaskId: "mock-5", createdAt: mins(30) },
{ ...base, id: "mock-6", title: "Draft task — no repo assigned yet", createdAt: hrs(7) },
];
+57 -121
View File
@@ -1,22 +1,18 @@
"use client";
import { useCallback, useEffect, useMemo, useState, memo } from "react";
import type { TaskState, TaskSession, TaskSessionState, Repository, Task } from "@/lib/types/http";
import type { TaskState, TaskSession, TaskSessionState, Repository } from "@/lib/types/http";
import type { TaskPR } from "@/lib/types/github";
import type { KanbanState } from "@/lib/state/slices";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import { TaskSwitcher } from "./task-switcher";
import type { TaskSwitcherItem } from "./task-switcher";
import { applyView } from "@/lib/sidebar/apply-view";
import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar";
import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
import { TaskRenameDialog } from "./task-rename-dialog";
import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog";
import { Button } from "@kandev/ui/button";
import { PanelRoot, PanelBody } from "./panel-primitives";
import { IconPlus } from "@tabler/icons-react";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { getShortcut } from "@/lib/keyboard/shortcut-overrides";
import type { CommandItem } from "@/lib/commands/types";
import { replaceTaskUrl } from "@/lib/links";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
@@ -31,29 +27,6 @@ import { useArchivedTaskState } from "./task-archived-context";
import { useRepositories } from "@/hooks/domains/workspace/use-repositories";
import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
// Set to true to render mock data covering all sidebar edge cases (prototype mode)
const MOCK_SIDEBAR = false;
const MOCK_REPO = "kdlbs/kandev";
const n = Date.now();
const mins = (m: number) => new Date(n - m * 60 * 1000).toISOString();
const hrs = (h: number) => new Date(n - h * 60 * 60 * 1000).toISOString();
const base = { primarySessionId: null as null, isArchived: false } as const;
/* prettier-ignore */
const MOCK_ITEMS: TaskSwitcherItem[] = [
{ ...base, id: "mock-1", title: "Full stack authentication migration", state: "IN_PROGRESS", sessionState: "RUNNING", repositories: [MOCK_REPO, "kdlbs/kandev-web", "kdlbs/infra"], diffStats: { additions: 88, deletions: 12 }, updatedAt: mins(2), createdAt: hrs(3) },
{ ...base, id: "mock-1a", title: "Migrate auth endpoints to new provider", state: "IN_PROGRESS", sessionState: "RUNNING", diffStats: { additions: 24, deletions: 8 }, parentTaskId: "mock-1", updatedAt: mins(1), createdAt: hrs(2) },
{ ...base, id: "mock-1b", title: "Update frontend auth flows", parentTaskId: "mock-1", createdAt: hrs(1) },
{ ...base, id: "mock-2", title: "Fix task sidebar layout", state: "IN_PROGRESS", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, diffStats: { additions: 3, deletions: 1 }, updatedAt: mins(5), createdAt: hrs(4), prInfo: { number: 547, state: "Open" } },
{ ...base, id: "mock-2a", title: "Extract RepoGroupHeader component", sessionState: "WAITING_FOR_INPUT", repositoryPath: MOCK_REPO, parentTaskId: "mock-2", diffStats: { additions: 45, deletions: 3 }, updatedAt: mins(10), createdAt: hrs(3) },
{ ...base, id: "mock-3", title: "Refactor token usage in CLI", repositoryPath: MOCK_REPO, createdAt: hrs(5) },
{ ...base, id: "mock-4", title: "Update dependencies", state: "COMPLETED", sessionState: "COMPLETED", repositoryPath: MOCK_REPO, diffStats: { additions: 466, deletions: 124 }, updatedAt: hrs(2), createdAt: hrs(6), prInfo: { number: 138, state: "Merged" } },
{ ...base, id: "mock-5", title: "Implement feature X with full test coverage", state: "IN_PROGRESS", sessionState: "RUNNING", repositoryPath: "myorg/other-repo", diffStats: { additions: 11, deletions: 3 }, updatedAt: mins(0.5), createdAt: hrs(1) },
{ ...base, id: "mock-5a", title: "Add unit tests", repositoryPath: "myorg/other-repo", parentTaskId: "mock-5", createdAt: mins(30) },
{ ...base, id: "mock-6", title: "Draft task — no repo assigned yet", createdAt: hrs(7) },
];
/** Find a task across all workflow snapshots */
function findTaskInSnapshots(
snapshots: Record<string, { tasks: KanbanState["tasks"] }>,
@@ -66,78 +39,6 @@ function findTaskInSnapshots(
return undefined;
}
// Extracted component to isolate dialog state from sidebar
type NewTaskButtonProps = {
workspaceId: string | null;
workflowId: string | null;
steps: Array<{
id: string;
title: string;
color?: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
onSuccess: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
};
export const NewTaskButton = memo(function NewTaskButton({
workspaceId,
workflowId,
steps,
onSuccess,
}: NewTaskButtonProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const keyboardShortcuts = useAppStore((s) => s.userSettings.keyboardShortcuts);
const newTaskShortcut = getShortcut("NEW_TASK", keyboardShortcuts);
const commands = useMemo<CommandItem[]>(
() => [
{
id: "task-create",
label: "Create New Task",
group: "Tasks",
icon: <IconPlus className="size-3.5" />,
shortcut: newTaskShortcut,
keywords: ["new", "create", "task", "add"],
action: () => setDialogOpen(true),
priority: 0,
},
],
[newTaskShortcut],
);
useRegisterCommands(commands);
return (
<>
<Button
size="sm"
variant="outline"
className="h-6 gap-1 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
Task
</Button>
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={steps[0]?.id ?? null}
steps={steps}
onSuccess={onSuccess}
/>
</>
);
});
/** Look up git status directly via primarySessionId, bypassing the session list. */
function getGitStatusForTask(
task: { primarySessionId?: string | null },
@@ -181,6 +82,8 @@ function toSidebarItem(
repositorySlugById: Map<string, string | undefined>;
taskPRsByTaskId: Record<string, TaskPR | undefined>;
titleById: Map<string, string>;
workflowNameById: Map<string, string>;
stepTitleById: Map<string, string>;
},
) {
const sessionInfo = getSessionInfoForTask(
@@ -194,9 +97,6 @@ function toSidebarItem(
const repoSlug = task.repositoryId ? ctx.repositorySlugById.get(task.repositoryId) : undefined;
const pr = ctx.taskPRsByTaskId[task.id];
// Diff stats: prefer session-based computation, fall back to direct git status lookup
// via primarySessionId. Sessions may not be loaded yet, but git status arrives via
// the bulk WS subscription using primarySessionId from the kanban task.
const diffStats = resolveDiffStats(
sessionInfo.diffStats,
task,
@@ -211,7 +111,11 @@ function toSidebarItem(
sessionState: resolvedSessionState,
description: task.description,
workflowId: task._workflowId,
workflowName: ctx.workflowNameById.get(task._workflowId),
workflowStepId: task.workflowStepId as string | undefined,
workflowStepTitle: task.workflowStepId
? ctx.stepTitleById.get(task.workflowStepId as string)
: undefined,
repositoryPath: pr ? `${pr.owner}/${pr.repo}` : repoSlug,
diffStats,
isRemoteExecutor: task.isRemoteExecutor,
@@ -224,6 +128,7 @@ function toSidebarItem(
parentTaskTitle: task.parentTaskId ? ctx.titleById.get(task.parentTaskId) : undefined,
parentTaskId: task.parentTaskId ?? undefined,
prInfo: toPrInfo(pr),
isPRReview: task.isPRReview ?? false,
};
}
@@ -243,7 +148,9 @@ function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarI
sessionState: undefined,
description: undefined,
workflowId: undefined,
workflowName: undefined,
workflowStepId: undefined,
workflowStepTitle: undefined,
repositoryPath: s.archivedTaskRepositoryPath,
diffStats: undefined,
isRemoteExecutor: false,
@@ -256,6 +163,7 @@ function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarI
parentTaskTitle: undefined,
parentTaskId: undefined,
prInfo: undefined,
isPRReview: false,
};
}
@@ -303,6 +211,10 @@ function useSidebarData(workspaceId: string | null) {
]),
);
const titleById = new Map(allTasks.map((t) => [t.id, t.title]));
const workflowNameById = new Map(
Object.entries(snapshots).map(([wfId, snap]) => [wfId, snap.workflowName]),
);
const stepTitleById = new Map(allSteps.map((s) => [s.id, s.title]));
const mapCtx = {
sessionsByTaskId,
gitStatusByEnvId,
@@ -310,6 +222,8 @@ function useSidebarData(workspaceId: string | null) {
repositorySlugById,
taskPRsByTaskId: taskPRsByTaskId as Record<string, TaskPR | undefined>,
titleById,
workflowNameById,
stepTitleById,
};
const items: SidebarItem[] = allTasks.map((task) => toSidebarItem(task, mapCtx));
if (
@@ -323,6 +237,8 @@ function useSidebarData(workspaceId: string | null) {
}, [
repositoriesByWorkspace,
allTasks,
allSteps,
snapshots,
workspaceId,
sessionsByTaskId,
gitStatusByEnvId,
@@ -532,8 +448,6 @@ function useSidebarActions(store: StoreApi) {
const handleDeleteTask = useCallback(
async (taskId: string) => {
setDeletingTaskId(taskId);
// Capture active state before the async API call — the WS "task.deleted"
// handler may clear activeTaskId/activeSessionId before removeTaskFromBoard runs.
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
store.getState().tasks;
try {
@@ -581,6 +495,28 @@ function useSidebarActions(store: StoreApi) {
};
}
function useBulkGitStatusSubscription(primarySessionIds: string[]) {
const connectionStatus = useAppStore((state) => state.connection.status);
useEffect(() => {
if (connectionStatus !== "connected" || primarySessionIds.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const unsubscribes = primarySessionIds.map((id) => client.subscribeSession(id));
return () => unsubscribes.forEach((u) => u());
}, [primarySessionIds, connectionStatus]);
}
function useEffectiveSidebarView() {
const sidebarSlice = useAppStore((state) => state.sidebarViews);
return useMemo(() => {
const active = sidebarSlice.views.find((v) => v.id === sidebarSlice.activeViewId);
if (!active) return sidebarSlice.views[0];
const d = sidebarSlice.draft;
if (!d || d.baseViewId !== active.id) return active;
return { ...active, filters: d.filters, sort: d.sort, group: d.group };
}, [sidebarSlice.views, sidebarSlice.activeViewId, sidebarSlice.draft]);
}
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
workspaceId,
}: TaskSessionSidebarProps) {
@@ -592,23 +528,13 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
const {
activeTaskId,
selectedTaskId,
allSteps,
stepsByWorkflowId,
isLoadingWorkflow,
tasksWithRepositories,
primarySessionIds,
} = useSidebarData(workspaceId);
// Subscribe to all primary sessions when connected so the backend sends an immediate
// git status snapshot for each — this makes diff stats visible without clicking into a task.
const connectionStatus = useAppStore((state) => state.connection.status);
useEffect(() => {
if (connectionStatus !== "connected" || primarySessionIds.length === 0) return;
const client = getWebSocketClient();
if (!client) return;
const unsubscribes = primarySessionIds.map((id) => client.subscribeSession(id));
return () => unsubscribes.forEach((u) => u());
}, [primarySessionIds, connectionStatus]);
useBulkGitStatusSubscription(primarySessionIds);
const {
deletingTaskId,
@@ -636,15 +562,24 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
: tasksWithRepositories;
}, [tasksWithRepositories, preparingTaskId]);
const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed);
const effectiveView = useEffectiveSidebarView();
const grouped = useMemo(
() => applyView(displayTasks, effectiveView),
[displayTasks, effectiveView],
);
return (
<PanelRoot data-testid="task-sidebar">
<SidebarFilterBar />
<PanelBody className="space-y-4 p-0">
<TaskSwitcher
tasks={displayTasks}
steps={allSteps.map((step) => ({ id: step.id, title: step.title, color: step.color }))}
grouped={grouped}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
collapsedGroupKeys={effectiveView.collapsedGroups}
onToggleGroup={(groupKey) => toggleSidebarGroupCollapsed(effectiveView.id, groupKey)}
onSelectTask={handleSelectTask}
onRenameTask={handleRenameTask}
onArchiveTask={handleArchiveTask}
@@ -652,6 +587,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
onMoveToStep={handleMoveToStep}
deletingTaskId={deletingTaskId}
isLoading={isLoadingWorkflow}
totalTaskCount={displayTasks.length}
/>
</PanelBody>
<TaskRenameDialog
+9 -67
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import { classifyTask } from "./task-classify";
import { statePriority, sortByStateThenCreated, type TaskSwitcherItem } from "./task-switcher";
import { applySort } from "@/lib/sidebar/apply-view";
import type { TaskSwitcherItem } from "./task-switcher";
const EARLY = "2026-01-01T00:00:00Z";
@@ -22,78 +23,19 @@ describe("classifyTask", () => {
});
});
describe("sortByStateThenCreated (regression: silent resume reorder)", () => {
describe("applySort state (regression: silent resume reorder)", () => {
it("sorts review (turn finished) above in_progress (running)", () => {
// Regression: when a task is auto-resumed after backend restart, its
// session transiently cycles WAITING_FOR_INPUT -> STARTING -> RUNNING ->
// WAITING_FOR_INPUT. The sidebar must NOT promote it above other
// turn-finished tasks during the RUNNING flicker.
const running = task({
id: "running",
sessionState: "RUNNING",
createdAt: EARLY,
});
const waiting = task({
id: "waiting",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
expect(statePriority(waiting)).toBeLessThan(statePriority(running));
expect([running, waiting].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"waiting",
"running",
]);
});
it("does not move a transiently-RUNNING task above older turn-finished tasks", () => {
// Older turn-finished task A and a newer turn-finished task B that gets
// resumed (so its sessionState briefly flips to RUNNING). With the
// pre-fix priority (in_progress=0), B would jump to the top. With the
// fix (review=0), B stays in its original position relative to A.
const a = task({
id: "a-old-waiting",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
const b = task({
id: "b-newer-resuming",
sessionState: "RUNNING",
createdAt: "2026-01-02T00:00:00Z",
});
const sorted = [a, b].sort(sortByStateThenCreated).map((t) => t.id);
expect(sorted[0]).toBe("a-old-waiting");
expect(sorted[1]).toBe("b-newer-resuming");
const running = task({ id: "running", sessionState: "RUNNING", createdAt: EARLY });
const waiting = task({ id: "waiting", sessionState: "WAITING_FOR_INPUT", createdAt: EARLY });
const sorted = applySort([running, waiting], { key: "state", direction: "asc" });
expect(sorted.map((t) => t.id)).toEqual(["waiting", "running"]);
});
it("orders backlog after both review and in_progress", () => {
const backlog = task({ id: "bk", sessionState: undefined });
const running = task({ id: "ru", sessionState: "RUNNING" });
const review = task({ id: "rv", sessionState: "WAITING_FOR_INPUT" });
expect([backlog, running, review].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"rv",
"ru",
"bk",
]);
});
it("breaks ties within a bucket by createdAt descending", () => {
const older = task({
id: "older",
sessionState: "WAITING_FOR_INPUT",
createdAt: EARLY,
});
const newer = task({
id: "newer",
sessionState: "WAITING_FOR_INPUT",
createdAt: "2026-02-01T00:00:00Z",
});
expect([older, newer].sort(sortByStateThenCreated).map((t) => t.id)).toEqual([
"newer",
"older",
]);
const sorted = applySort([backlog, running, review], { key: "state", direction: "asc" });
expect(sorted.map((t) => t.id)).toEqual(["rv", "ru", "bk"]);
});
});
+49 -130
View File
@@ -1,6 +1,6 @@
"use client";
import { cloneElement, isValidElement, memo, useMemo, useState } from "react";
import { cloneElement, isValidElement, memo, useState } from "react";
import {
IconChevronDown,
IconArrowRight,
@@ -22,8 +22,8 @@ import {
} from "@kandev/ui/context-menu";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
import { cn } from "@/lib/utils";
import { classifyTask } from "./task-classify";
import { TaskItem } from "./task-item";
import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view";
export type TaskSwitcherItem = {
id: string;
@@ -32,7 +32,9 @@ export type TaskSwitcherItem = {
sessionState?: TaskSessionState;
description?: string;
workflowId?: string;
workflowName?: string;
workflowStepId?: string;
workflowStepTitle?: string;
repositoryPath?: string;
repositories?: string[];
diffStats?: { additions: number; deletions: number };
@@ -46,16 +48,18 @@ export type TaskSwitcherItem = {
parentTaskTitle?: string;
parentTaskId?: string;
prInfo?: { number: number; state: string };
isPRReview?: boolean;
};
type StepDef = { id: string; title: string; color?: string };
type TaskSwitcherProps = {
tasks: TaskSwitcherItem[];
steps?: StepDef[];
grouped: GroupedSidebarList;
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
collapsedGroupKeys?: string[];
onToggleGroup?: (groupKey: string) => void;
onSelectTask: (taskId: string) => void;
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
@@ -63,29 +67,9 @@ type TaskSwitcherProps = {
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
deletingTaskId?: string | null;
isLoading?: boolean;
totalTaskCount?: number;
};
type RepoGroup = {
key: string;
label: string;
isMultiRepo?: boolean;
tasks: TaskSwitcherItem[];
};
export function statePriority(task: TaskSwitcherItem): number {
const bucket = classifyTask(task.sessionState, task.state);
if (bucket === "review") return 0;
if (bucket === "in_progress") return 1;
return 2;
}
export function sortByStateThenCreated(a: TaskSwitcherItem, b: TaskSwitcherItem): number {
// Review (turn finished) first, then in_progress, then backlog; newest createdAt within bucket
return (
statePriority(a) - statePriority(b) || (b.createdAt ?? "").localeCompare(a.createdAt ?? "")
);
}
function TaskSwitcherSkeleton() {
return (
<div className="animate-pulse">
@@ -97,13 +81,15 @@ function TaskSwitcherSkeleton() {
);
}
function RepoGroupHeader({
function GroupHeader({
label,
groupKey,
count,
isCollapsed,
onToggle,
}: {
label: string;
groupKey: string;
count: number;
isCollapsed: boolean;
onToggle: () => void;
@@ -112,7 +98,9 @@ function RepoGroupHeader({
<button
type="button"
onClick={onToggle}
data-testid={`sidebar-repo-group-${label}`}
data-testid="sidebar-group-header"
data-group-key={groupKey}
data-group-label={label}
className="flex w-full items-center gap-2 bg-background px-3 py-1.5 cursor-pointer hover:bg-foreground/[0.03]"
>
<span className="flex-1 truncate text-left text-[12px] font-medium text-foreground/80">
@@ -129,12 +117,15 @@ function RepoGroupHeader({
);
}
function RepoGroupSection({
function GroupSection({
group,
subTasksByParentId,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
isCollapsed,
onToggleCollapsed,
showHeader,
onSelectTask,
onRenameTask,
onArchiveTask,
@@ -142,11 +133,14 @@ function RepoGroupSection({
onMoveToStep,
deletingTaskId,
}: {
group: RepoGroup;
group: SidebarGroup;
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
isCollapsed: boolean;
onToggleCollapsed: () => void;
showHeader: boolean;
onSelectTask: (taskId: string) => void;
onRenameTask?: (taskId: string, currentTitle: string) => void;
onArchiveTask?: (taskId: string) => void;
@@ -154,7 +148,6 @@ function RepoGroupSection({
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
deletingTaskId?: string | null;
}) {
const [isCollapsed, setIsCollapsed] = useState(false);
const totalCount = group.tasks.reduce(
(sum, t) => sum + 1 + (subTasksByParentId.get(t.id)?.length ?? 0),
0,
@@ -199,12 +192,15 @@ function RepoGroupSection({
return (
<div>
<RepoGroupHeader
label={group.label}
count={totalCount}
isCollapsed={isCollapsed}
onToggle={() => setIsCollapsed((v) => !v)}
/>
{showHeader && (
<GroupHeader
label={group.label}
groupKey={group.key}
count={totalCount}
isCollapsed={isCollapsed}
onToggle={onToggleCollapsed}
/>
)}
{!isCollapsed &&
group.tasks.map((task) => (
<div key={task.id}>
@@ -345,98 +341,13 @@ function cloneWithMenuOpen(
return children;
}
/** Partition root tasks into multi-repo, per-repo, and unassigned buckets. */
function partitionRootTasks(rootTasks: TaskSwitcherItem[]): {
multiRepoTasks: TaskSwitcherItem[];
byRepo: Map<string, TaskSwitcherItem[]>;
unassigned: TaskSwitcherItem[];
} {
const multiRepoTasks: TaskSwitcherItem[] = [];
const byRepo = new Map<string, TaskSwitcherItem[]>();
const unassigned: TaskSwitcherItem[] = [];
for (const task of rootTasks) {
if (task.repositories && task.repositories.length > 1) {
multiRepoTasks.push(task);
} else if (task.repositoryPath) {
const arr = byRepo.get(task.repositoryPath) ?? [];
arr.push(task);
byRepo.set(task.repositoryPath, arr);
} else {
unassigned.push(task);
}
}
return { multiRepoTasks, byRepo, unassigned };
}
/** Build sorted repo groups from partitioned task buckets. */
function buildRepoGroups(
multiRepoTasks: TaskSwitcherItem[],
byRepo: Map<string, TaskSwitcherItem[]>,
unassigned: TaskSwitcherItem[],
): RepoGroup[] {
const result: RepoGroup[] = [];
if (multiRepoTasks.length > 0) {
result.push({
key: "multi-repo",
label: "Multi-repo",
isMultiRepo: true,
tasks: [...multiRepoTasks].sort(sortByStateThenCreated),
});
}
for (const [slug, groupTasks] of byRepo) {
result.push({ key: slug, label: slug, tasks: [...groupTasks].sort(sortByStateThenCreated) });
}
if (unassigned.length > 0) {
// In single-repo workspaces, merge unassigned tasks into the repo group
// so every task appears under a meaningful label instead of "Unassigned".
const singleRepoGroup =
byRepo.size === 1 && multiRepoTasks.length === 0
? result.find((g) => g.key !== "multi-repo")
: undefined;
if (singleRepoGroup) {
singleRepoGroup.tasks = [...singleRepoGroup.tasks, ...unassigned].sort(
sortByStateThenCreated,
);
} else {
result.push({
key: "unassigned",
label: "Unassigned",
tasks: [...unassigned].sort(sortByStateThenCreated),
});
}
}
return result;
}
/** Group tasks by repository, separating sub-tasks from root tasks. */
function groupTasksIntoRepos(tasks: TaskSwitcherItem[]): {
groups: RepoGroup[];
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
} {
const allTaskIds = new Set(tasks.map((t) => t.id));
const subMap = new Map<string, TaskSwitcherItem[]>();
const rootTasks: TaskSwitcherItem[] = [];
for (const t of tasks) {
if (t.parentTaskId && allTaskIds.has(t.parentTaskId)) {
const arr = subMap.get(t.parentTaskId) ?? [];
arr.push(t);
subMap.set(t.parentTaskId, arr);
} else {
rootTasks.push(t);
}
}
const { multiRepoTasks, byRepo, unassigned } = partitionRootTasks(rootTasks);
return {
groups: buildRepoGroups(multiRepoTasks, byRepo, unassigned),
subTasksByParentId: subMap,
};
}
export const TaskSwitcher = memo(function TaskSwitcher({
tasks,
grouped,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
collapsedGroupKeys = [],
onToggleGroup,
onSelectTask,
onRenameTask,
onArchiveTask,
@@ -444,24 +355,32 @@ export const TaskSwitcher = memo(function TaskSwitcher({
onMoveToStep,
deletingTaskId,
isLoading = false,
totalTaskCount,
}: TaskSwitcherProps) {
const { groups, subTasksByParentId } = useMemo(() => groupTasksIntoRepos(tasks), [tasks]);
if (isLoading) return <TaskSwitcherSkeleton />;
if (tasks.length === 0) {
const totalTasks = totalTaskCount ?? grouped.groups.reduce((sum, g) => sum + g.tasks.length, 0);
if (totalTasks === 0) {
return <div className="px-3 py-3 text-xs text-muted-foreground">No tasks yet.</div>;
}
const collapsedSet = new Set(collapsedGroupKeys);
const showHeaders =
grouped.groups.length > 1 ||
(grouped.groups.length === 1 && grouped.groups[0].key !== "__all__");
return (
<div>
{groups.map((group) => (
<RepoGroupSection
{grouped.groups.map((group) => (
<GroupSection
key={group.key}
group={group}
subTasksByParentId={subTasksByParentId}
subTasksByParentId={grouped.subTasksByParentId}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
isCollapsed={collapsedSet.has(group.key)}
onToggleCollapsed={() => onToggleGroup?.(group.key)}
showHeader={showHeaders}
onSelectTask={onSelectTask}
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}