diff --git a/AGENTS.md b/AGENTS.md index 140601e2ac..e5a6db4484 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,15 +228,16 @@ SSR Fetch -> Hydrate Store -> Components Read Store -> Hooks Subscribe ``` lib/state/ ├── store.ts # Root composition +├── default-state.ts # Default state + initial state merge ├── slices/ # Domain slices │ ├── kanban/ # boards, tasks, columns │ ├── session/ # sessions, messages, turns, worktrees │ ├── session-runtime/ # shell, processes, git, context │ ├── workspace/ # workspaces, repos, branches -│ ├── settings/ # executors, agents, editors, prompts +│ ├── settings/ # executors, agents, editors, prompts (incl. userSettings) │ ├── comments/ # code review diff comments │ ├── github/ # GitHub PRs, reviews -│ └── ui/ # preview, connection, active state +│ └── ui/ # preview, connection, active state, sidebar views ├── hydration/ # SSR merge strategies hooks/domains/{kanban,session,workspace,settings,comments,github}/ # Domain-organized hooks diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 1980d502f6..de66b84109 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -60,6 +60,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS LspAutoInstallLanguages: req.LspAutoInstallLanguages, LspServerConfigs: req.LspServerConfigs, SavedLayouts: req.SavedLayouts, + SidebarViews: req.SidebarViews, DefaultUtilityAgentID: req.DefaultUtilityAgentID, DefaultUtilityModel: req.DefaultUtilityModel, KeyboardShortcuts: req.KeyboardShortcuts, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 307433d020..0a1529d159 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -31,6 +31,7 @@ type UserSettingsDTO struct { LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` + SidebarViews []models.SidebarView `json:"sidebar_views"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -72,6 +73,7 @@ type UpdateUserSettingsRequest struct { LspAutoInstallLanguages *[]string `json:"lsp_auto_install_languages,omitempty"` LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` + SidebarViews *[]models.SidebarView `json:"sidebar_views,omitempty"` DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` DefaultUtilityModel *string `json:"default_utility_model,omitempty"` KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -108,6 +110,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { LspAutoInstallLanguages: settings.LspAutoInstallLanguages, LspServerConfigs: settings.LspServerConfigs, SavedLayouts: settings.SavedLayouts, + SidebarViews: settings.SidebarViews, DefaultUtilityAgentID: settings.DefaultUtilityAgentID, DefaultUtilityModel: settings.DefaultUtilityModel, KeyboardShortcuts: settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 30e92f9a77..df9543510a 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -30,6 +30,7 @@ type UserSettings struct { LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []SavedLayout `json:"saved_layouts"` + SidebarViews []SidebarView `json:"sidebar_views"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides @@ -48,3 +49,27 @@ type SavedLayout struct { Layout json.RawMessage `json:"layout"` CreatedAt string `json:"created_at"` } + +// SidebarView represents a user-saved sidebar filter/sort/group preset. +// The payload is stored and returned as-is; the server does not interpret +// clause values beyond passing them through. +type SidebarView struct { + ID string `json:"id"` + Name string `json:"name"` + Filters []SidebarViewClause `json:"filters"` + Sort SidebarViewSort `json:"sort"` + Group string `json:"group"` + CollapsedGroups []string `json:"collapsed_groups"` +} + +type SidebarViewClause struct { + ID string `json:"id"` + Dimension string `json:"dimension"` + Op string `json:"op"` + Value json.RawMessage `json:"value"` +} + +type SidebarViewSort struct { + Key string `json:"key"` + Direction string `json:"direction"` +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 717f6bc28e..d0c76cb493 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -45,6 +45,7 @@ type UpdateUserSettingsRequest struct { LspAutoInstallLanguages *[]string LspServerConfigs *map[string]map[string]interface{} SavedLayouts *[]models.SavedLayout + SidebarViews *[]models.SidebarView DefaultUtilityAgentID *string DefaultUtilityModel *string KeyboardShortcuts *map[string]interface{} @@ -112,6 +113,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err := applySavedLayouts(settings, req); err != nil { return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } + if err := applySidebarViews(settings, req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } settings.UpdatedAt = time.Now().UTC() if err := s.repo.UpsertUserSettings(ctx, settings); err != nil { return nil, err @@ -249,6 +253,34 @@ func applySavedLayouts(settings *models.UserSettings, req *UpdateUserSettingsReq return nil } +const maxSidebarViews = 50 + +// applySidebarViews validates and applies the sidebar_views setting. +func applySidebarViews(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarViews == nil { + return nil + } + views := *req.SidebarViews + if len(views) > maxSidebarViews { + return fmt.Errorf("sidebar_views: max %d views allowed", maxSidebarViews) + } + seen := make(map[string]struct{}, len(views)) + for i := range views { + if strings.TrimSpace(views[i].ID) == "" { + return errors.New("sidebar_views: view id must not be empty") + } + if strings.TrimSpace(views[i].Name) == "" { + return errors.New("sidebar_views: view name must not be empty") + } + if _, dup := seen[views[i].ID]; dup { + return fmt.Errorf("sidebar_views: duplicate view id %q", views[i].ID) + } + seen[views[i].ID] = struct{}{} + } + settings.SidebarViews = views + return nil +} + func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models.UserSettings) { if s.eventBus == nil || settings == nil { return @@ -271,6 +303,7 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "lsp_auto_install_languages": settings.LspAutoInstallLanguages, "lsp_server_configs": settings.LspServerConfigs, "saved_layouts": settings.SavedLayouts, + "sidebar_views": settings.SidebarViews, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index f2ed9c7d50..c294442b31 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -282,3 +282,108 @@ func TestApplySavedLayouts(t *testing.T) { }) } } + +func makeSidebarViews(n int) []models.SidebarView { + views := make([]models.SidebarView, n) + for i := range views { + views[i] = models.SidebarView{ + ID: fmt.Sprintf("view-%d", i), + Name: fmt.Sprintf("View %d", i), + Filters: []models.SidebarViewClause{}, + Sort: models.SidebarViewSort{Key: "state", Direction: "asc"}, + Group: "repository", + CollapsedGroups: []string{}, + } + } + return views +} + +func TestApplySidebarViews(t *testing.T) { + tests := []struct { + name string + req *UpdateUserSettingsRequest + wantErr string + wantCount int + wantApplied bool + }{ + { + name: "nil request is a no-op", + req: &UpdateUserSettingsRequest{SidebarViews: nil}, + wantApplied: false, + }, + { + name: "empty slice is accepted", + req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{})}, + wantCount: 0, + wantApplied: true, + }, + { + name: "valid single view is applied", + req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(1))}, + wantCount: 1, + wantApplied: true, + }, + { + name: "exactly max views is accepted", + req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(maxSidebarViews))}, + wantCount: maxSidebarViews, + wantApplied: true, + }, + { + name: "exceeding max views returns error", + req: &UpdateUserSettingsRequest{SidebarViews: ptr(makeSidebarViews(maxSidebarViews + 1))}, + wantErr: fmt.Sprintf("sidebar_views: max %d views allowed", maxSidebarViews), + }, + { + name: "empty id returns error", + req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{ + {ID: "", Name: "X"}, + })}, + wantErr: "sidebar_views: view id must not be empty", + }, + { + name: "empty name returns error", + req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{ + {ID: "v1", Name: ""}, + })}, + wantErr: "sidebar_views: view name must not be empty", + }, + { + name: "duplicate ids return error", + req: &UpdateUserSettingsRequest{SidebarViews: ptr([]models.SidebarView{ + {ID: "v1", Name: "A"}, + {ID: "v1", Name: "B"}, + })}, + wantErr: `sidebar_views: duplicate view id "v1"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings := &models.UserSettings{SidebarViews: makeSidebarViews(2)} + err := applySidebarViews(settings, tt.req) + + if tt.wantErr != "" { + if err == nil { + t.Fatalf("expected error %q, got nil", tt.wantErr) + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error()) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !tt.wantApplied { + if len(settings.SidebarViews) != 2 { + t.Fatalf("expected settings unchanged (2 views), got %d", len(settings.SidebarViews)) + } + return + } + if len(settings.SidebarViews) != tt.wantCount { + t.Fatalf("expected %d views, got %d", tt.wantCount, len(settings.SidebarViews)) + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index fb5837e404..1580720ae1 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -129,6 +129,10 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod if savedLayouts == nil { savedLayouts = []models.SavedLayout{} } + sidebarViews := settings.SidebarViews + if sidebarViews == nil { + sidebarViews = []models.SidebarView{} + } keyboardShortcuts := settings.KeyboardShortcuts if keyboardShortcuts == nil { keyboardShortcuts = map[string]interface{}{} @@ -150,6 +154,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod "lsp_auto_install_languages": lspAutoInstall, "lsp_server_configs": lspServerConfigs, "saved_layouts": savedLayouts, + "sidebar_views": sidebarViews, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": keyboardShortcuts, @@ -200,6 +205,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.ChatSubmitKey = "cmd_enter" settings.KeyboardShortcuts = map[string]interface{}{} settings.TerminalLinkBehavior = "new_tab" + settings.SidebarViews = []models.SidebarView{} return settings, nil } var payload struct { @@ -219,6 +225,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LspAutoInstallLanguages []string `json:"lsp_auto_install_languages"` LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` + SidebarViews []models.SidebarView `json:"sidebar_views"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` @@ -267,6 +274,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if settings.SavedLayouts == nil { settings.SavedLayouts = []models.SavedLayout{} } + settings.SidebarViews = payload.SidebarViews + if settings.SidebarViews == nil { + settings.SidebarViews = []models.SidebarView{} + } settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel settings.KeyboardShortcuts = payload.KeyboardShortcuts diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index bcbc4e4c00..eacdf4e9b0 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -12,6 +12,7 @@ import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider"; import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider"; import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider"; import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge"; +import { SidebarViewsSyncBridge } from "@/components/sidebar-views-sync-bridge"; export const metadata: Metadata = { title: "KanDev - AI Kanban", @@ -61,6 +62,7 @@ export default function RootLayout({ + diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index 65b89214ca..81dd08ee69 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -8,6 +8,7 @@ import { useRequest } from "@/lib/http/use-request"; import type { EditorOption } from "@/lib/types/http"; import { type ComboboxOption } from "@/components/combobox"; import { parseTerminalLinkBehavior } from "@/lib/ssr/user-settings"; +import { fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import { type EditorFormState, buildConfig, @@ -219,6 +220,7 @@ function mapEditorSettingsFields( lspAutoInstallLanguages: s.lsp_auto_install_languages ?? [], lspServerConfigs: s.lsp_server_configs ?? {}, savedLayouts: s.saved_layouts ?? [], + sidebarViews: (s.sidebar_views ?? []).map(fromApiSidebarView), loaded: true as const, }; } diff --git a/apps/web/components/sidebar-views-sync-bridge.tsx b/apps/web/components/sidebar-views-sync-bridge.tsx new file mode 100644 index 0000000000..b728c8dca8 --- /dev/null +++ b/apps/web/components/sidebar-views-sync-bridge.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useSidebarViewsSync } from "@/hooks/use-sidebar-views-sync"; + +/** Mounts the sidebar views sync hook inside the ToastProvider tree. */ +export function SidebarViewsSyncBridge() { + useSidebarViewsSync(); + return null; +} diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index edefddb2a0..efff5daf94 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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; + 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 ( + + ); +} + 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({
- ({ - id: step.id, - title: step.title, - color: step.color, - }))} activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={handleSelectTask} diff --git a/apps/web/components/task/new-task-button.tsx b/apps/web/components/task/new-task-button.tsx new file mode 100644 index 0000000000..5969df53c4 --- /dev/null +++ b/apps/web/components/task/new-task-button.tsx @@ -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 }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + }; + }>; + 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( + () => [ + { + id: "task-create", + label: "Create New Task", + group: "Tasks", + icon: , + shortcut: newTaskShortcut, + keywords: ["new", "create", "task", "add"], + action: () => setDialogOpen(true), + priority: 0, + }, + ], + [newTaskShortcut], + ); + useRegisterCommands(commands); + + return ( + <> + + + + ); +}); diff --git a/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx b/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx new file mode 100644 index 0000000000..087f971d8e --- /dev/null +++ b/apps/web/components/task/sidebar-filter/filter-clause-editor.tsx @@ -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 ( + + {option.color && } + {option.label} + + ); +} + +type Props = { + clause: FilterClause; + onChange: (next: FilterClause) => void; + onRemove: () => void; +}; + +function normaliseValueForDimension( + value: FilterValue, + meta: ReturnType, + 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 ( +
+ + + + + + + +
+ ); +} + +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 ( + 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 ( + onChange(v)} /> + ); + } + + const current = String(clause.value ?? ""); + return ( + + ); +} diff --git a/apps/web/components/task/sidebar-filter/filter-dimension-registry.ts b/apps/web/components/task/sidebar-filter/filter-dimension-registry.ts new file mode 100644 index 0000000000..8760693506 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/filter-dimension-registry.ts @@ -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 = { + 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]; +} diff --git a/apps/web/components/task/sidebar-filter/filter-multi-select.tsx b/apps/web/components/task/sidebar-filter/filter-multi-select.tsx new file mode 100644 index 0000000000..d023b494b6 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/filter-multi-select.tsx @@ -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 ( + + + + + + + + + No options. + {options.map((opt) => { + const checked = selectedSet.has(opt.value); + return ( + toggle(opt.value)} + data-checked={checked} + data-testid="filter-value-multi-option" + data-value={opt.value} + data-active={checked} + > + {opt.color && ( + + )} + {opt.label} + + ); + })} + + + + + ); +} + +function MultiSelectSummary({ + selected, + labelByValue, + colorByValue, + placeholder, +}: { + selected: string[]; + labelByValue: Map; + colorByValue: Map; + placeholder: string; +}) { + if (selected.length === 0) { + return {placeholder}; + } + if (selected.length === 1) { + const value = selected[0]; + const color = colorByValue.get(value); + return ( + + {color && } + {labelByValue.get(value) ?? value} + + ); + } + return {selected.length} selected; +} diff --git a/apps/web/components/task/sidebar-filter/group-picker.tsx b/apps/web/components/task/sidebar-filter/group-picker.tsx new file mode 100644 index 0000000000..f38740652f --- /dev/null +++ b/apps/web/components/task/sidebar-filter/group-picker.tsx @@ -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 ( + + ); +} diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx new file mode 100644 index 0000000000..53f78d6085 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx @@ -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(() => { + 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 ( +
+ + + + {hasDraft && ( + + )} + + } + /> +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx new file mode 100644 index 0000000000..37e3f2ed74 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx @@ -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 ( + + {trigger} + +
+ 1} + onSaveOverwrite={saveOverwrite} + onSaveAs={saveAs} + onRename={renameView} + onDiscard={discard} + onDelete={() => activeView && deleteView(activeView.id)} + /> +
+ + + +
+ Sort + updateDraft({ sort })} /> +
+ +
+ Group by + updateDraft({ group })} /> +
+
+
+ ); +} + +const SECTION_LABEL_CLASS = + "text-[11px] font-medium uppercase leading-none tracking-wide text-muted-foreground"; + +function SectionLabel({ children }: { children: React.ReactNode }) { + return {children}; +} + +function FilterSection({ + filters, + onAdd, + onChange, + onRemove, +}: { + filters: FilterClause[]; + onAdd: () => void; + onChange: (next: FilterClause) => void; + onRemove: (id: string) => void; +}) { + return ( +
+
+ Filters + +
+ {filters.length > 0 && ( +
+ {filters.map((clause) => ( + onRemove(clause.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx new file mode 100644 index 0000000000..a91ac6c67b --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx @@ -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 ( +
+ {views.map((view) => { + const active = view.id === activeViewId; + return ( + + ); + })} +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/sort-picker.tsx b/apps/web/components/task/sidebar-filter/sort-picker.tsx new file mode 100644 index 0000000000..c563eaf33a --- /dev/null +++ b/apps/web/components/task/sidebar-filter/sort-picker.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/web/components/task/sidebar-filter/use-filter-value-options.ts b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts new file mode 100644 index 0000000000..482970a2b0 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/use-filter-value-options.ts @@ -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(); + 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(); + 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]); +} diff --git a/apps/web/components/task/sidebar-filter/view-manager.tsx b/apps/web/components/task/sidebar-filter/view-manager.tsx new file mode 100644 index 0000000000..a2faa32965 --- /dev/null +++ b/apps/web/components/task/sidebar-filter/view-manager.tsx @@ -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("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 ( +
+
+ {mode === "saveAs" ? "Save as:" : "View:"} + {isEditing ? ( + + ) : ( + + )} +
+
+ {isEditing ? ( + + ) : ( + + )} +
+
+ ); +} + +function NameDisplay({ + activeView, + hasDraft, +}: { + activeView: SidebarView | undefined; + hasDraft: boolean; +}) { + return ( + <> + + {activeView?.name ?? "—"} + + {hasDraft && ( + + )} + + ); +} + +function NameInput({ + mode, + value, + onChange, + onSubmit, + onCancel, +}: { + mode: HeaderMode; + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; +}) { + return ( + 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 ( + <> + + + + ); +} + +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 && ( + + )} + {hasDraft && ( + + )} + {hasDraft && ( + + )} + {!hasDraft && activeView && ( + + )} + {!hasDraft && activeView && canDelete && ( + + )} + + ); +} diff --git a/apps/web/components/task/sidebar-mock-data.ts b/apps/web/components/task/sidebar-mock-data.ts new file mode 100644 index 0000000000..5709d92ae2 --- /dev/null +++ b/apps/web/components/task/sidebar-mock-data.ts @@ -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) }, +]; diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index ef3260ccbc..bc337648a6 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -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, @@ -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 }>; - on_turn_complete?: Array<{ type: string; config?: Record }>; - }; - }>; - 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( - () => [ - { - id: "task-create", - label: "Create New Task", - group: "Tasks", - icon: , - shortcut: newTaskShortcut, - keywords: ["new", "create", "task", "add"], - action: () => setDialogOpen(true), - priority: 0, - }, - ], - [newTaskShortcut], - ); - useRegisterCommands(commands); - - return ( - <> - - - - ); -}); - /** 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; taskPRsByTaskId: Record; titleById: Map; + workflowNameById: Map; + stepTitleById: Map; }, ) { 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): 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): 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, 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 ( + ({ 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} /> { }); }); -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"]); }); }); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 0091d2c0c8..1a5aa19960 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -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; 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 (
@@ -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({