From f6f45b57778883fb5385cb608f82d89ec53f84a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:44:24 +0100 Subject: [PATCH] feat: add filter/group/sort and saved views to task sidebar (#644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- AGENTS.md | 5 +- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/models/models.go | 25 ++ apps/backend/internal/user/service/service.go | 33 ++ .../internal/user/service/service_test.go | 105 ++++++ apps/backend/internal/user/store/sqlite.go | 11 + apps/web/app/layout.tsx | 2 + .../settings/editors-settings-state.tsx | 2 + .../components/sidebar-views-sync-bridge.tsx | 9 + .../mobile/session-task-switcher-sheet.tsx | 50 ++- apps/web/components/task/new-task-button.tsx | 82 +++++ .../sidebar-filter/filter-clause-editor.tsx | 195 +++++++++++ .../filter-dimension-registry.ts | 120 +++++++ .../sidebar-filter/filter-multi-select.tsx | 115 +++++++ .../task/sidebar-filter/group-picker.tsx | 35 ++ .../sidebar-filter/sidebar-filter-bar.tsx | 70 ++++ .../sidebar-filter/sidebar-filter-popover.tsx | 171 ++++++++++ .../sidebar-filter/sidebar-view-chips.tsx | 40 +++ .../task/sidebar-filter/sort-picker.tsx | 55 ++++ .../use-filter-value-options.ts | 60 ++++ .../task/sidebar-filter/view-manager.tsx | 251 +++++++++++++++ apps/web/components/task/sidebar-mock-data.ts | 24 ++ .../components/task/task-session-sidebar.tsx | 178 ++++------- .../web/components/task/task-switcher.test.ts | 76 +---- apps/web/components/task/task-switcher.tsx | 179 +++-------- apps/web/e2e/pages/sidebar-filter-popover.ts | 112 +++++++ .../web/e2e/tests/task/sidebar-filter.spec.ts | 229 +++++++++++++ .../web/e2e/tests/task/sidebar-layout.spec.ts | 8 +- .../kanban/use-all-workflow-snapshots.ts | 7 + apps/web/hooks/use-sidebar-views-sync.ts | 42 +++ apps/web/hooks/use-user-display-settings.ts | 1 + apps/web/lib/api/domains/settings-api.ts | 2 + apps/web/lib/local-storage.ts | 37 +++ apps/web/lib/sidebar/apply-view.test.ts | 302 ++++++++++++++++++ apps/web/lib/sidebar/apply-view.ts | 230 +++++++++++++ apps/web/lib/ssr/user-settings.ts | 22 +- apps/web/lib/state/default-state.ts | 2 + apps/web/lib/state/hydration/hydrator.ts | 15 + apps/web/lib/state/slices/kanban/types.ts | 1 + .../state/slices/settings/settings-slice.ts | 1 + apps/web/lib/state/slices/settings/types.ts | 2 + .../state/slices/ui/sidebar-view-builtins.ts | 14 + .../lib/state/slices/ui/sidebar-view-types.ts | 57 ++++ .../state/slices/ui/sidebar-view-wire.test.ts | 46 +++ .../lib/state/slices/ui/sidebar-view-wire.ts | 37 +++ apps/web/lib/state/slices/ui/types.ts | 24 ++ apps/web/lib/state/slices/ui/ui-slice.ts | 273 +++++++++++++++- apps/web/lib/state/store.ts | 13 + apps/web/lib/types/http.ts | 10 + 50 files changed, 3050 insertions(+), 334 deletions(-) create mode 100644 apps/web/components/sidebar-views-sync-bridge.tsx create mode 100644 apps/web/components/task/new-task-button.tsx create mode 100644 apps/web/components/task/sidebar-filter/filter-clause-editor.tsx create mode 100644 apps/web/components/task/sidebar-filter/filter-dimension-registry.ts create mode 100644 apps/web/components/task/sidebar-filter/filter-multi-select.tsx create mode 100644 apps/web/components/task/sidebar-filter/group-picker.tsx create mode 100644 apps/web/components/task/sidebar-filter/sidebar-filter-bar.tsx create mode 100644 apps/web/components/task/sidebar-filter/sidebar-filter-popover.tsx create mode 100644 apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx create mode 100644 apps/web/components/task/sidebar-filter/sort-picker.tsx create mode 100644 apps/web/components/task/sidebar-filter/use-filter-value-options.ts create mode 100644 apps/web/components/task/sidebar-filter/view-manager.tsx create mode 100644 apps/web/components/task/sidebar-mock-data.ts create mode 100644 apps/web/e2e/pages/sidebar-filter-popover.ts create mode 100644 apps/web/e2e/tests/task/sidebar-filter.spec.ts create mode 100644 apps/web/hooks/use-sidebar-views-sync.ts create mode 100644 apps/web/lib/sidebar/apply-view.test.ts create mode 100644 apps/web/lib/sidebar/apply-view.ts create mode 100644 apps/web/lib/state/slices/ui/sidebar-view-builtins.ts create mode 100644 apps/web/lib/state/slices/ui/sidebar-view-types.ts create mode 100644 apps/web/lib/state/slices/ui/sidebar-view-wire.test.ts create mode 100644 apps/web/lib/state/slices/ui/sidebar-view-wire.ts 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({