From 9e171c83774368a8d39dea3721467ea6a0b5fb85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 20 Sep 2026 00:09:02 +0100 Subject: [PATCH] feat: customize sidebar navigation (#3823) --- apps/backend/cmd/settings-catalog/main.go | 6 + .../internal/backendapp/boot_state_routes.go | 1 + .../internal/settingscatalog/defaults.go | 9 +- .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/models/models.go | 1 + .../user/models/sidebar_layout_test.go | 16 + .../internal/user/models/sidebar_layouts.go | 68 +++ apps/backend/internal/user/service/service.go | 11 + .../user/service/sidebar_layout_test.go | 99 +++++ .../internal/user/service/sidebar_layouts.go | 262 ++++++++++++ .../user/service/sidebar_workspace_views.go | 2 + apps/backend/internal/user/store/sqlite.go | 7 + .../app-sidebar/app-sidebar-footer.test.tsx | 27 +- .../app-sidebar/app-sidebar-footer.tsx | 11 +- .../app-sidebar/app-sidebar-primary-nav.tsx | 55 ++- .../app-sidebar/app-sidebar-section.tsx | 4 +- .../app-sidebar-workspace-picker.test.tsx | 23 + .../app-sidebar-workspace-picker.tsx | 29 +- .../components/app-sidebar/app-sidebar.tsx | 45 +- .../sections/automations-section.test.tsx | 4 +- .../sections/automations-section.tsx | 4 +- .../app-sidebar/sections/canvases-section.tsx | 4 +- .../sections/integrations-section.tsx | 17 +- .../shortcut-activity-indicator.tsx | 43 ++ .../app-sidebar/shortcut-section-actions.tsx | 356 ++++++++++++++++ .../app-sidebar/shortcut-section.test.tsx | 141 +++++++ .../app-sidebar/shortcut-section.tsx | 199 +++++++++ .../app-sidebar/sidebar-layout-navigation.tsx | 146 +++++++ .../navigation/app-nav-sections.tsx | 37 +- .../navigation/app-nav-sheet.test.tsx | 12 + .../mobile-sidebar-layout-navigation.tsx | 362 ++++++++++++++++ .../runs/use-workspace-automations.test.ts | 8 + .../runs/use-workspace-automations.ts | 11 +- .../settings/layouts/layout-settings.test.tsx | 40 +- .../settings/layouts/layout-settings.tsx | 145 +++++-- .../settings/settings-breadcrumb-labels.ts | 1 + .../sidebar-layout-editor-dnd.test.ts | 139 ++++++ .../settings/sidebar-layout-editor-dnd.ts | 92 ++++ .../sidebar-layout-editor-focused-group.tsx | 237 +++++++++++ .../sidebar-layout-editor-node-item.tsx | 383 +++++++++++++++++ .../sidebar-layout-editor-node-list.tsx | 363 ++++++++++++++++ .../sidebar-layout-editor-preview.tsx | 50 +++ .../sidebar-layout-editor-save.test.ts | 152 +++++++ .../settings/sidebar-layout-editor-save.ts | 315 ++++++++++++++ .../sidebar-layout-editor-shortcut-picker.tsx | 156 +++++++ .../settings/sidebar-layout-editor-state.ts | 192 +++++++++ .../settings/sidebar-layout-editor-types.ts | 3 + .../settings/sidebar-layout-editor-view.tsx | 135 ++++++ .../settings/sidebar-layout-editor.test.tsx | 395 ++++++++++++++++++ .../settings/sidebar-layout-editor.tsx | 273 ++++++++++++ apps/web/e2e/helpers/api-client.ts | 6 + .../mobile-sidebar-customization.spec.ts | 106 +++++ .../settings/sidebar-customization.spec.ts | 181 ++++++++ .../sidebar/use-shortcut-activity.test.ts | 129 ++++++ .../domains/sidebar/use-shortcut-activity.ts | 102 +++++ .../sidebar/use-sidebar-layout-navigation.ts | 81 ++++ .../use-sidebar-shortcut-catalog.test.ts | 95 +++++ .../sidebar/use-sidebar-shortcut-catalog.ts | 141 +++++++ .../hooks/use-ensure-user-settings.test.ts | 1 + .../lib/settings-discovery/catalog.test.ts | 10 + .../lib/settings-discovery/catalog/index.ts | 1 + .../settings-discovery/catalog/preferences.ts | 11 + .../contract.generated.json | 19 + .../profile-contract.generated.json | 1 + .../web/lib/sidebar/layout-operations.test.ts | 130 ++++++ apps/web/lib/sidebar/layout-operations.ts | 329 +++++++++++++++ .../web/lib/sidebar/layout-projection.test.ts | 126 ++++++ apps/web/lib/sidebar/layout-projection.ts | 193 +++++++++ apps/web/lib/sidebar/layout-types.ts | 144 +++++++ apps/web/lib/sidebar/shortcut-catalog.test.ts | 96 +++++ apps/web/lib/sidebar/shortcut-catalog.ts | 124 ++++++ apps/web/lib/ssr/user-settings.test.ts | 16 + apps/web/lib/ssr/user-settings.ts | 2 + apps/web/lib/state/slices/settings/types.ts | 2 + apps/web/lib/types/http-user-settings.ts | 32 ++ apps/web/src/locales/en/settings.json | 30 +- apps/web/src/locales/pseudo/settings.json | 30 +- apps/web/src/locales/pt-pt/settings.json | 30 +- apps/web/src/locales/zh-cn/settings.json | 30 +- apps/web/src/locales/zh-hk/settings.json | 30 +- apps/web/src/locales/zh-tw/settings.json | 30 +- apps/web/src/settings-routes.test.ts | 8 + apps/web/src/settings-routes.tsx | 2 + docs/plans/sidebar-customization/plan.md | 322 ++++++++++++++ .../task-01-persist-layouts.md | 88 ++++ .../task-02-resolve-shortcuts.md | 93 +++++ .../task-03-edit-layouts.md | 197 +++++++++ .../task-04-render-navigation.md | 181 ++++++++ .../task-05-show-activity.md | 129 ++++++ docs/public/coverage.json | 20 +- docs/public/use-kandev.md | 17 + .../ui/requirements/sidebar-customization.md | 75 ++++ .../ui/system-design/sidebar-customization.md | 262 ++++++++++++ 94 files changed, 8632 insertions(+), 115 deletions(-) create mode 100644 apps/backend/internal/user/models/sidebar_layout_test.go create mode 100644 apps/backend/internal/user/models/sidebar_layouts.go create mode 100644 apps/backend/internal/user/service/sidebar_layout_test.go create mode 100644 apps/backend/internal/user/service/sidebar_layouts.go create mode 100644 apps/web/components/app-sidebar/shortcut-activity-indicator.tsx create mode 100644 apps/web/components/app-sidebar/shortcut-section-actions.tsx create mode 100644 apps/web/components/app-sidebar/shortcut-section.test.tsx create mode 100644 apps/web/components/app-sidebar/shortcut-section.tsx create mode 100644 apps/web/components/app-sidebar/sidebar-layout-navigation.tsx create mode 100644 apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-dnd.test.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-dnd.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-focused-group.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-node-item.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-node-list.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-preview.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-save.test.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-save.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-shortcut-picker.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor-state.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-types.ts create mode 100644 apps/web/components/settings/sidebar-layout-editor-view.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor.test.tsx create mode 100644 apps/web/components/settings/sidebar-layout-editor.tsx create mode 100644 apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts create mode 100644 apps/web/e2e/tests/settings/sidebar-customization.spec.ts create mode 100644 apps/web/hooks/domains/sidebar/use-shortcut-activity.test.ts create mode 100644 apps/web/hooks/domains/sidebar/use-shortcut-activity.ts create mode 100644 apps/web/hooks/domains/sidebar/use-sidebar-layout-navigation.ts create mode 100644 apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.test.ts create mode 100644 apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.ts create mode 100644 apps/web/lib/sidebar/layout-operations.test.ts create mode 100644 apps/web/lib/sidebar/layout-operations.ts create mode 100644 apps/web/lib/sidebar/layout-projection.test.ts create mode 100644 apps/web/lib/sidebar/layout-projection.ts create mode 100644 apps/web/lib/sidebar/layout-types.ts create mode 100644 apps/web/lib/sidebar/shortcut-catalog.test.ts create mode 100644 apps/web/lib/sidebar/shortcut-catalog.ts create mode 100644 docs/plans/sidebar-customization/plan.md create mode 100644 docs/plans/sidebar-customization/task-01-persist-layouts.md create mode 100644 docs/plans/sidebar-customization/task-02-resolve-shortcuts.md create mode 100644 docs/plans/sidebar-customization/task-03-edit-layouts.md create mode 100644 docs/plans/sidebar-customization/task-04-render-navigation.md create mode 100644 docs/plans/sidebar-customization/task-05-show-activity.md create mode 100644 docs/specs/ui/requirements/sidebar-customization.md create mode 100644 docs/specs/ui/system-design/sidebar-customization.md diff --git a/apps/backend/cmd/settings-catalog/main.go b/apps/backend/cmd/settings-catalog/main.go index 21688db170..58995a0c30 100644 --- a/apps/backend/cmd/settings-catalog/main.go +++ b/apps/backend/cmd/settings-catalog/main.go @@ -115,6 +115,12 @@ func mutableFieldInventory() map[string][]string { // standalone user-settings catalog field. "sidebar_view_state": {}, }) + for index, fieldPath := range userFields { + if fieldPath == "sidebar_layout_state" { + // The request envelope updates the persisted workspace map atomically. + userFields[index] = "sidebar_layouts_by_workspace" + } + } // sidebar_task_colors is the public patch alias. The DTO stores its // normalized form as sidebar_task_color_patch before calling the service. userFields = append(userFields, "sidebar_task_colors") diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index fc364f945e..04fa25528d 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -712,6 +712,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "savedLayouts": settings.SavedLayouts, "sidebarViews": mapSidebarViews(settings.SidebarViews), "sidebarViewsByWorkspace": settings.SidebarViewsByWorkspace, + "sidebarLayoutsByWorkspace": settings.SidebarLayoutsByWorkspace, "sidebarActiveViewId": nullString(settings.SidebarActiveViewID), "sidebarDraft": mapSidebarDraft(settings.SidebarDraft), "threadViews": mapThreadViews(settings.ThreadViews), diff --git a/apps/backend/internal/settingscatalog/defaults.go b/apps/backend/internal/settingscatalog/defaults.go index e7f34527a9..0593ebe817 100644 --- a/apps/backend/internal/settingscatalog/defaults.go +++ b/apps/backend/internal/settingscatalog/defaults.go @@ -637,7 +637,7 @@ func userSettingsDomain() DomainDescriptor { "show_scroll_to_start", "scroll to start", "boolean", "show_transcript_auto_scroll_control", "transcript scroll control", "boolean", "show_todo_list_panel", "todo list panel", "boolean", "show_todo_list_panel_only_when_not_empty", "todo list empty state", "boolean", "show_release_notification", "release notification", "boolean", "release_notes_last_seen_version", "release notes version", "string", "lsp_auto_start_languages", "LSP auto start", "array", "lsp_auto_install_languages", "LSP auto install", "array", "lsp_server_configs", "LSP server configs", "object", - "lsp_status_location", "LSP status location", "string", "saved_layouts", "saved layouts", "array", "sidebar_views", "sidebar views", "array", + "lsp_status_location", "LSP status location", "string", "saved_layouts", "saved layouts", "array", "sidebar_views", "sidebar views", "array", "sidebar_layout_state", "sidebar layout state", "object", "sidebar_active_view_id", "active sidebar view", "string", "sidebar_draft", "sidebar draft", "object", "thread_views", "thread views", "array", "thread_active_view_id", "active thread view", "string", "thread_view_draft", "thread draft", "object", "sidebar_task_prefs", "sidebar task preferences", "object", "sidebar_task_color_automation", "sidebar color automation", "object", "sidebar_task_colors", "sidebar task colors", "object", "sidebar_task_color_patch", "sidebar task color patch", "object", "task_create_last_used", "last task create values", "object", @@ -654,6 +654,13 @@ func userSettingsDomain() DomainDescriptor { fields := make([]FieldDescriptor, 0, len(values)/3) for index := 0; index+2 < len(values); index += 3 { field := preferenceField(values[index], values[index+1], values[index+2]) + if values[index] == "sidebar_layout_state" { + // The update key is an atomic workspace-scoped operation. Reads + // project the complete persisted map so settings discovery can + // inspect it without exposing a whole-map replacement contract. + field.FieldPath = "sidebar_layouts_by_workspace" + field.Schema = map[string]any{"type": "object"} + } switch values[index] { case "keyboard_shortcuts": field.Schema = keyboardShortcutsSchema() diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 4e70cc24f2..82ffe03e11 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -84,6 +84,7 @@ func (c *Controller) RecordAgentProfileRecentUse( // resulting settings with OS shell options. func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserSettingsRequest) (dto.UserSettingsResponse, error) { settings, err := c.svc.UpdateUserSettings(ctx, &service.UpdateUserSettingsRequest{ + SidebarLayoutState: req.SidebarLayoutState, WorkspaceID: req.WorkspaceID, KanbanViewMode: req.KanbanViewMode, StartupPage: req.StartupPage, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 5d710184bf..5803b33605 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -16,6 +16,7 @@ type UserDTO struct { type UserSettingsDTO struct { SidebarViewsByWorkspace map[string]models.SidebarWorkspaceState `json:"sidebar_views_by_workspace"` + SidebarLayoutsByWorkspace map[string]models.SidebarLayout `json:"sidebar_layouts_by_workspace,omitempty"` UserID string `json:"user_id"` WorkspaceID string `json:"workspace_id"` KanbanViewMode string `json:"kanban_view_mode"` @@ -132,6 +133,7 @@ type ShellOption struct { type UpdateUserSettingsRequest struct { SidebarViewState *models.SidebarWorkspacePatch `json:"sidebar_view_state,omitempty"` + SidebarLayoutState *models.SidebarLayoutPatch `json:"sidebar_layout_state,omitempty"` WorkspaceID *string `json:"workspace_id,omitempty"` KanbanViewMode *string `json:"kanban_view_mode,omitempty"` StartupPage *string `json:"startup_page,omitempty"` @@ -330,6 +332,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { } return UserSettingsDTO{ UserID: settings.UserID, + SidebarLayoutsByWorkspace: settings.SidebarLayoutsByWorkspace, WorkspaceID: settings.WorkspaceID, KanbanViewMode: settings.KanbanViewMode, StartupPage: models.NormalizeStartupPage(settings.StartupPage), diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index dcb53fe03c..53779641a5 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -144,6 +144,7 @@ type UserSettings struct { LspStatusLocation string `json:"lsp_status_location"` SavedLayouts []SavedLayout `json:"saved_layouts"` SidebarViewsByWorkspace map[string]SidebarWorkspaceState `json:"sidebar_views_by_workspace"` + SidebarLayoutsByWorkspace map[string]SidebarLayout `json:"sidebar_layouts_by_workspace"` SidebarWorkspaceVersion int `json:"sidebar_workspace_version"` SidebarViews []SidebarView `json:"sidebar_views"` SidebarActiveViewID string `json:"sidebar_active_view_id"` diff --git a/apps/backend/internal/user/models/sidebar_layout_test.go b/apps/backend/internal/user/models/sidebar_layout_test.go new file mode 100644 index 0000000000..9afa72c07f --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_layout_test.go @@ -0,0 +1,16 @@ +package models + +import ( + "reflect" + "testing" +) + +func TestUserSettingsCarriesWorkspaceSidebarLayouts(t *testing.T) { + field, ok := reflect.TypeOf(UserSettings{}).FieldByName("SidebarLayoutsByWorkspace") + if !ok { + t.Fatal("UserSettings must expose SidebarLayoutsByWorkspace") + } + if field.Type.String() != "map[string]models.SidebarLayout" { + t.Fatalf("SidebarLayoutsByWorkspace type = %v, want map[string]models.SidebarLayout", field.Type) + } +} diff --git a/apps/backend/internal/user/models/sidebar_layouts.go b/apps/backend/internal/user/models/sidebar_layouts.go new file mode 100644 index 0000000000..df9cb02e0b --- /dev/null +++ b/apps/backend/internal/user/models/sidebar_layouts.go @@ -0,0 +1,68 @@ +package models + +const ( + SidebarLayoutVersion = 1 + + SidebarLayoutNodeBuiltin = "builtin" + SidebarLayoutNodePlugin = "plugin" + SidebarLayoutNodeShortcuts = "shortcuts" + + SidebarShortcutDestination = "destination" + SidebarShortcutHostAction = "host_action" + SidebarShortcutCanvas = "canvas" + SidebarShortcutAutomation = "automation" +) + +// SidebarLayout is the portable navigation composition for one workspace. +// Resource names, icons, and actions are resolved from the live catalog; only +// stable identities and the user's ordering choices are persisted. +type SidebarLayout struct { + Version int `json:"version"` + Revision int64 `json:"revision"` + Nodes []SidebarLayoutNode `json:"nodes"` + UnsupportedVersion bool `json:"unsupported_version,omitempty"` +} + +type SidebarLayoutNode struct { + ID string `json:"id"` + Kind string `json:"kind"` + Visible bool `json:"visible"` + DestinationID string `json:"destination_id,omitempty"` + Name string `json:"name,omitempty"` + Shortcuts []SidebarShortcut `json:"shortcuts,omitempty"` +} + +type SidebarShortcut struct { + ID string `json:"id"` + Target SidebarShortcutTarget `json:"target"` +} + +type SidebarShortcutTarget struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +// SidebarLayoutPatch replaces or resets one workspace layout under its own +// revision while the surrounding user settings row is protected by CAS. +type SidebarLayoutPatch struct { + WorkspaceID string `json:"workspace_id"` + ExpectedRevision int64 `json:"expected_revision"` + Layout *SidebarLayout `json:"layout"` +} + +// DefaultSidebarLayout returns the canonical layout used when a workspace has +// no saved customization. A fresh value is returned on every call so callers +// can safely edit it before persisting. +func DefaultSidebarLayout() SidebarLayout { + return SidebarLayout{ + Version: SidebarLayoutVersion, + Revision: 0, + Nodes: []SidebarLayoutNode{ + {ID: "home", Kind: SidebarLayoutNodeBuiltin, Visible: true, DestinationID: "home"}, + {ID: "new-task", Kind: SidebarLayoutNodeBuiltin, Visible: true, DestinationID: "new_task"}, + {ID: "automations", Kind: SidebarLayoutNodeBuiltin, Visible: true, DestinationID: "automations"}, + {ID: "canvases", Kind: SidebarLayoutNodeBuiltin, Visible: true, DestinationID: "canvases"}, + {ID: "integrations", Kind: SidebarLayoutNodeBuiltin, Visible: true, DestinationID: "integrations"}, + }, + } +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 2e7d999009..3245539ce0 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -46,6 +46,7 @@ type Service struct { type UpdateUserSettingsRequest struct { SidebarViewState *models.SidebarWorkspacePatch + SidebarLayoutState *models.SidebarLayoutPatch WorkspaceID *string KanbanViewMode *string StartupPage *string @@ -215,6 +216,9 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err := s.validateSidebarWorkspacePatch(ctx, req, sidebarWorkspaceIDs); err != nil { return nil, err } + if err := s.validateSidebarLayoutPatch(ctx, req, sidebarWorkspaceIDs); err != nil { + return nil, err + } if s.sidebarWorkspaceAccess != nil { settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { @@ -237,6 +241,12 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err := applySidebarWorkspacePatch(settings, req); err != nil { return false, err } + if err := applySidebarLayoutPatch(settings, req); err != nil { + if errors.Is(err, ErrUserSettingsConflict) { + return false, err + } + return false, fmt.Errorf("%w: %s", ErrValidation, err) + } if err := applyBasicSettings(settings, req); err != nil { return false, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } @@ -1124,6 +1134,7 @@ func (s *Service) projectSidebarSettingsForEvent( func addSidebarWorkspaceState(data map[string]interface{}, settings *models.UserSettings, include bool) { if include { data["sidebar_views_by_workspace"] = settings.SidebarViewsByWorkspace + data["sidebar_layouts_by_workspace"] = settings.SidebarLayoutsByWorkspace } } diff --git a/apps/backend/internal/user/service/sidebar_layout_test.go b/apps/backend/internal/user/service/sidebar_layout_test.go new file mode 100644 index 0000000000..f3d13de044 --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_layout_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +func TestSidebarLayoutDefaultsAreProjectedPerWorkspace(t *testing.T) { + svc, _, _ := sidebarService(t) + + got, err := svc.GetUserSettings(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, workspaceID := range []string{"a", "b"} { + layout, ok := got.SidebarLayoutsByWorkspace[workspaceID] + if !ok { + t.Fatalf("workspace %q has no sidebar layout", workspaceID) + } + if layout.Version != models.SidebarLayoutVersion || layout.Revision != 0 { + t.Fatalf("workspace %q layout = %+v, want version %d revision 0", workspaceID, layout, models.SidebarLayoutVersion) + } + } +} + +func TestSidebarLayoutPatchIsScopedAndRevisionChecked(t *testing.T) { + svc, repo, _ := sidebarService(t) + if _, err := svc.GetUserSettings(context.Background()); err != nil { + t.Fatal(err) + } + + got, err := svc.UpdateUserSettings(context.Background(), sidebarPatch(t, `{ + "SidebarLayoutState": { + "workspace_id": "a", + "expected_revision": 0, + "layout": { + "version": 1, + "nodes": [{"id":"home","kind":"builtin","visible":false,"destination_id":"home"}] + } + } + }`)) + if err != nil { + t.Fatal(err) + } + if got.SidebarLayoutsByWorkspace["a"].Revision != 1 || got.SidebarLayoutsByWorkspace["a"].Nodes[0].Visible { + t.Fatalf("workspace a layout = %+v, want hidden home at revision 1", got.SidebarLayoutsByWorkspace["a"]) + } + if got.SidebarLayoutsByWorkspace["b"].Revision != 0 { + t.Fatalf("workspace b layout changed: %+v", got.SidebarLayoutsByWorkspace["b"]) + } + + before := repo.snapshot().Revision + _, err = svc.UpdateUserSettings(context.Background(), sidebarPatch(t, `{ + "SidebarLayoutState": { + "workspace_id": "a", + "expected_revision": 0, + "layout": null + } + }`)) + if !errors.Is(err, ErrUserSettingsConflict) || repo.snapshot().Revision != before { + t.Fatalf("stale sidebar layout write = %v, settings revision %d want unchanged %d", err, repo.snapshot().Revision, before) + } +} + +func TestSidebarLayoutRejectsTooManyShortcutGroups(t *testing.T) { + layout := models.DefaultSidebarLayout() + for index := 0; index < maxSidebarLayoutShortcutsGroup+1; index++ { + layout.Nodes = append(layout.Nodes, models.SidebarLayoutNode{ + ID: fmt.Sprintf("group-%d", index), + Kind: models.SidebarLayoutNodeShortcuts, + Visible: true, + Name: fmt.Sprintf("Group %d", index), + }) + } + + if err := validateSidebarLayout(layout); err == nil { + t.Fatal("validateSidebarLayout accepted too many shortcut groups") + } +} + +func TestProjectSidebarLayoutsMarksUnsupportedVersion(t *testing.T) { + settings := &models.UserSettings{ + SidebarLayoutsByWorkspace: map[string]models.SidebarLayout{ + "workspace-1": {Version: models.SidebarLayoutVersion + 1, Revision: 7}, + }, + } + + projected := projectSidebarLayouts(settings, []string{"workspace-1"})["workspace-1"] + if !projected.UnsupportedVersion { + t.Fatal("unsupported layout was not marked for recovery") + } + if projected.Revision != 7 || projected.Version != models.SidebarLayoutVersion { + t.Fatalf("projected unsupported layout = %+v", projected) + } +} diff --git a/apps/backend/internal/user/service/sidebar_layouts.go b/apps/backend/internal/user/service/sidebar_layouts.go new file mode 100644 index 0000000000..911f4e933c --- /dev/null +++ b/apps/backend/internal/user/service/sidebar_layouts.go @@ -0,0 +1,262 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + "unicode/utf8" + + "github.com/kandev/kandev/internal/user/models" +) + +const ( + maxSidebarLayoutNodes = 40 + maxSidebarLayoutShortcutsGroup = 20 + maxSidebarLayoutShortcutsTotal = 100 + maxSidebarLayoutIDRunes = 255 + maxSidebarLayoutBytes = 256 * 1024 + maxSidebarLayoutNameRunes = 60 +) + +// validateSidebarLayoutPatch checks the request shape and the caller's +// workspace scope before the settings CAS starts. The layout itself remains +// an opaque, complete replacement inside the chosen workspace. +func (s *Service) validateSidebarLayoutPatch( + ctx context.Context, + req *UpdateUserSettingsRequest, + provided ...[]string, +) error { + patch := req.SidebarLayoutState + if patch == nil { + return nil + } + if s.sidebarWorkspaceAccess == nil { + return fmt.Errorf("%w: workspace access unavailable", ErrValidation) + } + workspaceID := strings.TrimSpace(patch.WorkspaceID) + if workspaceID == "" { + return fmt.Errorf("%w: workspace is required", ErrValidation) + } + ids, err := s.resolveSidebarWorkspaceIDs(ctx, provided...) + if err != nil { + return err + } + if !slices.Contains(ids, workspaceID) { + return fmt.Errorf("%w: invalid sidebar layout workspace", ErrValidation) + } + if patch.ExpectedRevision < 0 { + return fmt.Errorf("%w: sidebar layout revision must be non-negative", ErrValidation) + } + if patch.Layout == nil { + return nil + } + return validateSidebarLayout(*patch.Layout) +} + +func validateSidebarLayout(layout models.SidebarLayout) error { + if layout.Version != models.SidebarLayoutVersion { + return fmt.Errorf("sidebar layout version %d is unsupported", layout.Version) + } + if len(layout.Nodes) > maxSidebarLayoutNodes { + return fmt.Errorf("sidebar layout has more than %d nodes", maxSidebarLayoutNodes) + } + + nodeIDs := make(map[string]struct{}, len(layout.Nodes)) + shortcutIDs := make(map[string]struct{}) + totalShortcuts := 0 + shortcutGroups := 0 + for _, node := range layout.Nodes { + if node.Kind == models.SidebarLayoutNodeShortcuts { + shortcutGroups++ + } + shortcuts, err := validateSidebarLayoutNode(node, nodeIDs, shortcutIDs) + if err != nil { + return err + } + totalShortcuts += shortcuts + } + if shortcutGroups > maxSidebarLayoutShortcutsGroup { + return fmt.Errorf("sidebar layout has more than %d shortcut groups", maxSidebarLayoutShortcutsGroup) + } + if totalShortcuts > maxSidebarLayoutShortcutsTotal { + return fmt.Errorf("sidebar layout has more than %d shortcuts", maxSidebarLayoutShortcutsTotal) + } + raw, err := json.Marshal(layout) + if err != nil { + return fmt.Errorf("sidebar layout is not serializable: %w", err) + } + if len(raw) > maxSidebarLayoutBytes { + return fmt.Errorf("sidebar layout exceeds %d bytes", maxSidebarLayoutBytes) + } + return nil +} + +func validateSidebarLayoutNode( + node models.SidebarLayoutNode, + nodeIDs, shortcutIDs map[string]struct{}, +) (int, error) { + if err := validateSidebarLayoutID("node", node.ID); err != nil { + return 0, err + } + if _, exists := nodeIDs[node.ID]; exists { + return 0, fmt.Errorf("sidebar layout node %q is duplicated", node.ID) + } + nodeIDs[node.ID] = struct{}{} + + switch node.Kind { + case models.SidebarLayoutNodeBuiltin, models.SidebarLayoutNodePlugin: + return 0, validateSidebarDestinationNode(node) + case models.SidebarLayoutNodeShortcuts: + return len(node.Shortcuts), validateSidebarShortcutSection(node, shortcutIDs) + default: + return 0, fmt.Errorf("sidebar layout node %q has unsupported kind %q", node.ID, node.Kind) + } +} + +func validateSidebarDestinationNode(node models.SidebarLayoutNode) error { + if strings.TrimSpace(node.DestinationID) == "" { + return fmt.Errorf("sidebar layout node %q needs a destination", node.ID) + } + if isProtectedSidebarDestination(node.DestinationID) { + return fmt.Errorf("sidebar destination %q is fixed", node.DestinationID) + } + if node.Name != "" || len(node.Shortcuts) != 0 { + return fmt.Errorf("sidebar destination node %q has shortcut-section fields", node.ID) + } + return nil +} + +func validateSidebarShortcutSection(node models.SidebarLayoutNode, shortcutIDs map[string]struct{}) error { + name := strings.TrimSpace(node.Name) + if name == "" || utf8.RuneCountInString(name) > maxSidebarLayoutNameRunes { + return fmt.Errorf("sidebar shortcut section %q must have 1-%d characters", node.ID, maxSidebarLayoutNameRunes) + } + if node.DestinationID != "" { + return fmt.Errorf("sidebar shortcut section %q has a destination", node.ID) + } + if len(node.Shortcuts) > maxSidebarLayoutShortcutsGroup { + return fmt.Errorf("sidebar shortcut section %q has more than %d shortcuts", node.ID, maxSidebarLayoutShortcutsGroup) + } + targets := make(map[string]struct{}, len(node.Shortcuts)) + for _, shortcut := range node.Shortcuts { + if err := validateSidebarLayoutID("shortcut", shortcut.ID); err != nil { + return err + } + if _, exists := shortcutIDs[shortcut.ID]; exists { + return fmt.Errorf("sidebar shortcut %q is duplicated", shortcut.ID) + } + shortcutIDs[shortcut.ID] = struct{}{} + if err := validateSidebarShortcutTarget(shortcut.Target); err != nil { + return err + } + targetKey := shortcut.Target.Kind + ":" + shortcut.Target.ID + if _, exists := targets[targetKey]; exists { + return fmt.Errorf("sidebar shortcut target %q is duplicated in section %q", targetKey, node.ID) + } + targets[targetKey] = struct{}{} + } + return nil +} + +func validateSidebarLayoutID(kind, value string) error { + if strings.TrimSpace(value) == "" { + return fmt.Errorf("sidebar %s id is required", kind) + } + if utf8.RuneCountInString(value) > maxSidebarLayoutIDRunes { + return fmt.Errorf("sidebar %s id exceeds %d characters", kind, maxSidebarLayoutIDRunes) + } + return nil +} + +func validateSidebarShortcutTarget(target models.SidebarShortcutTarget) error { + if strings.TrimSpace(target.ID) == "" { + return fmt.Errorf("sidebar shortcut target id is required") + } + switch target.Kind { + case models.SidebarShortcutDestination, models.SidebarShortcutCanvas, models.SidebarShortcutAutomation: + return nil + case models.SidebarShortcutHostAction: + switch target.ID { + case "new_task", "quick_chat", "quick_terminal": + return nil + default: + return fmt.Errorf("sidebar host action %q is unsupported", target.ID) + } + default: + return fmt.Errorf("sidebar shortcut target kind %q is unsupported", target.Kind) + } +} + +func isProtectedSidebarDestination(id string) bool { + switch id { + case "tasks", "inbox", "needs_you_inbox": + return true + default: + return false + } +} + +func applySidebarLayoutPatch(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + patch := req.SidebarLayoutState + if patch == nil { + return nil + } + workspaceID := strings.TrimSpace(patch.WorkspaceID) + current, exists := settings.SidebarLayoutsByWorkspace[workspaceID] + if !exists { + current = models.DefaultSidebarLayout() + } else if current.Revision < 0 { + current.Revision = 0 + } + if current.Revision != patch.ExpectedRevision { + return ErrUserSettingsConflict + } + + next := models.DefaultSidebarLayout() + if patch.Layout != nil { + next = *patch.Layout + } + next.Version = models.SidebarLayoutVersion + next.Revision = patch.ExpectedRevision + 1 + next.UnsupportedVersion = false + if next.Nodes == nil { + next.Nodes = []models.SidebarLayoutNode{} + } + entries := maps.Clone(settings.SidebarLayoutsByWorkspace) + if entries == nil { + entries = map[string]models.SidebarLayout{} + } + entries[workspaceID] = next + settings.SidebarLayoutsByWorkspace = entries + return nil +} + +func projectSidebarLayouts(settings *models.UserSettings, ids []string) map[string]models.SidebarLayout { + projected := make(map[string]models.SidebarLayout, len(ids)) + for _, id := range ids { + layout, exists := settings.SidebarLayoutsByWorkspace[id] + switch { + case !exists: + layout = models.DefaultSidebarLayout() + case layout.Version != models.SidebarLayoutVersion: + fallback := models.DefaultSidebarLayout() + fallback.Revision = maxInt64(layout.Revision, 0) + fallback.UnsupportedVersion = true + layout = fallback + case layout.Nodes == nil: + layout.Nodes = []models.SidebarLayoutNode{} + } + projected[id] = layout + } + return projected +} + +func maxInt64(value, minimum int64) int64 { + if value < minimum { + return minimum + } + return value +} diff --git a/apps/backend/internal/user/service/sidebar_workspace_views.go b/apps/backend/internal/user/service/sidebar_workspace_views.go index c942f51740..4a3ed5cdae 100644 --- a/apps/backend/internal/user/service/sidebar_workspace_views.go +++ b/apps/backend/internal/user/service/sidebar_workspace_views.go @@ -73,12 +73,14 @@ func projectSidebarWorkspaces(settings *models.UserSettings, ids []string) *mode for _, id := range ids { copy.SidebarViewsByWorkspace[id] = normalizeSidebarWorkspace(settings.SidebarViewsByWorkspace[id]) } + copy.SidebarLayoutsByWorkspace = projectSidebarLayouts(settings, ids) return © } func withoutSidebarWorkspaceState(settings *models.UserSettings) *models.UserSettings { copy := *settings copy.SidebarViewsByWorkspace = nil + copy.SidebarLayoutsByWorkspace = nil return © } diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index b61407984d..9fea480cae 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -646,6 +646,7 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "saved_layouts": savedLayouts, "sidebar_views": sidebarViews, "sidebar_views_by_workspace": settings.SidebarViewsByWorkspace, + "sidebar_layouts_by_workspace": settings.SidebarLayoutsByWorkspace, "sidebar_workspace_version": settings.SidebarWorkspaceVersion, "sidebar_active_view_id": settings.SidebarActiveViewID, "sidebar_draft": settings.SidebarDraft, @@ -751,6 +752,7 @@ func defaultUserSettings(userID string) *models.UserSettings { LspServerConfigs: map[string]map[string]interface{}{}, LspStatusLocation: models.LspStatusLocationToolbar, SavedLayouts: []models.SavedLayout{}, + SidebarLayoutsByWorkspace: map[string]models.SidebarLayout{}, ChatSubmitKey: "cmd_enter", KeyboardShortcuts: map[string]interface{}{}, TerminalLinkBehavior: "new_tab", @@ -835,6 +837,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LspStatusLocation string `json:"lsp_status_location"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` SidebarViewsByWorkspace map[string]models.SidebarWorkspaceState `json:"sidebar_views_by_workspace"` + SidebarLayoutsByWorkspace map[string]models.SidebarLayout `json:"sidebar_layouts_by_workspace"` SidebarWorkspaceVersion int `json:"sidebar_workspace_version"` SidebarViews json.RawMessage `json:"sidebar_views"` SidebarActiveViewID json.RawMessage `json:"sidebar_active_view_id"` @@ -972,6 +975,10 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } } settings.SidebarViewsByWorkspace = payload.SidebarViewsByWorkspace + settings.SidebarLayoutsByWorkspace = payload.SidebarLayoutsByWorkspace + if settings.SidebarLayoutsByWorkspace == nil { + settings.SidebarLayoutsByWorkspace = map[string]models.SidebarLayout{} + } settings.SidebarWorkspaceVersion = payload.SidebarWorkspaceVersion settings.SidebarDraft = payload.SidebarDraft if len(payload.ThreadViews) > 0 { diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index d9b1770255..69adbb955d 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -164,10 +164,14 @@ vi.mock("@kandev/ui/dropdown-menu", () => ({ import { AppSidebarFooter, MAX_INLINE_PLUGIN_FOOTER_ITEMS } from "./app-sidebar-footer"; -function renderFooter(collapsed = false) { +function renderFooter(collapsed = false, layoutManaged = false) { return render( - + , ); } @@ -220,6 +224,25 @@ describe("AppSidebarFooter", () => { expect(mocks.routerPush).toHaveBeenCalledWith("/stats"); }); + it("leaves plugin insight destinations to the saved sidebar layout", () => { + insightDestinations = [ + STATS_DESTINATION, + { + id: "plugin:acme:board", + label: "Acme Board", + icon: IconChartBar, + section: "insights", + href: "/plugins/acme", + source: "plugin", + }, + ]; + + renderFooter(false, true); + + expect(screen.getByRole("button", { name: "Stats" })).not.toBeNull(); + expect(screen.queryByTestId("sidebar-plugin:acme:board-button")).toBeNull(); + }); + it("does not render a mode switch button", () => { // Mode follows the active workspace, and workspaces are switched through // the picker in the sidebar header. The footer used to carry a dedicated diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index c8cb5e1853..70f9cf7fb7 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -36,6 +36,7 @@ import type { ConnectionIssueSeverity } from "@/lib/types/connection"; type AppSidebarFooterProps = { collapsed: boolean; onToggleSettingsMode: () => void; + layoutManaged?: boolean; }; type FooterIconButtonProps = { @@ -351,7 +352,11 @@ function useSettingsGearToggle( }; } -export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebarFooterProps) { +export function AppSidebarFooter({ + collapsed, + onToggleSettingsMode, + layoutManaged = false, +}: AppSidebarFooterProps) { const { t } = useTranslation(); const router = useRouter(); const workspaces = useAppStore((s) => s.workspaces); @@ -360,7 +365,9 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); const toggleSettings = useSettingsGearToggle(settingsMode, activeWorkspace, onToggleSettingsMode); const appStatusBarEnabled = useAppStore((s) => s.userSettings.appStatusBarEnabled); - const insightDestinations = useStaticDestinations("sidebar", "insights"); + const insightDestinations = useStaticDestinations("sidebar", "insights").filter( + (destination) => !layoutManaged || destination.source !== "plugin", + ); const releaseNotes = useReleaseNotes(); const improveOpen = useAppStore((s) => s.appSidebar.improveDialogOpen); const setImproveOpen = useAppStore((s) => s.setImproveDialogOpen); diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx index 462075ef12..7fb71992bd 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx @@ -19,12 +19,36 @@ import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; type AppSidebarPrimaryNavProps = { collapsed: boolean; + showHome?: boolean; + showNewTask?: boolean; }; -export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { +export function AppSidebarHomeItem({ collapsed }: { collapsed: boolean }) { const { t } = useTranslation(); const workspaceId = useAppStore((s) => s.workspaces.activeId); const startupPage = useAppStore((s) => s.userSettings.startupPage); + const mode = useOfficeModeState(); + const inOffice = mode === "office"; + const homeHref = + mode === "unknown" ? undefined : homeDestinationHref({ workspaceId, inOffice, startupPage }); + + return ( + + ); +} + +export function AppSidebarFixedNav({ collapsed }: { collapsed: boolean }) { + const { t } = useTranslation(); + const workspaceId = useAppStore((s) => s.workspaces.activeId); const inboxCount = useAppStore(selectOfficeInboxCount); const needsYouInboxEnabled = useFeature("needsYouInbox"); const needsYouInboxCount = useAppStore(selectNeedsYouInboxCount); @@ -33,21 +57,9 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) { const inOffice = mode === "office"; const handleOpenQuickChat = useQuickChatLauncher(workspaceId); const { activity: quickChatActivity, label: quickChatLabel } = useQuickChatActivity(workspaceId); - const homeHref = - mode === "unknown" ? undefined : homeDestinationHref({ workspaceId, inOffice, startupPage }); return ( -
- + <> {inOffice && ( )} - + + ); +} + +export function AppSidebarPrimaryNav({ + collapsed, + showHome = true, + showNewTask = true, +}: AppSidebarPrimaryNavProps) { + return ( +
+ {showHome && } + + {showNewTask && }
); } diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index 90666646ab..1567d45cbe 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -2,7 +2,7 @@ import type { RefObject } from "react"; import { IconChevronRight } from "@tabler/icons-react"; -import type { Icon as TablerIcon } from "@tabler/icons-react"; +import type { DestinationIcon } from "@/lib/navigation/types"; import { Collapsible, CollapsibleContent } from "@kandev/ui/collapsible"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useAppStore } from "@/components/state-provider"; @@ -12,7 +12,7 @@ type AppSidebarSectionProps = { id: string; label: string; collapsed: boolean; - icon: TablerIcon; + icon: DestinationIcon; children: React.ReactNode; /** Optional control rendered between the label and the collapse chevron. */ headerAction?: React.ReactNode; diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx index 9a618cdc32..b0e97c3b28 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.test.tsx @@ -1,6 +1,10 @@ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { StartupPage } from "@/lib/types/http-user-settings"; +import { + clearNavigationBlockerForTests, + setNavigationBlocker, +} from "@/lib/routing/navigation-guard"; // The sidebar write path scopes names with the API-origin port; pin it so the // captured write assertions are deterministic. @@ -86,6 +90,7 @@ function resetWorkspaceSelectTest() { } function cleanupWorkspaceSelectTest() { + clearNavigationBlockerForTests(); if (cookieDescriptor) { Object.defineProperty(document, "cookie", cookieDescriptor); } @@ -176,6 +181,24 @@ describe("AppSidebarWorkspacePicker — workspace select", () => { expect(navigationMock.push).not.toHaveBeenCalled(); }); + it("waits for the navigation guard before changing the active workspace", () => { + let proceed: (() => void) | undefined; + const unregister = setNavigationBlocker((intent) => { + proceed = intent.proceed; + }); + render(); + + fireEvent.click(screen.getByTestId(OFFICE_WORKSPACE_ITEM)); + + expect(storeState.setActiveWorkspace).not.toHaveBeenCalled(); + expect(navigationMock.push).not.toHaveBeenCalled(); + expect(cookieWrites).toEqual([]); + + proceed?.(); + expect(storeState.setActiveWorkspace).toHaveBeenCalledWith("w2"); + unregister(); + }); + it("clears stale kanban context and routes to another kanban workspace with office disabled", () => { storeState.features.office = false; render(); diff --git a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx index b50ee91949..8889a652a3 100644 --- a/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-workspace-picker.tsx @@ -15,6 +15,7 @@ import { } from "@/components/workspaces/workspace-picker-content"; import { workspaceHomeHref } from "./app-sidebar-workspace-navigation"; import { useSelectWorkspace } from "@/hooks/use-select-workspace"; +import { requestNavigation } from "@/lib/routing/navigation-guard"; /** * Compact, secondary workspace switcher inlined after the Kandev brand in the @@ -120,23 +121,25 @@ export function AppSidebarWorkspacePicker({ const handleSelect = useCallback( (workspace: WorkspaceItem) => { const { id } = workspace; - if (id === activeId) { - if (officeEnabled && workspaceType(workspace) === "kanban") { - selectWorkspace(workspace); + requestNavigation(() => { + if (id === activeId) { + if (officeEnabled && workspaceType(workspace) === "kanban") { + selectWorkspace(workspace); + router.push(workspaceHomeHref(workspace, startupPage)); + } + setOpen(false); + onActionComplete?.(); + return; + } + selectWorkspace(workspace); + if (workspaceType(workspace) === "kanban") { router.push(workspaceHomeHref(workspace, startupPage)); + } else if (officeEnabled) { + router.push(`/office?workspaceId=${id}`); } setOpen(false); onActionComplete?.(); - return; - } - selectWorkspace(workspace); - if (workspaceType(workspace) === "kanban") { - router.push(workspaceHomeHref(workspace, startupPage)); - } else if (officeEnabled) { - router.push(`/office?workspaceId=${id}`); - } - setOpen(false); - onActionComplete?.(); + }); }, [activeId, startupPage, router, selectWorkspace, officeEnabled, onActionComplete, setOpen], ); diff --git a/apps/web/components/app-sidebar/app-sidebar.tsx b/apps/web/components/app-sidebar/app-sidebar.tsx index fcb134c1a8..b70bdc17f5 100644 --- a/apps/web/components/app-sidebar/app-sidebar.tsx +++ b/apps/web/components/app-sidebar/app-sidebar.tsx @@ -28,6 +28,8 @@ import { IntegrationsSection } from "./sections/integrations-section"; import { OfficeNavigationSection } from "./sections/office-navigation-section"; import { ProjectsSection } from "./sections/projects-section"; import { TasksSection } from "./sections/tasks-section"; +import { SidebarLayoutNavigation } from "./sidebar-layout-navigation"; +import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation"; const SECTION_ROUTE_MAP: Array<{ id: string; matches: (path: string) => boolean }> = [ { @@ -69,6 +71,7 @@ function AppSidebarUnresolvedNav({ collapsed }: { collapsed: boolean }) { } function AppSidebarModeNav({ collapsed, inOffice }: { collapsed: boolean; inOffice: boolean }) { + const hasSavedSidebarLayout = useHasSavedSidebarLayout(); return ( <>
- - {/* Directly under New Task: an automation is a thing you keep, the - same weight as a project, and the list IS the nav — picking one - opens its history rather than a settings form. */} - {!inOffice && } - {!inOffice && } - + {hasSavedSidebarLayout ? ( + + ) : ( + <> + + {/* Directly under New Task: an automation is a thing you keep, the + same weight as a project, and the list IS the nav — picking one + opens its history rather than a settings form. */} + {!inOffice && } + {!inOffice && } + + + )} {inOffice && } {inOffice && } - {!inOffice && } + {!inOffice && !hasSavedSidebarLayout && }
{/* In regular kanban mode, Tasks is the flex-grow middle section so it absorbs remaining vertical space and scrolls internally. @@ -121,6 +130,23 @@ function AppSidebarNavigation({ collapsed, mode, settingsMode }: AppSidebarNavig ); } +function AppSidebarFooterSlot({ + collapsed, + onToggleSettingsMode, +}: { + collapsed: boolean; + onToggleSettingsMode: () => void; +}) { + const layoutManaged = useHasSavedSidebarLayout(); + return ( + + ); +} + /** * Unified app sidebar mounted at the root layout. Replaces the legacy * WorkspaceRail + OfficeSidebar + dockview-embedded sidebar surfaces. @@ -233,7 +259,6 @@ export function AppSidebar() { const targetWidth = collapsed ? APP_SIDEBAR_COLLAPSED_WIDTH : expandedWidth; const { settingsMode, toggleSettingsMode: handleToggleSettingsMode } = useSettingsTakeover(pathname); - useEffect(() => { if (!pathname) return; for (const entry of SECTION_ROUTE_MAP) { @@ -284,7 +309,7 @@ export function AppSidebar() { mode={mode} settingsMode={settingsMode} /> - diff --git a/apps/web/components/app-sidebar/sections/automations-section.test.tsx b/apps/web/components/app-sidebar/sections/automations-section.test.tsx index a2a9dab096..9edc902ef7 100644 --- a/apps/web/components/app-sidebar/sections/automations-section.test.tsx +++ b/apps/web/components/app-sidebar/sections/automations-section.test.tsx @@ -284,12 +284,14 @@ describe("AutomationsSection responsive visibility", () => { vi.useFakeTimers({ shouldAdvanceTime: true }); try { renderOpenSection(); - await screen.findByTestId(`sidebar-automation-${AUTOMATION_ID}`); + await waitFor(() => expect(screen.getByText("Automations")).toBeTruthy()); await act(async () => { await vi.advanceTimersByTimeAsync(LIVE_REFRESH_INTERVAL_MS * 2); }); + expect(screen.queryByTestId(`sidebar-automation-${AUTOMATION_ID}`)).toBeNull(); + expect(mocks.listAutomations).not.toHaveBeenCalled(); expect(mocks.listAutomationSummaries).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); diff --git a/apps/web/components/app-sidebar/sections/automations-section.tsx b/apps/web/components/app-sidebar/sections/automations-section.tsx index 1bb3098c32..b0d46c92c9 100644 --- a/apps/web/components/app-sidebar/sections/automations-section.tsx +++ b/apps/web/components/app-sidebar/sections/automations-section.tsx @@ -142,9 +142,9 @@ export function AutomationsSection({ collapsed }: { collapsed: boolean }) { // nothing reads as empty, and nobody opens it. Health summaries are the // heavier read and buy nothing until the rows themselves are on screen. const showing = !collapsed && expanded && !isMobile; - const listScope = collapsed ? undefined : (workspaceId ?? undefined); + const listScope = collapsed || isMobile ? undefined : (workspaceId ?? undefined); const summaryScope = showing ? (workspaceId ?? undefined) : undefined; - const { automations } = useWorkspaceAutomations(listScope); + const { automations } = useWorkspaceAutomations(listScope, { active: !isMobile }); const { summaries, refresh } = useAutomationSummaries(summaryScope); useLiveRefresh(showing, refresh); const rows = buildAutomationRows(automations, summaries); diff --git a/apps/web/components/app-sidebar/sections/canvases-section.tsx b/apps/web/components/app-sidebar/sections/canvases-section.tsx index 4d9a85a1bb..2d565e33b2 100644 --- a/apps/web/components/app-sidebar/sections/canvases-section.tsx +++ b/apps/web/components/app-sidebar/sections/canvases-section.tsx @@ -17,6 +17,7 @@ import { } from "@/lib/api/domains/canvas-api"; import { cn } from "@/lib/utils"; import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { APP_SIDEBAR_SECTION_IDS, SIDEBAR_ITEM_ACTIVE, @@ -156,7 +157,8 @@ export function CanvasesSection({ collapsed }: { collapsed: boolean }) { const pathname = usePathname(); const enabled = useFeature("canvases"); const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); - const workspaceId = enabled ? activeWorkspaceId : null; + const { isMobile } = useResponsiveBreakpoint(); + const workspaceId = enabled && !isMobile ? activeWorkspaceId : null; const { canvases, ready } = useWorkspaceCanvasesState(workspaceId); const activeCanvases = canvases.filter(isActiveWorkspaceCanvas); const sectionHeaderRef = useRef(null); diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx index de03ee604b..65e96bf9f1 100644 --- a/apps/web/components/app-sidebar/sections/integrations-section.tsx +++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx @@ -17,6 +17,7 @@ import { AppSidebarSection } from "../app-sidebar-section"; type IntegrationsSectionProps = { collapsed: boolean; + includePluginItems?: boolean; }; const MAX_HEADER_SHORTCUTS = 4; @@ -67,7 +68,10 @@ function IntegrationRow({ href, label, icon: Icon, active, testId }: Integration ); } -export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { +export function IntegrationsSection({ + collapsed, + includePluginItems = true, +}: IntegrationsSectionProps) { const { t } = useTranslation(); const pathname = usePathname(); // First-party integration links and plugin-registered nav items that target @@ -76,9 +80,14 @@ export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) { const destinations = useAppDestinations("sidebar", "integrations"); // Header shortcuts stay first-party: they are a fixed-width strip, and a // plugin should not push a configured integration out of it. - const firstPartyLinks = destinations.filter((destination) => destination.source !== "plugin"); + const visibleDestinations = includePluginItems + ? destinations + : destinations.filter((destination) => destination.source !== "plugin"); + const firstPartyLinks = visibleDestinations.filter( + (destination) => destination.source !== "plugin", + ); - if (destinations.length === 0) return null; + if (visibleDestinations.length === 0) return null; return ( - {destinations.map((destination) => ( + {visibleDestinations.map((destination) => ( string) { + return activity.error + ? t("automations:failedToLoadAutomationActivity") + : t(ACTIVITY_LABEL_KEYS[activity.state]); +} + +export function ShortcutActivityIndicator({ activity, className }: ShortcutActivityIndicatorProps) { + const { t } = useTranslation(); + return ( + + } + aggregateActivity={running ? { state: "running", loading: false, error: false } : undefined} + /> + ); +} + +export function ShortcutSection({ + node, + collapsed = false, + mobile = false, + getActivity, + onActivateShortcut, + onNavigate, +}: ShortcutSectionProps) { + const { t } = useTranslation(); + if (mobile) { + return ( + + ); + } + if (collapsed) { + return ( + + ); + } + + return ( + + } + headerActionVisibility="always" + collapsedSummary={node.shortcuts.length > 0 ? node.shortcuts.length : undefined} + defaultExpanded={false} + > + + {node.shortcuts.length === 0 && ( + + {t("common:noResults")} + + )} + + ); +} diff --git a/apps/web/components/app-sidebar/sidebar-layout-navigation.tsx b/apps/web/components/app-sidebar/sidebar-layout-navigation.tsx new file mode 100644 index 0000000000..5f0b86d1b0 --- /dev/null +++ b/apps/web/components/app-sidebar/sidebar-layout-navigation.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { Fragment, useCallback, useMemo } from "react"; +import { AppSidebarNavItem } from "./app-sidebar-nav-item"; +import { AppSidebarFixedNav, AppSidebarHomeItem } from "./app-sidebar-primary-nav"; +import { AppSidebarNewTaskItem } from "./app-sidebar-new-task-item"; +import { AutomationsSection } from "./sections/automations-section"; +import { CanvasesSection } from "./sections/canvases-section"; +import { IntegrationsSection } from "./sections/integrations-section"; +import { ShortcutSection } from "./shortcut-section"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher"; +import { useSidebarLayoutNavigation } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { requestNewTaskCreation } from "@/lib/desktop/new-task-request"; +import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection"; + +type SidebarLayoutNavigationProps = { + collapsed: boolean; + inOffice: boolean; +}; + +function LayoutPluginItem({ + node, + href, + collapsed, +}: { + node: ProjectedSidebarNode; + href?: string; + collapsed: boolean; +}) { + return ( + + ); +} + +function builtinNode(node: ProjectedSidebarNode, collapsed: boolean): React.ReactNode { + switch (node.destinationId) { + case "home": + return ; + case "new_task": + return ; + case "automations": + return ; + case "canvases": + return ; + case "integrations": + return ; + default: + return null; + } +} + +export function SidebarLayoutNavigation({ collapsed, inOffice }: SidebarLayoutNavigationProps) { + const { isMobile } = useResponsiveBreakpoint(); + const { workspaceId, catalog, projection, activity } = useSidebarLayoutNavigation({ + active: !isMobile, + }); + const openQuickChat = useQuickChatLauncher(workspaceId); + const openQuickTerminal = useQuickTerminalLauncher(workspaceId); + const destinationHrefs = useMemo( + () => + new Map( + catalog.catalog + .filter((entry) => entry.target.kind === "destination") + .map((entry) => [entry.target.id, entry.href]), + ), + [catalog.catalog], + ); + + const activateShortcut = useCallback( + (shortcut: ProjectedShortcut) => { + if (shortcut.target.kind !== "host_action") return; + if (shortcut.target.id === "new_task") requestNewTaskCreation(); + if (shortcut.target.id === "quick_chat") openQuickChat(); + if (shortcut.target.id === "quick_terminal") void openQuickTerminal(); + }, + [openQuickChat, openQuickTerminal], + ); + + const visibleNodes = projection.nodes.filter( + (node) => node.visible && (!inOffice || !isKanbanOnlyNode(node)), + ); + const hasVisibleNewTask = visibleNodes.some((node) => node.destinationId === "new_task"); + let fixedRendered = false; + const items: React.ReactNode[] = []; + + for (const node of visibleNodes) { + if (!fixedRendered && node.destinationId !== "home") { + items.push(); + fixedRendered = true; + } + if (node.kind === "builtin") { + items.push({builtinNode(node, collapsed)}); + continue; + } + if (node.kind === "plugin") { + items.push( + , + ); + continue; + } + items.push( + , + ); + } + + if (!fixedRendered) { + items.push(); + } + if (!hasVisibleNewTask) { + items.push( + , + ); + } + + return
{items}
; +} + +function isKanbanOnlyNode(node: ProjectedSidebarNode): boolean { + return ( + node.destinationId === "automations" || + node.destinationId === "canvases" || + node.destinationId === "integrations" + ); +} diff --git a/apps/web/components/navigation/app-nav-sections.tsx b/apps/web/components/navigation/app-nav-sections.tsx index 6a54dcf50b..513af5fa9d 100644 --- a/apps/web/components/navigation/app-nav-sections.tsx +++ b/apps/web/components/navigation/app-nav-sections.tsx @@ -11,6 +11,7 @@ import { IconLayoutList, } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import Link from "@/components/routing/app-link"; import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; import { useConnectionIssueCopy } from "@/components/app-status-bar/connection-status-item"; import { ImproveKandevDialog } from "@/components/improve-kandev-dialog"; @@ -29,6 +30,9 @@ import type { NavSection } from "@/lib/navigation/types"; import { useRouter } from "@/lib/routing/client-router"; import { cn } from "@/lib/utils"; import { useTaskViewNavigation } from "./use-task-view-navigation"; +import { MobileSidebarLayoutNavigation } from "./mobile-sidebar-layout-navigation"; +import { useHasSavedSidebarLayout } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation"; +import { SIDEBAR_LAYOUT_TAB_HREF } from "@/lib/settings-discovery/catalog/preferences"; /** * Overlay-backed rows in the shared nav block (Task views, Improve Kandev, Health issues). @@ -135,11 +139,20 @@ export function AppNavSections({ }: AppNavSectionsProps) { const { t } = useTranslation(); const omit = new Set(omitSections); + const hasSavedSidebarLayout = useHasSavedSidebarLayout(); return ( <> {workspaceActions} - {!omit.has("primary") && ( - + {hasSavedSidebarLayout ? ( + + ) : ( + !omit.has("primary") && ( + + ) )} {controls.openTaskViews && ( {/* #2514 put this on the kanban drawer's own utility rows; that surface now draws this shared block instead, so the toggle lives here and every mobile menu gets it rather than only the board's. */} diff --git a/apps/web/components/navigation/app-nav-sheet.test.tsx b/apps/web/components/navigation/app-nav-sheet.test.tsx index ad7cf614f1..f02c6bd1b6 100644 --- a/apps/web/components/navigation/app-nav-sheet.test.tsx +++ b/apps/web/components/navigation/app-nav-sheet.test.tsx @@ -114,6 +114,10 @@ vi.mock("@/components/system-health/health-indicator", () => ({ HealthIssuesDialog: () =>
, })); +vi.mock("@/components/integrations/integrations-menu", () => ({ + MobileIntegrationsSection: () =>
, +})); + vi.mock("@/components/theme/app-theme", () => ({ useTheme: () => ({ resolvedTheme, setTheme: mocks.setTheme }), })); @@ -264,6 +268,14 @@ describe("AppNavSections", () => { expect(screen.getByTestId("mobile-improve-kandev-button")).not.toBeNull(); }); + it("keeps the legacy integrations destination for an untouched Office workspace", () => { + state.workspaces.items[0].office_workflow_id = "office-workflow"; + + render(); + + expect(screen.getByTestId("mobile-integrations-section")).not.toBeNull(); + }); + it("hides the health row while the system is healthy", () => { render(); diff --git a/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx b/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx new file mode 100644 index 0000000000..53da4d1b48 --- /dev/null +++ b/apps/web/components/navigation/mobile-sidebar-layout-navigation.tsx @@ -0,0 +1,362 @@ +"use client"; + +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { IconInbox, IconSquarePlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Badge } from "@kandev/ui/badge"; +import Link from "@/components/routing/app-link"; +import { useAppStore } from "@/components/state-provider"; +import { useFeature } from "@/hooks/domains/features/use-feature"; +import { useQuickChatLauncher } from "@/hooks/use-quick-chat-launcher"; +import { useQuickTerminalLauncher } from "@/hooks/use-quick-terminal-launcher"; +import { useStaticDestinations } from "@/hooks/use-app-destinations"; +import { useOfficeModeState } from "@/hooks/use-in-office"; +import { useSidebarLayoutNavigation } from "@/hooks/domains/sidebar/use-sidebar-layout-navigation"; +import { requestNewTaskCreation } from "@/lib/desktop/new-task-request"; +import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection"; +import type { ResolvedDestination } from "@/lib/navigation/types"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import type { ShortcutActivityReader } from "@/hooks/domains/sidebar/use-shortcut-activity"; +import { ShortcutSection } from "@/components/app-sidebar/shortcut-section"; +import type { ShortcutActivation } from "@/components/app-sidebar/shortcut-section-actions"; +import { + selectNeedsYouInboxCount, + selectNeedsYouInboxHasMore, +} from "@/lib/state/slices/needs-you-inbox/selectors"; +import { selectOfficeInboxCount } from "@/lib/state/slices/office/selectors"; +import { NEEDS_YOU_INBOX_HREF } from "@/lib/navigation/needs-you-inbox-destination"; +import { DestinationRows } from "./destination-rows"; + +const INTEGRATION_DESTINATION_IDS = new Set(["azure-devops", "github", "gitlab", "jira", "linear"]); + +type MobileSidebarLayoutNavigationProps = { + onNavigate: () => void; + omitSections: Set; + omitDestinations: string[]; +}; + +type MobileLayoutNodeProps = { + node: ProjectedSidebarNode; + homeDestination?: ResolvedDestination; + destinationHrefs: Map; + integrationEntries: ShortcutCatalogEntry[]; + canvasEntries: ShortcutCatalogEntry[]; + automationEntries: ShortcutCatalogEntry[]; + omitSections: Set; + omitDestinations: string[]; + getActivity: ShortcutActivityReader; + onActivateShortcut: ShortcutActivation; + onNavigate: () => void; +}; + +function resourceShortcuts( + node: ProjectedSidebarNode, + entries: ReturnType["catalog"]["catalog"], + omitDestinations: string[], +): ProjectedSidebarNode { + const shortcuts: ProjectedShortcut[] = entries + .filter( + (entry) => entry.target.kind !== "destination" || !omitDestinations.includes(entry.target.id), + ) + .map((entry, index) => ({ + id: `${node.id}:${entry.target.kind}:${entry.target.id}:${index}`, + target: entry.target, + label: entry.label, + icon: entry.icon ?? node.icon, + ...(entry.href ? { href: entry.href } : {}), + source: entry.source ?? "builtin", + available: entry.available, + })); + return { ...node, shortcuts }; +} + +function filterNodeShortcuts( + node: ProjectedSidebarNode, + omitDestinations: string[], +): ProjectedSidebarNode { + return { + ...node, + shortcuts: node.shortcuts.filter( + (shortcut) => + shortcut.target.kind !== "destination" || !omitDestinations.includes(shortcut.target.id), + ), + }; +} + +function MobilePluginRow({ + node, + href, + onNavigate, +}: { + node: ProjectedSidebarNode; + href?: string; + onNavigate: () => void; +}) { + const Icon = node.icon; + const content = ( + <> + + {node.label} + + ); + if (!href) { + return ( + + ); + } + return ( + + ); +} + +function MobileNewTaskRow({ onNavigate }: { onNavigate: () => void }) { + const { t } = useTranslation(); + return ( + + ); +} + +function MobileRequiredRows({ + onNavigate, + omitSections, + omitDestinations, +}: { + onNavigate: () => void; + omitSections: Set; + omitDestinations: string[]; +}) { + const { t } = useTranslation(); + const primary = useStaticDestinations("mobileMenu", "primary"); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const mode = useOfficeModeState(); + const needsYouEnabled = useFeature("needsYouInbox"); + const needsYouCount = useAppStore(selectNeedsYouInboxCount); + const needsYouHasMore = useAppStore(selectNeedsYouInboxHasMore); + const officeInboxCount = useAppStore(selectOfficeInboxCount); + if (omitSections.has("primary")) return null; + const fixedDestinations = primary.filter( + (destination) => + (destination.id === "tasks" || destination.id === "threads") && + !omitDestinations.includes(destination.id), + ); + return ( +
+ {fixedDestinations.length > 0 && ( + + )} + {mode === "office" && ( + + )} + {needsYouEnabled && workspaceId && ( + + )} +
+ ); +} + +function MobileBuiltinNode({ + node, + homeDestination, + integrationEntries, + canvasEntries, + automationEntries, + omitSections, + omitDestinations, + getActivity, + onActivateShortcut, + onNavigate, +}: MobileLayoutNodeProps) { + switch (node.destinationId) { + case "home": + if (omitSections.has("primary") || omitDestinations.includes("home")) return null; + return homeDestination ? ( + + ) : null; + case "new_task": + return omitDestinations.includes("new_task") ? null : ( + + ); + case "automations": + return ( + + ); + case "canvases": + return ( + + ); + case "integrations": + if (omitSections.has("integrations")) return null; + return ( + + ); + default: + return null; + } +} + +function MobileLayoutNode(props: MobileLayoutNodeProps) { + const { node, omitSections, omitDestinations, destinationHrefs, onNavigate } = props; + if (node.kind === "plugin") { + if (omitSections.has(node.pluginSection ?? "plugins")) return null; + return ( + + ); + } + if (node.kind === "shortcuts") { + return ( + + ); + } + return ; +} + +export function MobileSidebarLayoutNavigation({ + onNavigate, + omitSections, + omitDestinations, +}: MobileSidebarLayoutNavigationProps) { + const { workspaceId, catalog, projection, activity } = useSidebarLayoutNavigation({ + active: true, + }); + const openQuickChat = useQuickChatLauncher(workspaceId); + const openQuickTerminal = useQuickTerminalLauncher(workspaceId); + const primary = useStaticDestinations("mobileMenu", "primary"); + const destinationHrefs = useMemo( + () => + new Map( + catalog.catalog + .filter((entry) => entry.target.kind === "destination") + .map((entry) => [entry.target.id, entry.href]), + ), + [catalog.catalog], + ); + const activateShortcut: ShortcutActivation = useCallback( + (shortcut) => { + if (shortcut.target.kind !== "host_action") return; + onNavigate(); + if (shortcut.target.id === "new_task") requestNewTaskCreation(); + if (shortcut.target.id === "quick_chat") openQuickChat(); + if (shortcut.target.id === "quick_terminal") void openQuickTerminal(); + }, + [onNavigate, openQuickChat, openQuickTerminal], + ); + // The phone menu keeps the same destinations in Office and kanban modes. + // Office-specific required rows are added below, while user-selected + // visibility and order still come from the layout projection. + const visibleNodes = projection.nodes.filter((node) => node.visible); + const homeDestination = primary.find((destination) => destination.id === "home"); + const integrationEntries = catalog.catalog.filter( + (entry) => + entry.target.kind === "destination" && + entry.source !== "plugin" && + INTEGRATION_DESTINATION_IDS.has(entry.target.id), + ); + const canvasEntries = catalog.catalog.filter((entry) => entry.target.kind === "canvas"); + const automationEntries = catalog.catalog.filter((entry) => entry.target.kind === "automation"); + + return ( +
+ {visibleNodes.map((node) => ( + + ))} + +
+ ); +} diff --git a/apps/web/components/runs/use-workspace-automations.test.ts b/apps/web/components/runs/use-workspace-automations.test.ts index fcd232b2e0..2e573a1048 100644 --- a/apps/web/components/runs/use-workspace-automations.test.ts +++ b/apps/web/components/runs/use-workspace-automations.test.ts @@ -62,6 +62,14 @@ describe("useWorkspaceAutomations", () => { expect(result.current.automations).toEqual([]); }); + it("does not call the API for an inactive navigation surface", async () => { + const { result } = renderHook(() => useWorkspaceAutomations(WORKSPACE, { active: false })); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(mockList).not.toHaveBeenCalled(); + expect(result.current.automations).toEqual([]); + }); + it("surfaces a load failure instead of an empty list", async () => { mockList.mockRejectedValue(new Error("ws down")); diff --git a/apps/web/components/runs/use-workspace-automations.ts b/apps/web/components/runs/use-workspace-automations.ts index 48446ed086..afb568bf7a 100644 --- a/apps/web/components/runs/use-workspace-automations.ts +++ b/apps/web/components/runs/use-workspace-automations.ts @@ -32,7 +32,10 @@ const NOTHING_LOADED: LoadedAutomations = { automations: EMPTY_AUTOMATIONS, }; -export function useWorkspaceAutomations(workspaceId: string | undefined) { +export function useWorkspaceAutomations( + workspaceId: string | undefined, + { active = true }: { active?: boolean } = {}, +) { const [loaded, setLoaded] = useState(NOTHING_LOADED); const [loading, setLoading] = useState(Boolean(workspaceId)); const [error, setError] = useState(null); @@ -45,7 +48,9 @@ export function useWorkspaceAutomations(workspaceId: string | undefined) { // previous workspace cannot land and repopulate a list that should be empty. const requestId = ++requestRef.current; - if (!workspaceId) { + if (!workspaceId || !active) { + setLoaded({ workspaceId, automations: EMPTY_AUTOMATIONS }); + setError(null); setLoading(false); return; } @@ -65,7 +70,7 @@ export function useWorkspaceAutomations(workspaceId: string | undefined) { if (requestRef.current !== requestId) return; setLoading(false); }); - }, [workspaceId]); + }, [active, workspaceId]); useEffect(() => { refresh(); diff --git a/apps/web/components/settings/layouts/layout-settings.test.tsx b/apps/web/components/settings/layouts/layout-settings.test.tsx index 68ede42309..13839cdffc 100644 --- a/apps/web/components/settings/layouts/layout-settings.test.tsx +++ b/apps/web/components/settings/layouts/layout-settings.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ finePointer: true, + layoutTab: "profiles" as "profiles" | "sidebar", useLayoutSettings: vi.fn(), deleteSelected: vi.fn(), })); @@ -22,15 +23,26 @@ vi.mock("@/components/settings/settings-save-provider", () => ({ })); vi.mock("@/components/settings/settings-target", () => ({ - SettingsTarget: ({ children, ...props }: { children: React.ReactNode }) => ( -
{children}
- ), + SettingsTarget: ({ + children, + targetId: _targetId, + ...props + }: { + children: React.ReactNode; + targetId?: string; + }) =>
{children}
, })); vi.mock("./layout-editor", () => ({ LayoutEditor: () =>
, })); +vi.mock("../sidebar-layout-editor", () => ({ + SidebarLayoutEditor: ({ embedded }: { embedded?: boolean }) => ( +
+ ), +})); + vi.mock("./layout-profile-list", () => ({ LayoutProfileList: () =>
, })); @@ -45,6 +57,10 @@ vi.mock("./use-layout-settings", () => ({ useLayoutSettings: () => mocks.useLayoutSettings(), })); +vi.mock("@/hooks/domains/settings/use-settings-tab", () => ({ + useSettingsTab: () => ({ value: mocks.layoutTab, selectTab: vi.fn() }), +})); + import { LayoutSettings } from "./layout-settings"; const DELETE_PROFILE_TEST_ID = "layout-profile-delete"; @@ -89,12 +105,30 @@ function controller() { beforeEach(() => { mocks.finePointer = true; + mocks.layoutTab = "profiles"; mocks.deleteSelected.mockReset(); mocks.useLayoutSettings.mockReturnValue(controller()); }); afterEach(cleanup); +describe("LayoutSettings tabs", () => { + it("lists sidebar customization as a tab and embeds its editor", () => { + render(); + + expect(screen.getByRole("tab", { name: "settings:layoutProfiles" })).toBeTruthy(); + expect(screen.getByRole("tab", { name: "settings:sidebar" })).toBeTruthy(); + + cleanup(); + mocks.layoutTab = "sidebar"; + render(); + + expect(screen.getByTestId("sidebar-layout-editor-tab").getAttribute("data-embedded")).toBe( + "true", + ); + }); +}); + describe("LayoutSettings deletion confirmation", () => { it("anchors confirmation to the selected profile action on fine pointers", async () => { render(); diff --git a/apps/web/components/settings/layouts/layout-settings.tsx b/apps/web/components/settings/layouts/layout-settings.tsx index d7b8efefcc..8d4197d4cf 100644 --- a/apps/web/components/settings/layouts/layout-settings.tsx +++ b/apps/web/components/settings/layouts/layout-settings.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState, type ReactNode } from "react"; +import { useEffect, useRef, useState, type ReactNode, type RefObject } from "react"; import { IconAlertTriangle, IconLayoutDashboard, IconRestore } from "@tabler/icons-react"; import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; import { Button } from "@kandev/ui/button"; @@ -9,11 +9,20 @@ import { Input } from "@kandev/ui/input"; import { Separator } from "@kandev/ui/separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { SettingsPageHeader } from "@/components/settings/settings-typography"; +import { + SettingsTabs, + SettingsTabsList, + SettingsTabsPanel, + type SettingsTabOption, +} from "@/components/settings/settings-tabs"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useSettingsTab } from "@/hooks/domains/settings/use-settings-tab"; import { LayoutEditor } from "./layout-editor"; import { LayoutProfileList } from "./layout-profile-list"; import { LayoutProfileDeleteConfirmation } from "./layout-profile-delete-confirmation"; import { useLayoutSettings } from "./use-layout-settings"; +import { SidebarLayoutEditor } from "../sidebar-layout-editor"; import { useTranslation } from "react-i18next"; import { SettingsTarget } from "@/components/settings/settings-target"; import { @@ -31,19 +40,20 @@ function defaultActionHelpKey(selectedSavedDefault: boolean, selectedIsDefault: return "settings:useThisLayoutAsTheStarting"; } -function LayoutSettingsHeader() { +function LayoutSettingsHeader({ tabs }: { tabs: ReactNode }) { const { t } = useTranslation(); return ( <> -
-

- - {t("settings:layouts")} -

-

- {t("settings:configureTheInitialDesktopTaskWorkbench")} -

-
+ + + {t("settings:layouts")} + + } + description={t("settings:configureTheInitialDesktopTaskWorkbench")} + tabs={tabs} + /> ); @@ -149,45 +159,29 @@ function SelectedLayoutEditor({ controller }: { controller: Controller }) { ); } -export function LayoutSettings() { +function LayoutProfilesPanel({ + controller, + isFinePointer, + deleteOpen, + deleteAnchorRef, + requestDelete, + closeDelete, + confirmDelete, +}: { + controller: Controller; + isFinePointer: boolean; + deleteOpen: boolean; + deleteAnchorRef: RefObject; + requestDelete: () => void; + closeDelete: () => void; + confirmDelete: () => void; +}) { const { t } = useTranslation(); - const controller = useLayoutSettings(); - const { isFinePointer } = useResponsiveBreakpoint(); - const deleteAnchorRef = useRef(null); - const [deleteProfileId, setDeleteProfileId] = useState(null); - const selectedCustomId = controller.selectedCustom?.id ?? null; - const deleteOpen = deleteProfileId !== null && deleteProfileId === selectedCustomId; - - useEffect(() => { - if (deleteProfileId && deleteProfileId !== selectedCustomId) setDeleteProfileId(null); - }, [deleteProfileId, selectedCustomId]); - - const requestDelete = () => { - if (selectedCustomId) setDeleteProfileId(selectedCustomId); - }; - const closeDelete = () => setDeleteProfileId(null); - const confirmDelete = () => { - if (!deleteOpen) return; - closeDelete(); - controller.deleteSelected(); - }; - const invalidName = controller.profiles.some((profile) => !profile.name.trim()); - useSettingsSaveContributor({ - id: "layout-profiles", - revision: controller.profilesKey, - isDirty: controller.isDirty, - canSave: !invalidName, - invalidReason: invalidName ? t("settings:layoutProfileNamesMustNotBe") : undefined, - save: controller.save, - discard: controller.cancel, - }); return ( - {controller.error && ( @@ -228,3 +222,66 @@ export function LayoutSettings() { ); } + +export function LayoutSettings() { + const { t } = useTranslation(); + const controller = useLayoutSettings(); + const { isFinePointer } = useResponsiveBreakpoint(); + const deleteAnchorRef = useRef(null); + const [deleteProfileId, setDeleteProfileId] = useState(null); + const selectedCustomId = controller.selectedCustom?.id ?? null; + const deleteOpen = deleteProfileId !== null && deleteProfileId === selectedCustomId; + const tabs: SettingsTabOption[] = [ + { id: "profiles", label: t("settings:layoutProfiles") }, + { id: "sidebar", label: t("settings:sidebar") }, + ]; + const { value, selectTab } = useSettingsTab({ + tabs: tabs.map((tab) => tab.id), + defaultTab: "profiles", + }); + + useEffect(() => { + if (deleteProfileId && deleteProfileId !== selectedCustomId) setDeleteProfileId(null); + }, [deleteProfileId, selectedCustomId]); + + const requestDelete = () => { + if (selectedCustomId) setDeleteProfileId(selectedCustomId); + }; + const closeDelete = () => setDeleteProfileId(null); + const confirmDelete = () => { + if (!deleteOpen) return; + closeDelete(); + controller.deleteSelected(); + }; + const invalidName = controller.profiles.some((profile) => !profile.name.trim()); + useSettingsSaveContributor({ + id: "layout-profiles", + revision: controller.profilesKey, + isDirty: controller.isDirty, + canSave: !invalidName, + invalidReason: invalidName ? t("settings:layoutProfileNamesMustNotBe") : undefined, + save: controller.save, + discard: controller.cancel, + }); + return ( + +
+ } /> + + + + + + +
+
+ ); +} diff --git a/apps/web/components/settings/settings-breadcrumb-labels.ts b/apps/web/components/settings/settings-breadcrumb-labels.ts index 724badb964..3635546375 100644 --- a/apps/web/components/settings/settings-breadcrumb-labels.ts +++ b/apps/web/components/settings/settings-breadcrumb-labels.ts @@ -76,6 +76,7 @@ const SEGMENT_LABEL_KEYS: Record = { repositories: "sidebar:repositories", secrets: "settings:secrets", security: "settings:security", + sidebar: "settings:sidebar", status: "common:status", storage: "system:storageTitle", system: "common:system", diff --git a/apps/web/components/settings/sidebar-layout-editor-dnd.test.ts b/apps/web/components/settings/sidebar-layout-editor-dnd.test.ts new file mode 100644 index 0000000000..991832089a --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-dnd.test.ts @@ -0,0 +1,139 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { describe, expect, it } from "vitest"; +import { + defaultSidebarLayout, + SIDEBAR_LAYOUT_LIMITS, + type SidebarLayout, +} from "@/lib/sidebar/layout-types"; +import { + handleSidebarLayoutDragEnd, + type SidebarLayoutDragData, +} from "./sidebar-layout-editor-dnd"; + +function layoutWithGroups(): SidebarLayout { + return { + ...defaultSidebarLayout(), + nodes: [ + { + id: "group-a", + kind: "shortcuts", + visible: true, + name: "A", + shortcuts: [{ id: "chat", target: { kind: "host_action", id: "quick_chat" } }], + }, + { id: "group-b", kind: "shortcuts", visible: true, name: "B", shortcuts: [] }, + ], + }; +} + +function dragEvent(active: SidebarLayoutDragData, over: SidebarLayoutDragData): DragEndEvent { + return { + active: { data: { current: active } }, + over: { data: { current: over } }, + } as unknown as DragEndEvent; +} + +function expectRejectedDrop( + layout: SidebarLayout, + active: SidebarLayoutDragData, + over: SidebarLayoutDragData, +) { + let failed = false; + handleSidebarLayoutDragEnd(dragEvent(active, over), layout, (operation) => { + try { + operation(layout); + } catch { + failed = true; + } + return false; + }); + expect(failed).toBe(true); +} + +describe("sidebar layout drag and drop", () => { + it("reorders layout groups through the drag handler", () => { + const layout = layoutWithGroups(); + let next = layout; + + handleSidebarLayoutDragEnd( + dragEvent({ type: "node", nodeId: "group-b" }, { type: "node", nodeId: "group-a" }), + layout, + (operation) => { + next = operation(next); + return true; + }, + ); + + expect(next.nodes.map((node) => node.id)).toEqual(["group-b", "group-a"]); + }); + + it("moves a shortcut across groups through the drag handler", () => { + const layout = layoutWithGroups(); + let next = layout; + + handleSidebarLayoutDragEnd( + dragEvent( + { type: "shortcut", nodeId: "group-a", shortcutId: "chat" }, + { type: "group", nodeId: "group-b" }, + ), + layout, + (operation) => { + next = operation(next); + return true; + }, + ); + + expect(next.nodes.find((node) => node.id === "group-a")?.shortcuts).toEqual([]); + expect(next.nodes.find((node) => node.id === "group-b")?.shortcuts).toEqual([ + { id: "chat", target: { kind: "host_action", id: "quick_chat" } }, + ]); + }); + + it("lets the editor operation boundary reject a duplicate cross-group drop", () => { + const layout = layoutWithGroups(); + const duplicate = { + ...layout, + nodes: layout.nodes.map((node) => + node.id === "group-b" + ? { + ...node, + shortcuts: [ + { id: "existing", target: { kind: "host_action" as const, id: "quick_chat" } }, + ], + } + : node, + ), + }; + expectRejectedDrop( + duplicate, + { type: "shortcut", nodeId: "group-a", shortcutId: "chat" }, + { type: "group", nodeId: "group-b" }, + ); + }); + + it("lets the editor operation boundary reject a full destination", () => { + const layout = layoutWithGroups(); + const fullDestination = { + ...layout, + nodes: layout.nodes.map((node) => + node.id === "group-b" + ? { + ...node, + shortcuts: Array.from( + { length: SIDEBAR_LAYOUT_LIMITS.shortcutsPerGroup }, + (_, index) => ({ + id: `existing-${index}`, + target: { kind: "host_action" as const, id: `action-${index}` }, + }), + ), + } + : node, + ), + }; + expectRejectedDrop( + fullDestination, + { type: "shortcut", nodeId: "group-a", shortcutId: "chat" }, + { type: "group", nodeId: "group-b" }, + ); + }); +}); diff --git a/apps/web/components/settings/sidebar-layout-editor-dnd.ts b/apps/web/components/settings/sidebar-layout-editor-dnd.ts new file mode 100644 index 0000000000..f4e2a8dbb8 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-dnd.ts @@ -0,0 +1,92 @@ +import type { DragEndEvent } from "@dnd-kit/core"; +import { moveSection, moveShortcut } from "@/lib/sidebar/layout-operations"; +import type { SidebarLayout } from "@/lib/sidebar/layout-types"; +import type { DraftOperation } from "./sidebar-layout-editor-types"; + +export type SidebarLayoutDragData = { + type: "node" | "shortcut" | "group"; + nodeId: string; + shortcutId?: string; +}; + +export function nodeDragId(nodeId: string): string { + return `sidebar-node:${encodeURIComponent(nodeId)}`; +} + +export function groupDropId(nodeId: string): string { + return `sidebar-group:${encodeURIComponent(nodeId)}`; +} + +export function shortcutDragId(nodeId: string, shortcutId: string): string { + return `sidebar-shortcut:${encodeURIComponent(nodeId)}|${encodeURIComponent(shortcutId)}`; +} + +export function moveShortcutToSection( + layout: SidebarLayout, + shortcutId: string, + sourceNodeId: string, + destinationNodeId: string, +): SidebarLayout { + const destination = layout.nodes.find((node) => node.id === destinationNodeId); + return moveShortcut( + layout, + shortcutId, + sourceNodeId, + destinationNodeId, + destination?.shortcuts?.length ?? 0, + ); +} + +function applyNodeDrag( + draft: SidebarLayout, + active: SidebarLayoutDragData, + over: SidebarLayoutDragData, + onApply: (operation: DraftOperation) => boolean, +): void { + if (over.type !== "node") return; + const from = draft.nodes.findIndex((node) => node.id === active.nodeId); + const to = draft.nodes.findIndex((node) => node.id === over.nodeId); + if (from >= 0 && to >= 0 && from !== to) { + onApply((current) => moveSection(current, active.nodeId, to)); + } +} + +function applyShortcutDrag( + draft: SidebarLayout, + active: SidebarLayoutDragData, + over: SidebarLayoutDragData, + onApply: (operation: DraftOperation) => boolean, +): void { + if (active.type !== "shortcut" || !active.shortcutId) return; + if (over.type === "shortcut") { + const destination = draft.nodes.find((node) => node.id === over.nodeId); + const position = + destination?.shortcuts?.findIndex((shortcut) => shortcut.id === over.shortcutId) ?? -1; + if (position >= 0) { + onApply((current) => + moveShortcut(current, active.shortcutId!, active.nodeId, over.nodeId, position), + ); + } + return; + } + if (over.type === "group") { + onApply((current) => + moveShortcutToSection(current, active.shortcutId!, active.nodeId, over.nodeId), + ); + } +} + +export function handleSidebarLayoutDragEnd( + event: DragEndEvent, + draft: SidebarLayout, + onApply: (operation: DraftOperation) => boolean, +): void { + const active = event.active.data.current as SidebarLayoutDragData | undefined; + const over = event.over?.data.current as SidebarLayoutDragData | undefined; + if (!active || !over) return; + if (active.type === "node") { + applyNodeDrag(draft, active, over, onApply); + return; + } + applyShortcutDrag(draft, active, over, onApply); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-focused-group.tsx b/apps/web/components/settings/sidebar-layout-editor-focused-group.tsx new file mode 100644 index 0000000000..7042e3793e --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-focused-group.tsx @@ -0,0 +1,237 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { + IconArrowLeft, + IconChevronDown, + IconChevronUp, + IconPlus, + IconTrash, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { SettingsPageHeader } from "@/components/settings/settings-typography"; +import { moveShortcut, removeShortcut } from "@/lib/sidebar/layout-operations"; +import type { SidebarLayout } from "@/lib/sidebar/layout-types"; +import type { ProjectedSidebarNode } from "@/lib/sidebar/layout-projection"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import type { DraftOperation } from "./sidebar-layout-editor-types"; +import { + SidebarShortcutPicker, + ShortcutSectionMoveMenu, +} from "./sidebar-layout-editor-shortcut-picker"; + +const isEditableGroup = (node: SidebarLayout["nodes"][number]): boolean => + node.kind === "shortcuts"; + +function FocusedShortcutRows({ + node, + sections, + readOnly, + onApply, + t, +}: { + node: ProjectedSidebarNode; + sections: SidebarLayout["nodes"]; + readOnly: boolean; + onApply: (operation: DraftOperation) => boolean; + t: (key: string, options?: Record) => string; +}) { + return ( +
+ {node.shortcuts.map((shortcut, index) => { + const Icon = shortcut.icon; + return ( +
+ + {shortcut.label} + {!readOnly && ( + + onApply((current) => { + const destination = current.nodes.find((item) => item.id === destinationNodeId); + return moveShortcut( + current, + shortcut.id, + node.id, + destinationNodeId, + destination?.shortcuts?.length ?? 0, + ); + }) + } + t={t} + /> + )} + + + +
+ ); + })} +
+ ); +} + +function UnsupportedLayoutNotice({ + onLoadLatest, + latestLoading, + t, +}: { + onLoadLatest: () => void; + latestLoading: boolean; + t: (key: string) => string; +}) { + return ( +
+

{t("settings:sidebarUnsupportedVersion")}

+ +
+ ); +} + +export function FocusedSidebarGroup({ + node, + readOnly, + draft, + catalog, + loading, + error, + canvasError, + query, + onQueryChange, + onBack, + onLoadLatest, + latestLoading, + onOpenPicker, + pickerOpen, + onClosePicker, + onAdd, + onApply, + onSetNameDraft, + onCommitName, +}: { + node: ProjectedSidebarNode; + readOnly: boolean; + draft: SidebarLayout; + catalog: ShortcutCatalogEntry[]; + loading: boolean; + error: string | null; + canvasError?: string | null; + query: string; + onQueryChange: (value: string) => void; + onBack: () => void; + onLoadLatest: () => void; + latestLoading: boolean; + onOpenPicker: () => void; + pickerOpen: boolean; + onClosePicker: () => void; + onAdd: (entry: ShortcutCatalogEntry) => void; + onApply: (operation: DraftOperation) => boolean; + onSetNameDraft: (nodeId: string, name: string) => void; + onCommitName: (nodeId: string, name: string) => void; +}) { + const { t } = useTranslation(); + const sections = draft.nodes.filter(isEditableGroup); + return ( +
+ {readOnly && ( + + )} + + + onSetNameDraft(node.id, event.target.value)} + onBlur={() => onCommitName(node.id, node.name ?? "")} + disabled={readOnly} + className="min-h-11" + /> + + + {pickerOpen && !readOnly && ( + `${shortcut.target.kind}:${shortcut.target.id}`, + )} + t={t} + /> + )} +
+ ); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-node-item.tsx b/apps/web/components/settings/sidebar-layout-editor-node-item.tsx new file mode 100644 index 0000000000..d9fc455e28 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-node-item.tsx @@ -0,0 +1,383 @@ +"use client"; + +import type { HTMLAttributes } from "react"; +import { useDroppable } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { + IconChevronDown, + IconChevronUp, + IconGripVertical, + IconPlus, + IconTrash, + IconX, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Input } from "@kandev/ui/input"; +import { Switch } from "@kandev/ui/switch"; +import type { SidebarLayoutNode } from "@/lib/sidebar/layout-types"; +import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import { + SidebarShortcutPicker, + ShortcutSectionMoveMenu, +} from "./sidebar-layout-editor-shortcut-picker"; +import { + groupDropId, + nodeDragId, + shortcutDragId, + type SidebarLayoutDragData, +} from "./sidebar-layout-editor-dnd"; + +const SECTION_NAME_LABEL_KEY = "settings:sectionName"; +const BUILTIN_LABEL_KEYS: Record = { + home: "sidebar:home", + new_task: "sidebar:newTask", + automations: "common:automations", + canvases: "canvases:canvases", + integrations: "common:integrations", +}; + +function nodeTitle( + node: SidebarLayoutNode, + projected: ProjectedSidebarNode | undefined, + t: (key: string) => string, +): string { + if (projected?.available === false) return projected.label; + if (node.name) return node.name; + return node.destinationId && BUILTIN_LABEL_KEYS[node.destinationId] + ? t(BUILTIN_LABEL_KEYS[node.destinationId]) + : node.id; +} + +function handleProps( + attributes: object, + listeners: object | undefined, +): HTMLAttributes { + return { ...attributes, ...(listeners ?? {}) } as HTMLAttributes; +} + +type NodeEditorProps = { + node: SidebarLayoutNode; + projected?: ProjectedSidebarNode; + sections: SidebarLayoutNode[]; + index: number; + total: number; + catalog: ShortcutCatalogEntry[]; + loading: boolean; + catalogError: string | null; + canvasError?: string | null; + readOnly: boolean; + pickerOpen: boolean; + query: string; + onQueryChange: (value: string) => void; + onToggle: () => void; + onMove: (position: number) => void; + onRemove: () => void; + onSetNameDraft: (nodeId: string, name: string) => void; + onCommitName: (nodeId: string, name: string) => void; + onOpenPicker: () => void; + onClosePicker: () => void; + onAdd: (entry: ShortcutCatalogEntry) => void; + onRemoveShortcut: (shortcutId: string) => void; + onMoveShortcut: (shortcutId: string, position: number) => void; + onMoveShortcutToSection: (shortcutId: string, destinationNodeId: string) => void; + onFocus: () => void; + t: (key: string, options?: Record) => string; +}; + +export function SidebarLayoutNodeEditor(props: NodeEditorProps) { + const { node } = props; + const sortable = useSortable({ + id: nodeDragId(node.id), + disabled: props.readOnly, + data: { type: "node", nodeId: node.id } satisfies SidebarLayoutDragData, + }); + const style = { + transform: CSS.Transform.toString(sortable.transform), + transition: sortable.transition, + }; + return ( +
+ + +
+ ); +} + +function SidebarLayoutNodeHeader({ + node, + projected, + index, + total, + onToggle, + onMove, + onRemove, + onSetNameDraft, + onCommitName, + dragHandleProps, + readOnly, + t, +}: NodeEditorProps & { dragHandleProps: HTMLAttributes }) { + const isGroup = node.kind === "shortcuts"; + return ( +
+ + + {isGroup ? ( + onSetNameDraft(node.id, event.target.value)} + onBlur={() => onCommitName(node.id, node.name ?? "")} + disabled={readOnly} + className="min-h-7 min-w-0 flex-1 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" + /> + ) : ( + + {nodeTitle(node, projected, t)} + + )} + + + + {isGroup && ( + + )} +
+ ); +} + +function SidebarLayoutShortcutList({ + node, + projected, + sections, + catalog, + loading, + catalogError, + canvasError, + readOnly, + pickerOpen, + query, + onQueryChange, + onOpenPicker, + onClosePicker, + onAdd, + onRemoveShortcut, + onMoveShortcut, + onMoveShortcutToSection, + onFocus, + t, +}: NodeEditorProps) { + const { setNodeRef } = useDroppable({ + id: groupDropId(node.id), + disabled: readOnly, + data: { type: "group", nodeId: node.id } satisfies SidebarLayoutDragData, + }); + if (node.kind !== "shortcuts") return null; + const shortcuts = projected?.shortcuts ?? []; + return ( +
+ shortcutDragId(node.id, shortcut.id))} + strategy={verticalListSortingStrategy} + > + {shortcuts.map((shortcut, shortcutIndex) => ( + + ))} + +
+ + +
+ {pickerOpen && !readOnly && ( + `${shortcut.target.kind}:${shortcut.target.id}`)} + t={t} + /> + )} +
+ ); +} + +function SortableSidebarShortcut({ + nodeId, + shortcut, + readOnly, + shortcutIndex, + shortcutCount, + sections, + t, + onMoveShortcut, + onRemoveShortcut, + onMoveShortcutToSection, +}: { + nodeId: string; + shortcut: ProjectedShortcut; + readOnly: boolean; + shortcutIndex: number; + shortcutCount: number; + sections: SidebarLayoutNode[]; + t: (key: string, options?: Record) => string; + onMoveShortcut: (shortcutId: string, position: number) => void; + onRemoveShortcut: (shortcutId: string) => void; + onMoveShortcutToSection: (shortcutId: string, destinationNodeId: string) => void; +}) { + const sortable = useSortable({ + id: shortcutDragId(nodeId, shortcut.id), + disabled: readOnly, + data: { type: "shortcut", nodeId, shortcutId: shortcut.id } satisfies SidebarLayoutDragData, + }); + const Icon = shortcut.icon; + return ( +
+ + +
+ ); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-node-list.tsx b/apps/web/components/settings/sidebar-layout-editor-node-list.tsx new file mode 100644 index 0000000000..cab2fe847b --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-node-list.tsx @@ -0,0 +1,363 @@ +"use client"; + +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { IconPlus } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { + addShortcut, + createShortcutSection, + moveSection, + moveShortcut, + removeShortcut, + removeShortcutSection, + toggleNodeVisibility, +} from "@/lib/sidebar/layout-operations"; +import type { SidebarLayout, SidebarLayoutNode } from "@/lib/sidebar/layout-types"; +import type { ProjectedSidebarNode } from "@/lib/sidebar/layout-projection"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import { generateUUID } from "@/lib/utils"; +import type { DraftOperation } from "./sidebar-layout-editor-types"; +import { SidebarLayoutNodeEditor } from "./sidebar-layout-editor-node-item"; +import { + handleSidebarLayoutDragEnd, + moveShortcutToSection, + nodeDragId, +} from "./sidebar-layout-editor-dnd"; + +const SECTION_NAME_LABEL_KEY = "settings:sectionName"; + +function isEditableGroup(node: SidebarLayoutNode): boolean { + return node.kind === "shortcuts"; +} + +function SidebarLayoutFeedback({ + readOnly, + validation, + validationMessage, + operationError, + saveError, + latestLoading, + onLoadLatest, + t, +}: Pick< + NodesCardProps, + | "readOnly" + | "validation" + | "validationMessage" + | "operationError" + | "saveError" + | "latestLoading" + | "onLoadLatest" + | "t" +>) { + return ( + <> + {readOnly && ( +
+

{t("settings:sidebarUnsupportedVersion")}

+ +
+ )} + {!validation.valid && ( +

+ {validationMessage} +

+ )} + {operationError && ( +

+ {operationError} +

+ )} + {saveError && ( +
+

+ {t(saveError === "conflict" ? "settings:sidebarConflict" : "settings:sidebarSaveError")} +

+ {saveError === "conflict" && ( + + )} +
+ )} + + ); +} + +type NodesCardProps = { + draft: SidebarLayout; + readOnly: boolean; + projected: { nodes: ProjectedSidebarNode[] }; + catalog: ShortcutCatalogEntry[]; + catalogLoading: boolean; + catalogError: string | null; + canvasError?: string | null; + newSectionName: string; + setNewSectionName: (value: string) => void; + pickerNodeId: string | null; + setPickerNodeId: (value: string | null) => void; + pickerQuery: string; + setPickerQuery: (value: string) => void; + validation: { valid: boolean; errors: string[] }; + validationMessage: string | null; + operationError: string | null; + saveError: "conflict" | "error" | null; + latestLoading: boolean; + onLoadLatest: () => void; + onApply: (operation: DraftOperation) => boolean; + onSetNameDraft: (nodeId: string, name: string) => void; + onCommitName: (nodeId: string, name: string) => void; + onSetFocusedNodeId: (value: string | null) => void; + t: (key: string, options?: Record) => string; +}; + +function NewShortcutSectionForm({ + readOnly, + name, + setName, + onApply, + t, +}: { + readOnly: boolean; + name: string; + setName: (value: string) => void; + onApply: (operation: DraftOperation) => boolean; + t: (key: string, options?: Record) => string; +}) { + return ( +
+ setName(event.target.value)} + disabled={readOnly} + placeholder={t(SECTION_NAME_LABEL_KEY)} + className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" + /> + +
+ ); +} + +type NodeListProps = Pick< + NodesCardProps, + | "draft" + | "projected" + | "catalog" + | "catalogLoading" + | "catalogError" + | "canvasError" + | "readOnly" + | "pickerNodeId" + | "pickerQuery" + | "setPickerNodeId" + | "setPickerQuery" + | "onApply" + | "onSetNameDraft" + | "onCommitName" + | "onSetFocusedNodeId" + | "t" +>; + +function SidebarLayoutNodeList({ + draft, + projected, + catalog, + catalogLoading, + catalogError, + canvasError, + readOnly, + pickerNodeId, + pickerQuery, + setPickerNodeId, + setPickerQuery, + onApply, + onSetNameDraft, + onCommitName, + onSetFocusedNodeId, + t, +}: NodeListProps) { + const sections = draft.nodes.filter(isEditableGroup); + return ( + <> + {draft.nodes.map((node, index) => ( + item.id === node.id)} + sections={sections} + catalog={catalog} + loading={catalogLoading} + catalogError={catalogError} + canvasError={canvasError} + readOnly={readOnly} + pickerOpen={pickerNodeId === node.id} + query={pickerQuery} + onQueryChange={setPickerQuery} + onToggle={() => + onApply((current) => toggleNodeVisibility(current, node.id, !node.visible)) + } + onMove={(position) => onApply((current) => moveSection(current, node.id, position))} + onRemove={() => onApply((current) => removeShortcutSection(current, node.id))} + onSetNameDraft={onSetNameDraft} + onCommitName={onCommitName} + onOpenPicker={() => setPickerNodeId(node.id)} + onClosePicker={() => setPickerNodeId(null)} + onAdd={(entry) => { + if ( + onApply((current) => + addShortcut(current, node.id, { id: generateUUID(), target: entry.target }), + ) + ) { + setPickerQuery(""); + } + }} + onRemoveShortcut={(shortcutId) => + onApply((current) => removeShortcut(current, node.id, shortcutId)) + } + onMoveShortcut={(shortcutId, position) => + onApply((current) => moveShortcut(current, shortcutId, node.id, node.id, position)) + } + onMoveShortcutToSection={(shortcutId, destinationNodeId) => + onApply((current) => + moveShortcutToSection(current, shortcutId, node.id, destinationNodeId), + ) + } + onFocus={() => onSetFocusedNodeId(node.id)} + t={t} + /> + ))} + + ); +} + +export function SidebarLayoutNodesCard({ + draft, + readOnly, + projected, + catalog, + catalogLoading, + catalogError, + canvasError, + newSectionName, + setNewSectionName, + pickerNodeId, + setPickerNodeId, + pickerQuery, + setPickerQuery, + validation, + validationMessage, + operationError, + saveError, + latestLoading, + onLoadLatest, + onApply, + onSetNameDraft, + onCommitName, + onSetFocusedNodeId, + t, +}: NodesCardProps) { + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + return ( + + + {t("settings:sidebarLayout")} + + + { + if (!readOnly) handleSidebarLayoutDragEnd(event, draft, onApply); + }} + > + nodeDragId(node.id))} + strategy={verticalListSortingStrategy} + > + + + + + + + + ); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-preview.tsx b/apps/web/components/settings/sidebar-layout-editor-preview.tsx new file mode 100644 index 0000000000..4d1879ef77 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-preview.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import type { ProjectedSidebarNode } from "@/lib/sidebar/layout-projection"; + +export function SidebarDraftPreview({ + nodes, + t, +}: { + nodes: ProjectedSidebarNode[]; + t: (key: string) => string; +}) { + return ( + + + {t("settings:sidebarPreview")} + + + {nodes.map((node) => ( +
+
+ + {node.label} + {node.kind === "shortcuts" && ( + {node.shortcuts.length} + )} +
+ {node.kind === "shortcuts" && node.shortcuts.length > 0 && ( +
+ {node.shortcuts.map((shortcut) => { + const Icon = shortcut.icon; + return ( + + ); + })} +
+ )} +
+ ))} + {nodes.length === 0 && ( +

{t("settings:sidebarPreviewEmpty")}

+ )} +
+
+ ); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-save.test.ts b/apps/web/components/settings/sidebar-layout-editor-save.test.ts new file mode 100644 index 0000000000..d42e30e15b --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-save.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import { defaultSidebarLayout, type SidebarLayout } from "@/lib/sidebar/layout-types"; +import { submitSidebarLayout } from "./sidebar-layout-editor-save"; + +const mocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() })); +const WORKSPACE_ID = "workspace-1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: mocks.updateUserSettings, + fetchUserSettings: vi.fn(), +})); + +function layoutWithGroup(name: string): SidebarLayout { + const layout = defaultSidebarLayout(); + layout.nodes.push({ + id: name.toLowerCase().replaceAll(" ", "-"), + kind: "shortcuts", + visible: true, + name, + shortcuts: [], + }); + return layout; +} + +describe("submitSidebarLayout", () => { + it("keeps a response from a previous workspace out of the current saved baseline", async () => { + let resolveSave: ((value: unknown) => void) | undefined; + mocks.updateUserSettings.mockReturnValue( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const submittedLayout = layoutWithGroup("Workspace one"); + const currentLayout = defaultSidebarLayout(); + const draftRef = { current: submittedLayout }; + const savedRef = { current: submittedLayout }; + const workspaceRef = { current: WORKSPACE_ID as string | null }; + const generationsRef = { current: new Map() }; + const requestGenerationsRef = { current: new Map() }; + const acknowledge = vi.fn(); + const setUserSettings = vi.fn(); + const onOperationError = vi.fn(); + const store = { getState: () => ({ userSettings: {} as never }) }; + const pending = submitSidebarLayout({ + catalog: [], + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, + }); + + workspaceRef.current = "workspace-2"; + savedRef.current = currentLayout; + draftRef.current = layoutWithGroup("Workspace two"); + await resolveSave?.({ + settings: { + sidebar_layouts_by_workspace: { + [WORKSPACE_ID]: { + version: 1, + revision: 1, + nodes: [], + }, + }, + }, + }); + await pending; + + expect(savedRef.current).toEqual(currentLayout); + expect(draftRef.current.nodes.at(-1)?.name).toBe("Workspace two"); + expect(acknowledge).toHaveBeenCalledWith( + WORKSPACE_ID, + expect.objectContaining({ revision: 1 }), + false, + ); + }); +}); + +describe("submission ordering", () => { + it("ignores an older response after a newer save starts", async () => { + const resolvers: Array<(value: unknown) => void> = []; + mocks.updateUserSettings.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const first = layoutWithGroup("First"); + const second = layoutWithGroup("Second"); + const draftRef = { current: first }; + const savedRef = { current: defaultSidebarLayout() }; + const workspaceRef = { current: WORKSPACE_ID as string | null }; + const generationsRef = { current: new Map() }; + const requestGenerationsRef = { current: new Map() }; + const acknowledge = vi.fn(); + const setUserSettings = vi.fn(); + const onOperationError = vi.fn(); + const store = { getState: () => ({ userSettings: {} as never }) }; + + const firstSave = submitSidebarLayout({ + catalog: [], + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, + }); + draftRef.current = second; + generationsRef.current.set(WORKSPACE_ID, 1); + const secondSave = submitSidebarLayout({ + catalog: [], + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, + }); + + resolvers[1]?.({ + settings: { + sidebar_layouts_by_workspace: { + [WORKSPACE_ID]: { version: 1, revision: 2, nodes: second.nodes }, + }, + }, + }); + await secondSave; + resolvers[0]?.({ + settings: { + sidebar_layouts_by_workspace: { + [WORKSPACE_ID]: { version: 1, revision: 1, nodes: first.nodes }, + }, + }, + }); + await firstSave; + + expect(savedRef.current.revision).toBe(2); + expect(draftRef.current.nodes.at(-1)?.name).toBe("Second"); + expect(acknowledge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/settings/sidebar-layout-editor-save.ts b/apps/web/components/settings/sidebar-layout-editor-save.ts new file mode 100644 index 0000000000..9411d700a3 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-save.ts @@ -0,0 +1,315 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react"; +import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; +import { ApiError } from "@/lib/api/client"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { + defaultSidebarLayout, + fromApiSidebarLayout, + toApiSidebarLayout, + type SidebarLayout, +} from "@/lib/sidebar/layout-types"; +import { materializeSidebarPluginNodes } from "@/lib/sidebar/layout-projection"; +import { layoutValue } from "./sidebar-layout-editor-state"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; + +type SaveContext = { + catalog: ShortcutCatalogEntry[]; + acknowledge: (workspaceId: string, nextSaved: SidebarLayout, applyDraft: boolean) => void; + setUserSettings: (settings: UserSettingsState) => void; + store: { getState: () => { userSettings: UserSettingsState } }; + draftRef: MutableRefObject; + savedRef: MutableRefObject; + workspaceRef: MutableRefObject; + generationsRef: MutableRefObject>; + requestGenerationsRef: MutableRefObject>; + onOperationError: (value: string | null) => void; +}; + +export async function submitSidebarLayout({ + catalog, + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, +}: SaveContext) { + const submittedWorkspaceId = workspaceRef.current; + if (!submittedWorkspaceId) return; + const submittedGeneration = generationsRef.current.get(submittedWorkspaceId) ?? 0; + const submittedRequestGeneration = + (requestGenerationsRef.current.get(submittedWorkspaceId) ?? 0) + 1; + requestGenerationsRef.current.set(submittedWorkspaceId, submittedRequestGeneration); + const submittedRevision = savedRef.current.revision; + const submitted = { ...draftRef.current, revision: submittedRevision }; + const response = await updateUserSettings({ + sidebar_layout_state: { + workspace_id: submittedWorkspaceId, + expected_revision: submittedRevision, + layout: toApiSidebarLayout(submitted), + }, + }); + const latest = response.settings.sidebar_layouts_by_workspace?.[submittedWorkspaceId]; + const next = latest + ? fromApiSidebarLayout(latest) + : { ...submitted, revision: submittedRevision + 1 }; + if (requestGenerationsRef.current.get(submittedWorkspaceId) !== submittedRequestGeneration) { + return; + } + const stillCurrent = + workspaceRef.current === submittedWorkspaceId && + (generationsRef.current.get(submittedWorkspaceId) ?? 0) === submittedGeneration && + layoutValue(draftRef.current) === layoutValue(submitted); + acknowledge(submittedWorkspaceId, next, stillCurrent); + if (workspaceRef.current === submittedWorkspaceId) { + savedRef.current = materializeSidebarPluginNodes(next, catalog); + if (stillCurrent) { + draftRef.current = savedRef.current; + } + } + if (workspaceRef.current === submittedWorkspaceId) { + const current = store.getState().userSettings; + setUserSettings(mapUserSettingsResponse(response, current)); + onOperationError(null); + } +} + +function useLatestSidebarLayout({ + workspaceId, + catalog, + acknowledge, + setUserSettings, + store, + savedRef, + workspaceRef, + draftRef, + setStatus, +}: { + workspaceId: string | null; + catalog: ShortcutCatalogEntry[]; + acknowledge: (workspaceId: string, nextSaved: SidebarLayout, applyDraft: boolean) => void; + setUserSettings: (settings: UserSettingsState) => void; + store: { getState: () => { userSettings: UserSettingsState } }; + savedRef: MutableRefObject; + workspaceRef: MutableRefObject; + draftRef: MutableRefObject; + setStatus: (value: "error" | null) => void; +}) { + const [latestLoading, setLatestLoading] = useState(false); + const latestAttemptRef = useRef(0); + + useEffect(() => { + latestAttemptRef.current += 1; + setLatestLoading(false); + }, [workspaceId]); + + const loadLatest = useCallback(async () => { + if (!workspaceId) return; + const requestedWorkspaceId = workspaceId; + const requestedAttempt = ++latestAttemptRef.current; + setLatestLoading(true); + try { + const response = await fetchUserSettings({ cache: "no-store" }); + const latest = response.settings.sidebar_layouts_by_workspace?.[requestedWorkspaceId]; + const next = latest ? fromApiSidebarLayout(latest) : defaultSidebarLayout(); + if (latestAttemptRef.current !== requestedAttempt) return; + const recoverUnsupportedDraft = + draftRef.current.unsupportedVersion === true && next.unsupportedVersion !== true; + acknowledge(requestedWorkspaceId, next, recoverUnsupportedDraft); + if ( + workspaceRef.current === requestedWorkspaceId && + latestAttemptRef.current === requestedAttempt + ) { + savedRef.current = materializeSidebarPluginNodes(next, catalog); + } + if ( + workspaceRef.current === requestedWorkspaceId && + latestAttemptRef.current === requestedAttempt + ) { + const current = store.getState().userSettings; + setUserSettings(mapUserSettingsResponse(response, current)); + setStatus(null); + } + } catch { + if ( + workspaceRef.current === requestedWorkspaceId && + latestAttemptRef.current === requestedAttempt + ) { + setStatus("error"); + } + } finally { + if ( + workspaceRef.current === requestedWorkspaceId && + latestAttemptRef.current === requestedAttempt + ) { + setLatestLoading(false); + } + } + }, [ + acknowledge, + catalog, + draftRef, + savedRef, + setStatus, + setUserSettings, + store, + workspaceId, + workspaceRef, + ]); + + return { latestLoading, loadLatest }; +} + +function discardSidebarLayout({ + draftRef, + savedRef, + setDraft, + workspaceRef, + generationsRef, + requestGenerationsRef, + setSaveError, + onOperationError, +}: { + draftRef: MutableRefObject; + savedRef: MutableRefObject; + setDraft: (next: SidebarLayout) => void; + workspaceRef: MutableRefObject; + generationsRef: MutableRefObject>; + requestGenerationsRef: MutableRefObject>; + setSaveError: (value: "conflict" | "error" | null) => void; + onOperationError: (value: string | null) => void; +}) { + const currentWorkspaceId = workspaceRef.current; + if (!currentWorkspaceId) return; + generationsRef.current.set( + currentWorkspaceId, + (generationsRef.current.get(currentWorkspaceId) ?? 0) + 1, + ); + requestGenerationsRef.current.set( + currentWorkspaceId, + (requestGenerationsRef.current.get(currentWorkspaceId) ?? 0) + 1, + ); + draftRef.current = savedRef.current; + setDraft(savedRef.current); + setSaveError(null); + onOperationError(null); +} + +type SidebarLayoutSaveProps = { + workspaceId: string | null; + draft: SidebarLayout; + dirty: boolean; + validationValid: boolean; + invalidReason?: string; + catalog: ShortcutCatalogEntry[]; + setDraft: (next: SidebarLayout | ((current: SidebarLayout) => SidebarLayout)) => void; + acknowledge: (workspaceId: string, nextSaved: SidebarLayout, applyDraft: boolean) => void; + setUserSettings: (settings: UserSettingsState) => void; + store: { getState: () => { userSettings: UserSettingsState } }; + draftRef: MutableRefObject; + savedRef: MutableRefObject; + workspaceRef: MutableRefObject; + generationsRef: MutableRefObject>; + requestGenerationsRef: MutableRefObject>; + onOperationError: (value: string | null) => void; +}; + +export function useSidebarLayoutSave({ + workspaceId, + draft, + dirty, + validationValid, + invalidReason, + catalog, + setDraft, + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, +}: SidebarLayoutSaveProps) { + const [saveError, setSaveError] = useState<"conflict" | "error" | null>(null); + const saveAttemptRef = useRef(0); + const saveWorkspaceRef = useRef(workspaceId); + useEffect(() => { + if (saveWorkspaceRef.current === workspaceId) return; + saveWorkspaceRef.current = workspaceId; + saveAttemptRef.current += 1; + setSaveError(null); + }, [workspaceId]); + const { latestLoading, loadLatest } = useLatestSidebarLayout({ + workspaceId, + catalog, + acknowledge, + setUserSettings, + store, + savedRef, + workspaceRef, + draftRef, + setStatus: (value) => setSaveError(value), + }); + useSettingsSaveContributor({ + id: "sidebar-layout", + order: 20, + revision: `${workspaceId ?? "none"}:${layoutValue(draft)}`, + isDirty: Boolean(workspaceId && dirty), + canSave: validationValid && !draft.unsupportedVersion, + invalidReason, + save: async () => { + const requestedWorkspaceId = workspaceRef.current; + const requestedAttempt = ++saveAttemptRef.current; + try { + await submitSidebarLayout({ + catalog, + acknowledge, + setUserSettings, + store, + draftRef, + savedRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + onOperationError, + }); + if ( + workspaceRef.current === requestedWorkspaceId && + saveAttemptRef.current === requestedAttempt + ) { + setSaveError(null); + } + } catch (error) { + if ( + workspaceRef.current === requestedWorkspaceId && + saveAttemptRef.current === requestedAttempt + ) { + setSaveError(error instanceof ApiError && error.status === 409 ? "conflict" : "error"); + } + throw error; + } + }, + discard: () => + discardSidebarLayout({ + draftRef, + savedRef, + setDraft, + workspaceRef, + generationsRef, + requestGenerationsRef, + setSaveError, + onOperationError, + }), + }); + + return { saveError, setSaveError, latestLoading, loadLatest }; +} diff --git a/apps/web/components/settings/sidebar-layout-editor-shortcut-picker.tsx b/apps/web/components/settings/sidebar-layout-editor-shortcut-picker.tsx new file mode 100644 index 0000000000..811f7964f7 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-shortcut-picker.tsx @@ -0,0 +1,156 @@ +"use client"; + +import { IconArrowsExchange, IconX } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Input } from "@kandev/ui/input"; +import { unavailableShortcutIcon, type ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import type { ProjectedShortcut } from "@/lib/sidebar/layout-projection"; +import type { SidebarLayoutNode } from "@/lib/sidebar/layout-types"; + +const BUILTIN_LABEL_KEYS: Record = { + home: "sidebar:home", + new_task: "sidebar:newTask", + automations: "common:automations", + canvases: "canvases:canvases", + integrations: "common:integrations", +}; + +function nodeTitle(node: SidebarLayoutNode, t: (key: string) => string): string { + if (node.name) return node.name; + return node.destinationId && BUILTIN_LABEL_KEYS[node.destinationId] + ? t(BUILTIN_LABEL_KEYS[node.destinationId]) + : node.id; +} + +export function ShortcutSectionMoveMenu({ + shortcut, + currentNodeId, + sections, + onMove, + t, +}: { + shortcut: ProjectedShortcut; + currentNodeId: string; + sections: SidebarLayoutNode[]; + onMove: (destinationNodeId: string) => void; + t: (key: string, options?: Record) => string; +}) { + if (sections.length <= 1) return null; + return ( + + + + + + {t("settings:moveToSection")} + {sections + .filter((section) => section.id !== currentNodeId) + .map((section) => ( + onMove(section.id)}> + {nodeTitle(section, t)} + + ))} + + + ); +} + +export function SidebarShortcutPicker({ + catalog, + loading, + error, + canvasError, + query, + onQueryChange, + onAdd, + onClose, + existing, + t, +}: { + catalog: ShortcutCatalogEntry[]; + loading: boolean; + error: string | null; + canvasError?: string | null; + query: string; + onQueryChange: (value: string) => void; + onAdd: (entry: ShortcutCatalogEntry) => void; + onClose: () => void; + existing: string[]; + t: (key: string) => string; +}) { + const existingSet = new Set(existing); + const queryLower = query.trim().toLocaleLowerCase(); + const entries = catalog.filter( + (entry) => + !existingSet.has(`${entry.target.kind}:${entry.target.id}`) && + (!queryLower || entry.label.toLocaleLowerCase().includes(queryLower)), + ); + return ( +
+
+ onQueryChange(event.target.value)} + placeholder={t("common:searchEllipsis")} + aria-label={t("settings:searchShortcuts")} + className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11" + /> + +
+ {loading &&

{t("common:loading")}

} + {(error || canvasError) && ( +

{t("settings:sidebarShortcutLoadError")}

+ )} + {!loading && !error && entries.length === 0 && ( +

{t("settings:noMatchingShortcuts")}

+ )} + {!error && entries.length > 0 && ( +
+ {entries.map((entry) => { + const Icon = entry.icon ?? unavailableShortcutIcon; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/apps/web/components/settings/sidebar-layout-editor-state.ts b/apps/web/components/settings/sidebar-layout-editor-state.ts new file mode 100644 index 0000000000..7eb3fe5d39 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-state.ts @@ -0,0 +1,192 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import { + defaultSidebarLayout, + fromApiSidebarLayout, + type SidebarLayout, +} from "@/lib/sidebar/layout-types"; +import { materializeSidebarPluginNodes } from "@/lib/sidebar/layout-projection"; +import { + setShortcutSectionNameDraft, + type SidebarLayoutOperationError, +} from "@/lib/sidebar/layout-operations"; +import type { DraftOperation } from "./sidebar-layout-editor-types"; + +export function layoutValue(layout: SidebarLayout): string { + return JSON.stringify(layout.nodes); +} + +export function validationCopy(code: string, t: (key: string) => string): string { + switch (code) { + case "invalid_group_name": + return t("settings:sidebarSectionNameError"); + case "duplicate_target": + return t("settings:sidebarDuplicateShortcutError"); + case "limit_reached": + return t("settings:sidebarLayoutLimitError"); + default: + return t("settings:sidebarLayoutInvalidError"); + } +} + +export function operationCode(error: unknown): string { + return error instanceof Error && "code" in error + ? String((error as SidebarLayoutOperationError).code) + : "invalid_layout"; +} + +function sameScopeLayout( + layouts: Record[0]> | undefined, + workspaceId: string, +): SidebarLayout { + return fromApiSidebarLayout(layouts?.[workspaceId]); +} + +type ScopedDraft = { saved: SidebarLayout; draft: SidebarLayout }; + +export function useSidebarDraft(catalog: ShortcutCatalogEntry[]) { + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const layouts = useAppStore((state) => state.userSettings.sidebarLayoutsByWorkspace); + const setUserSettings = useAppStore((state) => state.setUserSettings); + const store = useAppStoreApi(); + const scopeKey = workspaceId ?? "__none__"; + const fallback = useMemo( + () => + materializeSidebarPluginNodes( + workspaceId ? sameScopeLayout(layouts, workspaceId) : defaultSidebarLayout(), + catalog, + ), + [catalog, layouts, workspaceId], + ); + const [scopes, setScopes] = useState>({}); + const stored = scopes[scopeKey]; + const saved = stored?.saved ?? fallback; + const draft = stored?.draft ?? fallback; + const dirty = layoutValue(draft) !== layoutValue(saved); + const authoritativeRevision = workspaceId ? layouts?.[workspaceId]?.revision : undefined; + + const updateScope = useCallback( + (next: ScopedDraft) => setScopes((current) => ({ ...current, [scopeKey]: next })), + [scopeKey], + ); + + const setDraft = useCallback( + (next: SidebarLayout | ((current: SidebarLayout) => SidebarLayout)) => { + setScopes((currentScopes) => { + const current = currentScopes[scopeKey] ?? { saved, draft }; + const nextDraft = typeof next === "function" ? next(current.draft) : next; + return { + ...currentScopes, + [scopeKey]: { + saved: current.saved, + draft: materializeSidebarPluginNodes(nextDraft, catalog), + }, + }; + }); + }, + [catalog, draft, saved, scopeKey], + ); + + const acknowledge = useCallback( + (ackWorkspaceId: string, nextSaved: SidebarLayout, applyDraft: boolean) => { + setScopes((currentScopes) => { + const current = currentScopes[ackWorkspaceId] ?? { saved: nextSaved, draft: nextSaved }; + return { + ...currentScopes, + [ackWorkspaceId]: { + saved: materializeSidebarPluginNodes(nextSaved, catalog), + draft: applyDraft + ? materializeSidebarPluginNodes(nextSaved, catalog) + : materializeSidebarPluginNodes(current.draft, catalog), + }, + }; + }); + }, + [catalog], + ); + + useEffect(() => { + if (!workspaceId || !stored || stored.saved.revision === authoritativeRevision) return; + if (layoutValue(stored.draft) !== layoutValue(stored.saved)) return; + const next = materializeSidebarPluginNodes(sameScopeLayout(layouts, workspaceId), catalog); + updateScope({ saved: next, draft: next }); + }, [authoritativeRevision, catalog, layouts, stored, updateScope, workspaceId]); + + return { + workspaceId, + scopeKey, + saved, + draft, + dirty, + setDraft, + acknowledge, + setUserSettings, + store, + }; +} + +export function useSidebarLayoutActions({ + draft, + workspaceId, + setDraft, +}: { + draft: SidebarLayout; + workspaceId: string | null; + setDraft: (next: SidebarLayout | ((current: SidebarLayout) => SidebarLayout)) => void; +}) { + const [operationError, setOperationError] = useState(null); + const draftRef = useRef(draft); + const workspaceRef = useRef(workspaceId); + const generationsRef = useRef(new Map()); + const requestGenerationsRef = useRef(new Map()); + const scopeKey = workspaceId ?? "__none__"; + draftRef.current = draft; + workspaceRef.current = workspaceId; + + const applyDraftOperation = useCallback( + (operation: DraftOperation): boolean => { + if (draftRef.current.unsupportedVersion) return false; + try { + const next = operation(draftRef.current); + draftRef.current = next; + generationsRef.current.set(scopeKey, (generationsRef.current.get(scopeKey) ?? 0) + 1); + setDraft(next); + setOperationError(null); + return true; + } catch (error) { + setOperationError(operationCode(error)); + return false; + } + }, + [scopeKey, setDraft], + ); + + const setNameDraft = useCallback( + (nodeId: string, name: string) => { + applyDraftOperation((current) => setShortcutSectionNameDraft(current, nodeId, name)); + }, + [applyDraftOperation], + ); + + const commitName = useCallback( + (rename: DraftOperation) => { + applyDraftOperation(rename); + }, + [applyDraftOperation], + ); + + return { + operationError, + setOperationError, + draftRef, + workspaceRef, + generationsRef, + requestGenerationsRef, + applyDraftOperation, + setNameDraft, + commitName, + }; +} diff --git a/apps/web/components/settings/sidebar-layout-editor-types.ts b/apps/web/components/settings/sidebar-layout-editor-types.ts new file mode 100644 index 0000000000..1d3af51192 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-types.ts @@ -0,0 +1,3 @@ +import type { SidebarLayout } from "@/lib/sidebar/layout-types"; + +export type DraftOperation = (layout: SidebarLayout) => SidebarLayout; diff --git a/apps/web/components/settings/sidebar-layout-editor-view.tsx b/apps/web/components/settings/sidebar-layout-editor-view.tsx new file mode 100644 index 0000000000..c5ccab5483 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor-view.tsx @@ -0,0 +1,135 @@ +"use client"; + +import { Button } from "@kandev/ui/button"; +import { Separator } from "@kandev/ui/separator"; +import { IconRefresh } from "@tabler/icons-react"; +import { SettingsPageHeader } from "@/components/settings/settings-typography"; +import type { SidebarLayout } from "@/lib/sidebar/layout-types"; +import type { + ProjectedSidebarNode, + SidebarLayoutProjection, +} from "@/lib/sidebar/layout-projection"; +import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog"; +import type { DraftOperation } from "./sidebar-layout-editor-types"; +import { SidebarDraftPreview } from "./sidebar-layout-editor-preview"; +import { SidebarLayoutNodesCard } from "./sidebar-layout-editor-node-list"; +import { FocusedSidebarGroup } from "./sidebar-layout-editor-focused-group"; + +const SIDEBAR_LABEL_KEY = "settings:sidebar"; + +export type SidebarLayoutContentProps = { + draft: SidebarLayout; + readOnly: boolean; + projected: SidebarLayoutProjection; + visibleNodes: ProjectedSidebarNode[]; + catalog: ShortcutCatalogEntry[]; + catalogLoading: boolean; + catalogError: string | null; + canvasError?: string | null; + newSectionName: string; + setNewSectionName: (value: string) => void; + pickerNodeId: string | null; + setPickerNodeId: (value: string | null) => void; + pickerQuery: string; + setPickerQuery: (value: string) => void; + validation: { valid: boolean; errors: string[] }; + validationMessage: string | null; + operationError: string | null; + saveError: "conflict" | "error" | null; + latestLoading: boolean; + onLoadLatest: () => void; + onApply: (operation: DraftOperation) => boolean; + onSetNameDraft: (nodeId: string, name: string) => void; + onCommitName: (nodeId: string, name: string) => void; + onReset: () => void; + onSetFocusedNodeId: (value: string | null) => void; + t: (key: string, options?: Record) => string; + isMobile: boolean; + showHeader?: boolean; +}; + +export function SidebarLayoutContent({ + draft, + readOnly, + projected, + visibleNodes, + catalog, + catalogLoading, + catalogError, + canvasError, + newSectionName, + setNewSectionName, + pickerNodeId, + setPickerNodeId, + pickerQuery, + setPickerQuery, + validation, + validationMessage, + operationError, + saveError, + latestLoading, + onLoadLatest, + onApply, + onSetNameDraft, + onCommitName, + onReset, + onSetFocusedNodeId, + t, + isMobile, + showHeader = true, +}: SidebarLayoutContentProps) { + return ( +
+ {showHeader && ( + <> + + + + )} +
+ + +
+ +
+ ); +} + +export { FocusedSidebarGroup }; diff --git a/apps/web/components/settings/sidebar-layout-editor.test.tsx b/apps/web/components/settings/sidebar-layout-editor.test.tsx new file mode 100644 index 0000000000..a860204042 --- /dev/null +++ b/apps/web/components/settings/sidebar-layout-editor.test.tsx @@ -0,0 +1,395 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComponentProps } from "react"; +import { ApiError } from "@/lib/api/client"; +import { SettingsSaveProvider } from "./settings-save-provider"; +import { SidebarLayoutEditor } from "./sidebar-layout-editor"; + +const mocks = vi.hoisted(() => ({ + state: { + workspaces: { activeId: "workspace-1" }, + userSettings: { sidebarLayoutsByWorkspace: {} }, + }, + catalog: [] as Array<{ + target: { kind: "destination"; id: string }; + label: string; + available: boolean; + }>, + isMobile: false, + setUserSettings: vi.fn(), + updateUserSettings: vi.fn(), + fetchUserSettings: vi.fn(), +})); + +const SIDEBAR_LABEL = "Sidebar"; +const ADD_SECTION_LABEL = "Add shortcut section"; +const SECTION_NAME_LABEL = "Section name"; +const MOVE_UP_LABEL = "Move up"; +const MOVE_DOWN_LABEL = "Move down"; +const LOAD_LATEST_LABEL = "Load latest"; +const RETRY_SAVE_LABEL = "Retry save"; +const LOADING_LABEL = "Loading"; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state), + useAppStoreApi: () => ({ getState: () => mocks.state }), +})); + +vi.mock("react-i18next", () => ({ + Trans: ({ children }: { children?: unknown }) => children, + useTranslation: () => ({ + t: (key: string) => + ({ + "settings:sidebar": SIDEBAR_LABEL, + "settings:addShortcutSection": ADD_SECTION_LABEL, + "settings:sectionName": SECTION_NAME_LABEL, + "settings:moveUp": MOVE_UP_LABEL, + "settings:moveDown": MOVE_DOWN_LABEL, + "settings:sidebarConflict": "Conflict", + "settings:sidebarLoadLatest": LOAD_LATEST_LABEL, + "settings:retrySave": RETRY_SAVE_LABEL, + "common:loading": LOADING_LABEL, + })[key] ?? key, + }), +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: mocks.isMobile, isFinePointer: true }), +})); + +vi.mock("@/hooks/domains/sidebar/use-sidebar-shortcut-catalog", () => ({ + useSidebarShortcutCatalog: () => ({ + workspaceId: mocks.state.workspaces.activeId, + catalog: mocks.catalog, + loading: false, + error: null, + refresh: vi.fn(), + }), +})); + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: (...args: unknown[]) => mocks.updateUserSettings(...args), + fetchUserSettings: (...args: unknown[]) => mocks.fetchUserSettings(...args), +})); + +type MockSwitchProps = ComponentProps<"button"> & { + checked?: boolean; + onCheckedChange?: (checked: boolean) => void; +}; + +vi.mock("@kandev/ui/switch", () => ({ + Switch: ({ checked = false, onCheckedChange, ...props }: MockSwitchProps) => ( +