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 (
+
+ );
+}
diff --git a/apps/web/components/app-sidebar/shortcut-section-actions.tsx b/apps/web/components/app-sidebar/shortcut-section-actions.tsx
new file mode 100644
index 0000000000..de66ba6bd6
--- /dev/null
+++ b/apps/web/components/app-sidebar/shortcut-section-actions.tsx
@@ -0,0 +1,356 @@
+"use client";
+
+import { useId, type ReactNode } from "react";
+import { useTranslation } from "react-i18next";
+import { IconDots } from "@tabler/icons-react";
+import Link from "@/components/routing/app-link";
+import { Button } from "@kandev/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@kandev/ui/dropdown-menu";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
+import type { ProjectedShortcut } from "@/lib/sidebar/layout-projection";
+import type { ShortcutActivity } from "@/hooks/domains/sidebar/use-shortcut-activity";
+import { cn } from "@/lib/utils";
+import { ShortcutActivityIndicator, shortcutActivityLabel } from "./shortcut-activity-indicator";
+
+export type ShortcutActivation = (shortcut: ProjectedShortcut) => void;
+export type ShortcutActivityReader = (automationId: string) => ShortcutActivity | undefined;
+
+type ShortcutActionProps = {
+ shortcut: ProjectedShortcut;
+ activity?: ShortcutActivity;
+ mobile?: boolean;
+ className?: string;
+ onActivate?: ShortcutActivation;
+ onNavigate?: () => void;
+ showLabel?: boolean;
+ descriptionSuffix?: string;
+ children?: ReactNode;
+};
+
+function activityDescriptionId(shortcut: ProjectedShortcut, suffix: string): string {
+ return `sidebar-shortcut-activity-${encodeURIComponent(shortcut.id)}-${suffix}`;
+}
+
+function ShortcutIcon({
+ shortcut,
+ activity,
+}: {
+ shortcut: ProjectedShortcut;
+ activity?: ShortcutActivity;
+}) {
+ const Icon = shortcut.icon;
+ return (
+
+
+ {activity && }
+
+ );
+}
+
+function activityDescription(
+ shortcut: ProjectedShortcut,
+ activity: ShortcutActivity | undefined,
+ t: (key: string) => string,
+ descriptionSuffix: string,
+) {
+ if (!activity || shortcut.target.kind !== "automation") return null;
+ return (
+
+ {shortcut.label}: {shortcutActivityLabel(activity, t)}
+
+ );
+}
+
+export function ShortcutAction({
+ shortcut,
+ activity,
+ mobile = false,
+ className,
+ onActivate,
+ onNavigate,
+ showLabel = false,
+ descriptionSuffix = "action",
+ children,
+}: ShortcutActionProps) {
+ const { t } = useTranslation();
+ const description = activityDescription(shortcut, activity, t, descriptionSuffix);
+ const describedBy = description ? activityDescriptionId(shortcut, descriptionSuffix) : undefined;
+ const baseClass = cn(
+ "relative flex items-center gap-2 rounded-md text-left transition-colors",
+ mobile ? "min-h-11 min-w-11 px-3 text-sm" : "h-7 min-w-7 px-1.5 text-[13px]",
+ shortcut.available
+ ? "cursor-pointer text-muted-foreground hover:bg-muted/60 hover:text-foreground"
+ : "cursor-not-allowed text-muted-foreground/50",
+ className,
+ );
+ const content = children ?? ;
+
+ if (!shortcut.available) {
+ return (
+ <>
+
+ {content}
+ {!mobile && {t("common:unavailable")} }
+
+ {description}
+ >
+ );
+ }
+
+ const trigger = shortcut.href ? (
+
+ {content}
+ {showLabel && {shortcut.label} }
+
+ ) : (
+ onActivate?.(shortcut)}
+ aria-label={shortcut.label}
+ aria-describedby={describedBy}
+ data-testid={`sidebar-shortcut-${shortcut.id}`}
+ >
+ {content}
+ {showLabel && {shortcut.label} }
+
+ );
+
+ return (
+ <>
+ {mobile ? (
+ trigger
+ ) : (
+
+ {trigger}
+ {shortcut.label}
+
+ )}
+ {description}
+ >
+ );
+}
+
+type ShortcutIconStripProps = {
+ shortcuts: ProjectedShortcut[];
+ activity: ShortcutActivityReader;
+ mobile?: boolean;
+ onActivate?: ShortcutActivation;
+ onNavigate?: () => void;
+};
+
+export function ShortcutIconStrip({
+ shortcuts,
+ activity,
+ mobile = false,
+ onActivate,
+ onNavigate,
+}: ShortcutIconStripProps) {
+ const { t } = useTranslation();
+ const inline = shortcuts.slice(0, 4);
+ const overflow = shortcuts.slice(4);
+ const overflowRunning = overflow.some(
+ (shortcut) =>
+ shortcut.target.kind === "automation" && activity(shortcut.target.id)?.state === "running",
+ );
+ return (
+
+ {inline.map((shortcut) => (
+
+ ))}
+ {overflow.length > 0 && (
+
+ )}
+ {shortcuts.length === 0 && {t("common:noResults")} }
+
+ );
+}
+
+type ShortcutOverflowMenuProps = ShortcutIconStripProps & {
+ shortcuts: ProjectedShortcut[];
+ triggerLabel?: string;
+ menuLabel?: string;
+ triggerContent?: ReactNode;
+ aggregateActivity?: ShortcutActivity;
+ triggerClassName?: string;
+ triggerTestId?: string;
+};
+
+export function ShortcutOverflowMenu({
+ shortcuts,
+ activity,
+ mobile = false,
+ onActivate,
+ onNavigate,
+ triggerLabel,
+ menuLabel,
+ triggerContent,
+ aggregateActivity,
+ triggerClassName,
+ triggerTestId,
+}: ShortcutOverflowMenuProps) {
+ const { t } = useTranslation();
+ const label = triggerLabel ?? t("common:showMoreActions");
+ const activityDescriptionId = `sidebar-shortcut-aggregate-activity-${encodeURIComponent(useId())}`;
+ return (
+
+
+
+ {triggerContent ?? (
+
+
+ {aggregateActivity && }
+
+ )}
+
+
+ {aggregateActivity && (
+
+ {shortcutActivityLabel(aggregateActivity, t)}
+
+ )}
+
+ {menuLabel && {menuLabel} }
+ {shortcuts.map((shortcut) => {
+ const shortcutActivity =
+ shortcut.target.kind === "automation" ? activity(shortcut.target.id) : undefined;
+ const Icon = shortcut.icon;
+ if (shortcut.href && shortcut.available) {
+ return (
+
+
+
+
+ {shortcutActivity && }
+
+ {shortcut.label}
+ {shortcutActivity && (
+ {shortcutActivityLabel(shortcutActivity, t)}
+ )}
+
+
+ );
+ }
+ return (
+ onActivate?.(shortcut)}
+ >
+
+
+ {shortcutActivity && }
+
+ {shortcut.label}
+ {shortcutActivity && (
+ {shortcutActivityLabel(shortcutActivity, t)}
+ )}
+
+ );
+ })}
+
+
+ );
+}
+
+type ShortcutRowsProps = {
+ shortcuts: ProjectedShortcut[];
+ activity: ShortcutActivityReader;
+ mobile?: boolean;
+ onActivate?: ShortcutActivation;
+ onNavigate?: () => void;
+};
+
+export function ShortcutRows({
+ shortcuts,
+ activity,
+ mobile = false,
+ onActivate,
+ onNavigate,
+}: ShortcutRowsProps) {
+ const { t } = useTranslation();
+ return (
+
+ {shortcuts.map((shortcut) => (
+
+
+
+ {shortcut.label}
+ {shortcut.target.kind === "automation" && activity(shortcut.target.id) && (
+
+ {shortcutActivityLabel(activity(shortcut.target.id)!, t)}
+
+ )}
+
+
+ ))}
+
+ );
+}
diff --git a/apps/web/components/app-sidebar/shortcut-section.test.tsx b/apps/web/components/app-sidebar/shortcut-section.test.tsx
new file mode 100644
index 0000000000..463d1663db
--- /dev/null
+++ b/apps/web/components/app-sidebar/shortcut-section.test.tsx
@@ -0,0 +1,141 @@
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { IconBrandGithub, IconList } from "@tabler/icons-react";
+import { TooltipProvider } from "@kandev/ui/tooltip";
+import type { ProjectedSidebarNode } from "@/lib/sidebar/layout-projection";
+import type { ShortcutActivity } from "@/hooks/domains/sidebar/use-shortcut-activity";
+import { ShortcutSection } from "./shortcut-section";
+import { ShortcutAction } from "./shortcut-section-actions";
+
+afterEach(() => cleanup());
+
+const node: ProjectedSidebarNode = {
+ id: "group-1",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ label: "Pinned",
+ icon: IconList,
+ shortcuts: [
+ {
+ id: "github",
+ target: { kind: "destination", id: "github" },
+ label: "GitHub",
+ icon: IconBrandGithub,
+ href: "/github",
+ source: "builtin",
+ available: true,
+ },
+ {
+ id: "canvas",
+ target: { kind: "canvas", id: "canvas-1" },
+ label: "Canvas",
+ icon: IconList,
+ href: "/canvases/canvas-1",
+ source: "canvas",
+ available: true,
+ },
+ {
+ id: "automation",
+ target: { kind: "automation", id: "automation-1" },
+ label: "Nightly",
+ icon: IconList,
+ href: "/automations/automation-1",
+ source: "automation",
+ available: true,
+ },
+ {
+ id: "plugin",
+ target: { kind: "destination", id: "plugin:slack:home" },
+ label: "Slack",
+ icon: IconList,
+ href: "/plugins/slack",
+ source: "plugin",
+ available: true,
+ },
+ {
+ id: "terminal",
+ target: { kind: "host_action", id: "quick_terminal" },
+ label: "Quick terminal",
+ icon: IconList,
+ source: "host_action",
+ available: true,
+ },
+ ],
+};
+
+const activity: ShortcutActivity = { state: "running", loading: false, error: false };
+
+describe("ShortcutSection", () => {
+ beforeEach(() => onActivateShortcutMock.mockReset());
+
+ it("uses the compact text size shared by expanded sidebar sections", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("sidebar-shortcut-github").className).toContain("text-[13px]");
+ });
+
+ it("uses one ordered shortcut collection for header actions and expanded rows", () => {
+ render(
+ (id === "automation-1" ? activity : undefined)}
+ onActivateShortcut={() => undefined}
+ />,
+ );
+
+ expect(screen.getByRole("link", { name: "GitHub" }).getAttribute("href")).toBe("/github");
+ expect(screen.getByRole("link", { name: "Canvas" }).getAttribute("href")).toBe(
+ "/canvases/canvas-1",
+ );
+ expect(screen.getByRole("link", { name: "Nightly" }).getAttribute("href")).toBe(
+ "/automations/automation-1",
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Pinned" }));
+ expect(screen.getByTestId("shortcut-section-rows").getAttribute("hidden")).toBeNull();
+ expect(screen.getAllByText("Nightly").length).toBeGreaterThan(0);
+ });
+
+ it("keeps overflow and host actions reachable without turning the section into a link", () => {
+ const onActivateShortcut = (shortcut: { id: string }) => {
+ if (shortcut.id === "terminal") onActivateShortcutMock();
+ };
+ render(
+ undefined}
+ onActivateShortcut={onActivateShortcut}
+ />,
+ );
+
+ const more = screen.getByRole("button", { name: "Show more actions" });
+ fireEvent.pointerDown(more);
+ expect(screen.getByRole("menuitem", { name: "Quick terminal" })).toBeTruthy();
+ fireEvent.click(screen.getByRole("menuitem", { name: "Quick terminal" }));
+ expect(onActivateShortcutMock).toHaveBeenCalledOnce();
+ });
+
+ it("describes running activity on the mobile section trigger", () => {
+ render(
+ (id === "automation-1" ? activity : undefined)}
+ />,
+ );
+
+ const trigger = screen.getByRole("button", { name: "Pinned" });
+ const descriptionId = trigger.getAttribute("aria-describedby");
+ expect(descriptionId).toBeTruthy();
+ expect(document.getElementById(descriptionId ?? "")?.textContent).toContain("Running");
+ });
+});
+
+const onActivateShortcutMock = vi.fn();
diff --git a/apps/web/components/app-sidebar/shortcut-section.tsx b/apps/web/components/app-sidebar/shortcut-section.tsx
new file mode 100644
index 0000000000..f057ec8981
--- /dev/null
+++ b/apps/web/components/app-sidebar/shortcut-section.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import { useId, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@kandev/ui/collapsible";
+import type { ProjectedSidebarNode, ProjectedShortcut } from "@/lib/sidebar/layout-projection";
+import type {
+ ShortcutActivity,
+ ShortcutActivityReader,
+} from "@/hooks/domains/sidebar/use-shortcut-activity";
+import { AppSidebarSection } from "./app-sidebar-section";
+import {
+ ShortcutIconStrip,
+ ShortcutOverflowMenu,
+ ShortcutRows,
+ type ShortcutActivation,
+} from "./shortcut-section-actions";
+import { ShortcutActivityIndicator, shortcutActivityLabel } from "./shortcut-activity-indicator";
+import { cn } from "@/lib/utils";
+
+export type ShortcutSectionProps = {
+ node: ProjectedSidebarNode;
+ collapsed?: boolean;
+ mobile?: boolean;
+ getActivity: ShortcutActivityReader;
+ onActivateShortcut?: ShortcutActivation;
+ onNavigate?: () => void;
+};
+
+function shortcutAutomationIds(shortcuts: ProjectedShortcut[]): string[] {
+ return shortcuts
+ .filter((shortcut) => shortcut.target.kind === "automation")
+ .map((shortcut) => shortcut.target.id);
+}
+
+function isRunning(activity: ShortcutActivity | undefined): boolean {
+ return activity?.state === "running";
+}
+
+function MobileShortcutSection({
+ node,
+ getActivity,
+ onActivateShortcut,
+ onNavigate,
+}: ShortcutSectionProps) {
+ const { t } = useTranslation();
+ const [open, setOpen] = useState(false);
+ const activityDescriptionId = `mobile-shortcut-section-activity-${encodeURIComponent(useId())}`;
+ const automationIds = shortcutAutomationIds(node.shortcuts);
+ const running = automationIds.some((id) => isRunning(getActivity(id)));
+
+ return (
+
+
+
+
+
+
+
+ {node.label}
+ {running && (
+
+ )}
+
+
+
+ {running && (
+
+ {shortcutActivityLabel({ state: "running", loading: false, error: false }, t)}
+
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function DesktopRailShortcutSection({
+ node,
+ getActivity,
+ onActivateShortcut,
+ onNavigate,
+}: ShortcutSectionProps) {
+ const Icon = node.icon;
+ const running = shortcutAutomationIds(node.shortcuts).some((id) => isRunning(getActivity(id)));
+ return (
+
+
+ {running && (
+
+ )}
+
+ }
+ 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 && (
)}
- {!omit.has("plugins") && }
- {!omit.has("integrations") && }
+ {!hasSavedSidebarLayout && !omit.has("plugins") && (
+
+ )}
+ {!hasSavedSidebarLayout && !omit.has("integrations") && (
+
+ )}
>
);
@@ -199,6 +216,18 @@ function UtilityNavSection({
onNavigate={onNavigate}
className="gap-3 px-3 text-sm"
/>
+
+
+
+ {t("settings:sidebar")}
+
+
{/* #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 (
+
+ {content}
+
+ );
+ }
+ return (
+
+
+ {content}
+
+
+ );
+}
+
+function MobileNewTaskRow({ onNavigate }: { onNavigate: () => void }) {
+ const { t } = useTranslation();
+ return (
+ {
+ onNavigate();
+ requestNewTaskCreation();
+ }}
+ >
+
+ {t("sidebar:newTask")}
+
+ );
+}
+
+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" && (
+
+
+
+ {t("sidebar:inbox")}
+ {officeInboxCount > 0 && {officeInboxCount} }
+
+
+ )}
+ {needsYouEnabled && workspaceId && (
+
+
+
+
+ {mode === "office" ? t("sidebar:needsYouInbox") : t("sidebar:inbox")}
+
+ {needsYouCount > 0 && {`${needsYouCount}${needsYouHasMore ? "+" : ""}`} }
+
+
+ )}
+
+ );
+}
+
+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}
+ />
+ )}
+
+ onApply((current) =>
+ moveShortcut(current, shortcut.id, node.id, node.id, index - 1),
+ )
+ }
+ disabled={readOnly || index === 0}
+ aria-label={t("settings:moveUp")}
+ >
+
+
+
+ onApply((current) =>
+ moveShortcut(current, shortcut.id, node.id, node.id, index + 1),
+ )
+ }
+ disabled={readOnly || index === node.shortcuts.length - 1}
+ aria-label={t("settings:moveDown")}
+ >
+
+
+ onApply((current) => removeShortcut(current, node.id, shortcut.id))}
+ disabled={readOnly}
+ aria-label={t("settings:remove")}
+ >
+
+
+
+ );
+ })}
+
+ );
+}
+
+function UnsupportedLayoutNotice({
+ onLoadLatest,
+ latestLoading,
+ t,
+}: {
+ onLoadLatest: () => void;
+ latestLoading: boolean;
+ t: (key: string) => string;
+}) {
+ return (
+
+
{t("settings:sidebarUnsupportedVersion")}
+
+ {latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
+
+
+ );
+}
+
+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 && (
+
+ )}
+
+
+ {t("common:back")}
+
+
+ onSetNameDraft(node.id, event.target.value)}
+ onBlur={() => onCommitName(node.id, node.name ?? "")}
+ disabled={readOnly}
+ className="min-h-11"
+ />
+
+
+
+ {t("settings:addShortcut")}
+
+ {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)}
+
+ )}
+
+ onMove(Math.max(0, index - 1))}
+ disabled={readOnly || index === 0}
+ aria-label={t("settings:moveUp")}
+ >
+
+
+ onMove(Math.min(total - 1, index + 1))}
+ disabled={readOnly || index === total - 1}
+ aria-label={t("settings:moveDown")}
+ >
+
+
+ {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) => (
+
+ ))}
+
+
+
+
+ {t("settings:addShortcut")}
+
+
+ {t("settings:editSection")}
+
+
+ {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 (
+
+
+
+
+
+ {shortcut.label}
+ {!shortcut.available && (
+ {t("common:unavailable")}
+ )}
+ {!readOnly && (
+ onMoveShortcutToSection(shortcut.id, destinationNodeId)}
+ t={t}
+ />
+ )}
+ onMoveShortcut(shortcut.id, Math.max(0, shortcutIndex - 1))}
+ disabled={readOnly || shortcutIndex === 0}
+ aria-label={t("settings:moveUp")}
+ >
+
+
+ onMoveShortcut(shortcut.id, Math.min(shortcutCount - 1, shortcutIndex + 1))}
+ disabled={readOnly || shortcutIndex === shortcutCount - 1}
+ aria-label={t("settings:moveDown")}
+ >
+
+
+ onRemoveShortcut(shortcut.id)}
+ disabled={readOnly}
+ aria-label={t("settings:remove")}
+ >
+
+
+
+ );
+}
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")}
+
+ {latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
+
+
+ )}
+ {!validation.valid && (
+
+ {validationMessage}
+
+ )}
+ {operationError && (
+
+ {operationError}
+
+ )}
+ {saveError && (
+
+
+ {t(saveError === "conflict" ? "settings:sidebarConflict" : "settings:sidebarSaveError")}
+
+ {saveError === "conflict" && (
+
+ {latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
+
+ )}
+
+ )}
+ >
+ );
+}
+
+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"
+ />
+ {
+ if (onApply((current) => createShortcutSection(current, name))) setName("");
+ }}
+ disabled={readOnly || !name.trim()}
+ >
+
+ {t("settings:addShortcutSection")}
+
+
+ );
+}
+
+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 (
+ onAdd(entry)}
+ >
+
+ {entry.label}
+
+ );
+ })}
+
+ )}
+
+ );
+}
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 && (
+ <>
+
+
+ >
+ )}
+
+
+
+
+
+
+ {t("settings:sidebarRestoreDefaults")}
+
+
+ );
+}
+
+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) => (
+ onCheckedChange?.(!checked)}
+ />
+ ),
+}));
+
+// eslint-disable-next-line max-lines-per-function -- related editor regression cases share one mocked editor harness.
+describe("SidebarLayoutEditor", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ beforeEach(() => {
+ mocks.updateUserSettings.mockReset();
+ mocks.fetchUserSettings.mockReset();
+ mocks.setUserSettings.mockReset();
+ mocks.catalog = [];
+ mocks.isMobile = false;
+ mocks.state.workspaces.activeId = "workspace-1";
+ mocks.state.userSettings = { sidebarLayoutsByWorkspace: {} };
+ });
+
+ it("keeps an empty shortcut section editable and exposes explicit move controls", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole("heading", { name: SIDEBAR_LABEL })).toBeTruthy();
+ fireEvent.change(screen.getByLabelText(SECTION_NAME_LABEL), { target: { value: "Pinned" } });
+ fireEvent.click(screen.getAllByRole("button", { name: ADD_SECTION_LABEL }).at(-1)!);
+
+ expect(screen.getByDisplayValue("Pinned")).toBeTruthy();
+ expect(screen.getAllByRole("button", { name: MOVE_UP_LABEL }).length).toBeGreaterThan(0);
+ expect(screen.getAllByRole("button", { name: MOVE_DOWN_LABEL }).length).toBeGreaterThan(0);
+ });
+
+ it("keeps edits made while a save is pending", async () => {
+ let resolveSave: ((value: unknown) => void) | undefined;
+ mocks.updateUserSettings.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveSave = resolve;
+ }),
+ );
+ render(
+
+
+ ,
+ );
+
+ const newSectionInputs = () => screen.getAllByLabelText(SECTION_NAME_LABEL);
+ fireEvent.change(newSectionInputs().at(-1)!, { target: { value: "First" } });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+ fireEvent.click(screen.getByRole("button", { name: "settings:saveChanges" }));
+ await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(1));
+
+ fireEvent.change(newSectionInputs().at(-1)!, { target: { value: "Second" } });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+ await act(async () => {
+ resolveSave?.({
+ settings: {
+ sidebar_layouts_by_workspace: {
+ "workspace-1": {
+ version: 1,
+ revision: 1,
+ nodes: [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ {
+ id: "automations",
+ kind: "builtin",
+ visible: true,
+ destination_id: "automations",
+ },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ {
+ id: "integrations",
+ kind: "builtin",
+ visible: true,
+ destination_id: "integrations",
+ },
+ { id: "first", kind: "shortcuts", visible: true, name: "First", shortcuts: [] },
+ ],
+ },
+ },
+ },
+ });
+ });
+
+ expect(screen.getByDisplayValue("Second")).toBeTruthy();
+ });
+
+ it("retains a dirty draft when the active workspace changes", () => {
+ const view = render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getAllByLabelText(SECTION_NAME_LABEL).at(-1)!, {
+ target: { value: "Workspace one draft" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+
+ mocks.state.workspaces.activeId = "workspace-2";
+ view.rerender(
+
+
+ ,
+ );
+ mocks.state.workspaces.activeId = "workspace-1";
+ view.rerender(
+
+
+ ,
+ );
+
+ expect(screen.getByDisplayValue("Workspace one draft")).toBeTruthy();
+ });
+
+ it("offers same-group move controls in the focused phone editor", () => {
+ mocks.isMobile = true;
+ mocks.catalog = [
+ { target: { kind: "destination", id: "github" }, label: "GitHub", available: true },
+ { target: { kind: "destination", id: "linear" }, label: "Linear", available: true },
+ ];
+ mocks.state.userSettings = {
+ sidebarLayoutsByWorkspace: {
+ "workspace-1": {
+ version: 1,
+ revision: 1,
+ nodes: [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ { id: "automations", kind: "builtin", visible: true, destination_id: "automations" },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ { id: "integrations", kind: "builtin", visible: true, destination_id: "integrations" },
+ {
+ id: "group-1",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ shortcuts: [
+ { id: "github", target: { kind: "destination", id: "github" } },
+ { id: "linear", target: { kind: "destination", id: "linear" } },
+ ],
+ },
+ ],
+ },
+ },
+ };
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(
+ within(screen.getByTestId("sidebar-layout-node-group-1")).getByRole("button", {
+ name: "settings:editSection",
+ }),
+ );
+ expect(screen.getAllByRole("button", { name: MOVE_UP_LABEL }).length).toBeGreaterThan(0);
+ expect(screen.getAllByRole("button", { name: MOVE_DOWN_LABEL }).length).toBeGreaterThan(0);
+ });
+
+ it("blocks editing when the server returns a newer layout version", () => {
+ mocks.state.userSettings = {
+ sidebarLayoutsByWorkspace: {
+ "workspace-1": {
+ version: 99,
+ revision: 4,
+ nodes: [],
+ },
+ },
+ };
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText("settings:sidebarUnsupportedVersion")).toBeTruthy();
+ expect(screen.getByRole("button", { name: ADD_SECTION_LABEL })).toHaveProperty(
+ "disabled",
+ true,
+ );
+ });
+
+ it("loads the latest revision before retrying a same-workspace conflict", async () => {
+ mocks.updateUserSettings.mockRejectedValueOnce(new ApiError("conflict", 409, {}));
+ mocks.updateUserSettings.mockResolvedValueOnce({
+ settings: {
+ sidebar_layouts_by_workspace: {
+ "workspace-1": {
+ version: 1,
+ revision: 8,
+ nodes: [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ { id: "automations", kind: "builtin", visible: true, destination_id: "automations" },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ {
+ id: "integrations",
+ kind: "builtin",
+ visible: true,
+ destination_id: "integrations",
+ },
+ ],
+ },
+ },
+ },
+ });
+ mocks.fetchUserSettings.mockResolvedValue({
+ settings: {
+ sidebar_layouts_by_workspace: {
+ "workspace-1": {
+ version: 1,
+ revision: 7,
+ nodes: [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ { id: "automations", kind: "builtin", visible: true, destination_id: "automations" },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ {
+ id: "integrations",
+ kind: "builtin",
+ visible: true,
+ destination_id: "integrations",
+ },
+ ],
+ },
+ },
+ },
+ });
+
+ render(
+
+
+ ,
+ );
+ fireEvent.change(screen.getAllByLabelText(SECTION_NAME_LABEL).at(-1)!, {
+ target: { value: "Pinned" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+ fireEvent.click(screen.getByRole("button", { name: "settings:saveChanges" }));
+
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: LOAD_LATEST_LABEL })).toBeTruthy(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: LOAD_LATEST_LABEL }));
+ await waitFor(() => expect(mocks.fetchUserSettings).toHaveBeenCalledTimes(1));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: RETRY_SAVE_LABEL })).toBeTruthy(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: RETRY_SAVE_LABEL }));
+ await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(2));
+ expect(mocks.updateUserSettings.mock.calls[1]?.[0]).toMatchObject({
+ sidebar_layout_state: { expected_revision: 7 },
+ });
+ });
+
+ it("scopes save recovery status to the active workspace", async () => {
+ let resolveLatest: ((value: unknown) => void) | undefined;
+ const conflict = new ApiError("conflict", 409, {});
+ mocks.updateUserSettings.mockRejectedValueOnce(conflict).mockRejectedValueOnce(conflict);
+ mocks.fetchUserSettings.mockReturnValue(
+ new Promise((resolve) => {
+ resolveLatest = resolve;
+ }),
+ );
+ const view = render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getAllByLabelText(SECTION_NAME_LABEL).at(-1)!, {
+ target: { value: "Workspace one" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+ fireEvent.click(screen.getByRole("button", { name: "settings:saveChanges" }));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: LOAD_LATEST_LABEL })).toBeTruthy(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: LOAD_LATEST_LABEL }));
+
+ await waitFor(() => expect(mocks.fetchUserSettings).toHaveBeenCalledTimes(1));
+ mocks.state.workspaces.activeId = "workspace-2";
+ view.rerender(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getAllByLabelText(SECTION_NAME_LABEL).at(-1)!, {
+ target: { value: "Workspace two" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: ADD_SECTION_LABEL }));
+ fireEvent.click(screen.getByRole("button", { name: RETRY_SAVE_LABEL }));
+ await waitFor(() => expect(mocks.updateUserSettings).toHaveBeenCalledTimes(2));
+ await waitFor(() =>
+ expect(screen.getByRole("button", { name: LOAD_LATEST_LABEL })).toBeTruthy(),
+ );
+
+ resolveLatest?.({ settings: { sidebar_layouts_by_workspace: {} } });
+ });
+});
diff --git a/apps/web/components/settings/sidebar-layout-editor.tsx b/apps/web/components/settings/sidebar-layout-editor.tsx
new file mode 100644
index 0000000000..d3c243b0bc
--- /dev/null
+++ b/apps/web/components/settings/sidebar-layout-editor.tsx
@@ -0,0 +1,273 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
+import { useSidebarShortcutCatalog } from "@/hooks/domains/sidebar/use-sidebar-shortcut-catalog";
+import {
+ addShortcut,
+ renameShortcutSection,
+ validateSidebarLayout,
+} from "@/lib/sidebar/layout-operations";
+import { defaultSidebarLayout, type SidebarLayout } from "@/lib/sidebar/layout-types";
+import {
+ projectSidebarLayout,
+ type SidebarLayoutProjection,
+} from "@/lib/sidebar/layout-projection";
+import type { ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
+import { generateUUID } from "@/lib/utils";
+import { FocusedSidebarGroup } from "./sidebar-layout-editor-focused-group";
+import { useSidebarLayoutSave } from "./sidebar-layout-editor-save";
+import {
+ useSidebarDraft,
+ useSidebarLayoutActions,
+ validationCopy,
+} from "./sidebar-layout-editor-state";
+import { SidebarLayoutContent } from "./sidebar-layout-editor-view";
+import type { DraftOperation } from "./sidebar-layout-editor-types";
+
+const BUILTIN_LABEL_KEYS: Record = {
+ home: "sidebar:home",
+ new_task: "sidebar:newTask",
+ automations: "common:automations",
+ canvases: "canvases:canvases",
+ integrations: "common:integrations",
+};
+
+type EditorSurfaceProps = {
+ draft: SidebarLayout;
+ unsupportedVersion: boolean;
+ projected: SidebarLayoutProjection;
+ catalog: ShortcutCatalogEntry[];
+ catalogLoading: boolean;
+ catalogError: string | null;
+ canvasError?: string | null;
+ validation: ReturnType;
+ saveError: "conflict" | "error" | null;
+ latestLoading: boolean;
+ operationError: string | null;
+ isMobile: boolean;
+ newSectionName: string;
+ setNewSectionName: (value: string) => void;
+ pickerNodeId: string | null;
+ focusedNodeId: string | null;
+ setPickerNodeId: (value: string | null) => void;
+ pickerQuery: string;
+ setPickerQuery: (value: string) => void;
+ onApply: (operation: DraftOperation) => boolean;
+ onSetNameDraft: (nodeId: string, name: string) => void;
+ onCommitName: (nodeId: string, name: string) => void;
+ onLoadLatest: () => void;
+ onReset: () => void;
+ onSetFocusedNodeId: (value: string | null) => void;
+ t: (key: string, options?: Record) => string;
+ showHeader: boolean;
+};
+
+function SidebarLayoutEditorSurface({
+ draft,
+ unsupportedVersion,
+ projected,
+ catalog,
+ catalogLoading,
+ catalogError,
+ canvasError,
+ validation,
+ saveError,
+ latestLoading,
+ operationError,
+ isMobile,
+ newSectionName,
+ setNewSectionName,
+ pickerNodeId,
+ focusedNodeId,
+ setPickerNodeId,
+ pickerQuery,
+ setPickerQuery,
+ onApply,
+ onSetNameDraft,
+ onCommitName,
+ onLoadLatest,
+ onReset,
+ onSetFocusedNodeId,
+ t,
+ showHeader,
+}: EditorSurfaceProps) {
+ const visibleNodes = projected.nodes.filter((node) => node.visible);
+ const activeFocusedNode = projected.nodes.find((node) => node.id === focusedNodeId);
+ if (isMobile && activeFocusedNode?.kind === "shortcuts") {
+ return (
+ onSetFocusedNodeId(null)}
+ onLoadLatest={onLoadLatest}
+ latestLoading={latestLoading}
+ onOpenPicker={() => setPickerNodeId(activeFocusedNode.id)}
+ pickerOpen={pickerNodeId === activeFocusedNode.id}
+ onClosePicker={() => setPickerNodeId(null)}
+ onAdd={(entry) => {
+ if (
+ onApply((current) =>
+ addShortcut(current, activeFocusedNode.id, {
+ id: generateUUID(),
+ target: entry.target,
+ }),
+ )
+ ) {
+ setPickerNodeId(null);
+ }
+ }}
+ onApply={onApply}
+ onSetNameDraft={onSetNameDraft}
+ onCommitName={onCommitName}
+ />
+ );
+ }
+ return (
+
+ );
+}
+
+export function SidebarLayoutEditor({ embedded = false }: { embedded?: boolean } = {}) {
+ const { t } = useTranslation();
+ const { isMobile } = useResponsiveBreakpoint();
+ const catalogState = useSidebarShortcutCatalog();
+ const draftState = useSidebarDraft(catalogState.catalog);
+ const actions = useSidebarLayoutActions(draftState);
+ const validation = validateSidebarLayout(draftState.draft);
+ const save = useSidebarLayoutSave({
+ workspaceId: draftState.workspaceId,
+ draft: draftState.draft,
+ dirty: draftState.dirty,
+ validationValid: validation.valid,
+ invalidReason: validation.valid ? undefined : validationCopy(validation.errors[0] ?? "", t),
+ catalog: catalogState.catalog,
+ setDraft: draftState.setDraft,
+ acknowledge: draftState.acknowledge,
+ setUserSettings: draftState.setUserSettings,
+ store: draftState.store,
+ draftRef: actions.draftRef,
+ savedRef: useRefValue(draftState.saved),
+ workspaceRef: actions.workspaceRef,
+ generationsRef: actions.generationsRef,
+ requestGenerationsRef: actions.requestGenerationsRef,
+ onOperationError: actions.setOperationError,
+ });
+ const [newSectionName, setNewSectionName] = useState("");
+ const [pickerNodeId, setPickerNodeId] = useState(null);
+ const [focusedNodeId, setFocusedNodeId] = useState(null);
+ const [pickerQuery, setPickerQuery] = useState("");
+ useEffect(() => {
+ setNewSectionName("");
+ setPickerNodeId(null);
+ setFocusedNodeId(null);
+ setPickerQuery("");
+ }, [draftState.workspaceId]);
+ const applyDraftOperation = useCallback(
+ (operation: DraftOperation) => {
+ const applied = actions.applyDraftOperation(operation);
+ if (applied) save.setSaveError(null);
+ return applied;
+ },
+ [actions.applyDraftOperation, save.setSaveError],
+ );
+ const onCommitName = useCallback(
+ (nodeId: string, name: string) => {
+ applyDraftOperation((current) => renameShortcutSection(current, nodeId, name));
+ },
+ [applyDraftOperation],
+ );
+ const onReset = useCallback(
+ () =>
+ applyDraftOperation((current) => ({ ...defaultSidebarLayout(), revision: current.revision })),
+ [applyDraftOperation],
+ );
+ const projected = useMemo(
+ () =>
+ projectSidebarLayout(draftState.draft, catalogState.catalog, {
+ unavailableLabel: t("common:unavailable"),
+ builtinLabels: Object.fromEntries(
+ Object.entries(BUILTIN_LABEL_KEYS).map(([id, key]) => [id, t(key)]),
+ ),
+ }),
+ [catalogState.catalog, draftState.draft, t],
+ );
+
+ if (!draftState.workspaceId) {
+ return {t("settings:sidebarNoWorkspace")}
;
+ }
+ return (
+
+ );
+}
+
+function useRefValue(value: SidebarLayout) {
+ const ref = useMemo(() => ({ current: value }), []);
+ ref.current = value;
+ return ref;
+}
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts
index d2689a28c8..f270160921 100644
--- a/apps/web/e2e/helpers/api-client.ts
+++ b/apps/web/e2e/helpers/api-client.ts
@@ -1233,6 +1233,7 @@ export class ApiClient {
terminal_font_family?: string;
terminal_font_size?: number;
startup_page?: "task_overview" | "last_task" | "threads";
+ sidebar_layouts_by_workspace?: Record;
mcp_task_agent_profile_default?: MCPTaskAgentProfileDefault;
tasks_list_show_details?: boolean;
show_transcript_auto_scroll_control?: boolean;
@@ -1274,6 +1275,11 @@ export class ApiClient {
active_view_id?: string;
draft?: unknown;
};
+ sidebar_layout_state?: {
+ workspace_id: string;
+ expected_revision: number;
+ layout?: unknown;
+ };
thread_views?: unknown[];
thread_active_view_id?: string;
thread_view_draft?: unknown;
diff --git a/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts b/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts
new file mode 100644
index 0000000000..57316fdc7e
--- /dev/null
+++ b/apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts
@@ -0,0 +1,106 @@
+import { expect, test } from "../../fixtures/test-base";
+import type { Page } from "@playwright/test";
+import type { ApiClient } from "../../helpers/api-client";
+
+type MobileSidebarLayoutNode = {
+ id: string;
+ kind: "builtin" | "shortcuts";
+ visible: boolean;
+ destination_id?: string;
+ name?: string;
+ shortcuts?: Array<{
+ id: string;
+ target: { kind: "host_action"; id: "quick_chat" | "quick_terminal" };
+ }>;
+};
+
+async function seedMobileLayout(apiClient: ApiClient, workspaceId: string): Promise {
+ const current = await apiClient.getUserSettings();
+ const expectedRevision =
+ current.settings.sidebar_layouts_by_workspace?.[workspaceId]?.revision ?? 0;
+ await apiClient.saveUserSettings({
+ sidebar_layout_state: {
+ workspace_id: workspaceId,
+ expected_revision: expectedRevision,
+ layout: {
+ version: 1,
+ revision: expectedRevision,
+ nodes: [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ { id: "automations", kind: "builtin", visible: true, destination_id: "automations" },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ { id: "integrations", kind: "builtin", visible: true, destination_id: "integrations" },
+ {
+ id: "group-1",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ shortcuts: [
+ { id: "chat", target: { kind: "host_action", id: "quick_chat" } },
+ { id: "terminal", target: { kind: "host_action", id: "quick_terminal" } },
+ ],
+ } satisfies MobileSidebarLayoutNode,
+ ],
+ },
+ },
+ });
+}
+
+async function addMobileShortcut(page: Page, label: string): Promise {
+ const picker = page.getByRole("dialog", { name: "Add shortcut" });
+ await expect(picker).toBeVisible();
+ await picker.getByRole("button", { name: label, exact: true }).click();
+}
+
+test.describe("Sidebar customization on phone", () => {
+ test("uses the focused editor flow and preserves touch-sized reorder controls", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await seedMobileLayout(apiClient, seedData.workspaceId);
+ await testPage.goto("/settings/preferences/layouts?tab=sidebar");
+ await expect(testPage.getByRole("tab", { name: "Sidebar", exact: true })).toBeVisible();
+
+ const group = testPage.getByTestId("sidebar-layout-node-group-1");
+ await group.getByRole("button", { name: "Edit section", exact: true }).tap();
+ const focused = testPage.getByTestId("sidebar-layout-focused-group");
+ await expect(focused).toBeVisible();
+
+ const moveUp = focused.getByRole("button", { name: "Move up" });
+ const moveDown = focused.getByRole("button", { name: "Move down" });
+ await expect(moveUp).toHaveCount(2);
+ await expect(moveDown).toHaveCount(2);
+ for (const button of [moveUp.nth(0), moveDown.nth(0), moveUp.nth(1), moveDown.nth(1)]) {
+ const box = await button.boundingBox();
+ expect(box?.height).toBeGreaterThanOrEqual(44);
+ }
+
+ await focused.getByRole("button", { name: "Add shortcut", exact: true }).tap();
+ await addMobileShortcut(testPage, "New Task");
+ await focused.getByRole("button", { name: "Add shortcut", exact: true }).tap();
+ await addMobileShortcut(testPage, "Stats");
+
+ const saveButton = testPage
+ .getByTestId("settings-floating-save")
+ .getByRole("button", { name: "Save changes" });
+ await saveButton.tap();
+ await expect
+ .poll(
+ async () =>
+ (await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
+ seedData.workspaceId
+ ]?.revision,
+ )
+ .toBeGreaterThan(0);
+
+ await testPage.reload();
+ await expect(testPage.getByTestId("sidebar-layout-editor")).toBeVisible();
+ expect(
+ await testPage.evaluate(
+ () => document.documentElement.scrollWidth <= document.documentElement.clientWidth,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/apps/web/e2e/tests/settings/sidebar-customization.spec.ts b/apps/web/e2e/tests/settings/sidebar-customization.spec.ts
new file mode 100644
index 0000000000..39dd14e7a1
--- /dev/null
+++ b/apps/web/e2e/tests/settings/sidebar-customization.spec.ts
@@ -0,0 +1,181 @@
+import { expect, test } from "../../fixtures/test-base";
+import type { Page } from "@playwright/test";
+import type { ApiClient } from "../../helpers/api-client";
+
+type SidebarLayoutNode = {
+ id: string;
+ kind: "builtin" | "shortcuts";
+ visible: boolean;
+ destination_id?: string;
+ name?: string;
+ shortcuts?: Array<{
+ id: string;
+ target: { kind: "host_action" | "destination" | "automation"; id: string };
+ }>;
+};
+
+function defaultNodes(groups: SidebarLayoutNode[]): SidebarLayoutNode[] {
+ return [
+ { id: "home", kind: "builtin", visible: true, destination_id: "home" },
+ { id: "new-task", kind: "builtin", visible: true, destination_id: "new_task" },
+ { id: "automations", kind: "builtin", visible: true, destination_id: "automations" },
+ { id: "canvases", kind: "builtin", visible: true, destination_id: "canvases" },
+ { id: "integrations", kind: "builtin", visible: true, destination_id: "integrations" },
+ ...groups,
+ ];
+}
+
+async function saveSidebarLayout(
+ apiClient: ApiClient,
+ workspaceId: string,
+ nodes: SidebarLayoutNode[],
+): Promise {
+ const current = await apiClient.getUserSettings();
+ const expectedRevision =
+ current.settings.sidebar_layouts_by_workspace?.[workspaceId]?.revision ?? 0;
+ await apiClient.saveUserSettings({
+ sidebar_layout_state: {
+ workspace_id: workspaceId,
+ expected_revision: expectedRevision,
+ layout: { version: 1, revision: expectedRevision, nodes },
+ },
+ });
+ return expectedRevision + 1;
+}
+
+async function addShortcut(page: Page, label: string) {
+ const picker = page.getByRole("dialog", { name: "Add shortcut" });
+ await expect(picker).toBeVisible();
+ await picker.getByRole("button", { name: label, exact: true }).click();
+}
+
+test.describe("Sidebar customization on desktop", () => {
+ test("opens from the Sidebar tab on the Layouts settings page", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ await saveSidebarLayout(apiClient, seedData.workspaceId, defaultNodes([]));
+
+ await testPage.goto("/settings/preferences/layouts");
+ await expect(testPage.getByRole("tab", { name: "Sidebar", exact: true })).toBeVisible();
+ await testPage.getByRole("tab", { name: "Sidebar", exact: true }).click();
+
+ await expect(testPage).toHaveURL(/\/settings\/preferences\/layouts\?tab=sidebar$/);
+ await expect(testPage.getByTestId("sidebar-layout-editor")).toBeVisible();
+ });
+
+ test("adds four shortcuts, hides a destination, reorders a group, and persists the result", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const initialRevision = await saveSidebarLayout(
+ apiClient,
+ seedData.workspaceId,
+ defaultNodes([
+ { id: "group-1", kind: "shortcuts", visible: true, name: "Pinned", shortcuts: [] },
+ { id: "group-2", kind: "shortcuts", visible: true, name: "More", shortcuts: [] },
+ ]),
+ );
+
+ await testPage.goto("/settings/sidebar");
+ const editor = testPage.getByTestId("sidebar-layout-editor");
+ const group = testPage.getByTestId("sidebar-layout-node-group-1");
+ const add = group.getByRole("button", { name: "Add shortcut", exact: true });
+
+ await add.click();
+ await addShortcut(testPage, "New Task");
+ await add.click();
+ await addShortcut(testPage, "Quick Chat");
+ await add.click();
+ await addShortcut(testPage, "Quick terminal");
+ await add.click();
+ await addShortcut(testPage, "Stats");
+
+ const homeNode = testPage.getByTestId("sidebar-layout-node-home");
+ await homeNode.getByRole("switch", { name: "Toggle visibility" }).click();
+ await expect(testPage.getByTestId("sidebar-layout-drag-handle-group-1")).toBeVisible();
+ await testPage
+ .getByTestId("sidebar-layout-node-group-1")
+ .getByRole("button", { name: "Move down", exact: true })
+ .first()
+ .click();
+
+ const saveButton = testPage
+ .getByTestId("settings-floating-save")
+ .getByRole("button", { name: "Save changes" });
+ await expect(saveButton).toBeEnabled();
+ await saveButton.click();
+ await expect
+ .poll(
+ async () =>
+ (await apiClient.getUserSettings()).settings.sidebar_layouts_by_workspace?.[
+ seedData.workspaceId
+ ]?.revision,
+ )
+ .toBe(initialRevision + 1);
+
+ await testPage.reload();
+ await expect(editor).toBeVisible();
+ for (const label of ["New Task", "Quick Chat", "Quick terminal", "Stats"]) {
+ await expect(testPage.getByTestId("sidebar-layout-node-group-1")).toContainText(label);
+ }
+ await expect(
+ testPage
+ .getByTestId("sidebar-layout-node-home")
+ .getByRole("switch", { name: "Toggle visibility" }),
+ ).not.toBeChecked();
+
+ const saved = await apiClient.getUserSettings();
+ const nodes = (saved.settings.sidebar_layouts_by_workspace?.[seedData.workspaceId]?.nodes ??
+ []) as Array<{
+ id: string;
+ }>;
+ expect(nodes.slice(-2).map((node) => node.id)).toEqual(["group-2", "group-1"]);
+
+ await testPage.goto("/");
+ const section = testPage.getByRole("button", { name: /Pinned/ });
+ await expect(section).toBeVisible();
+ await section.click();
+ await expect(
+ testPage.getByRole("button", { name: "Quick Chat", exact: true }).last(),
+ ).toBeVisible();
+ });
+
+ test("updates a pinned automation bubble when a run starts", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const automation = await apiClient.seedAutomation({
+ workspaceId: seedData.workspaceId,
+ name: "Pinned activity",
+ workflowId: seedData.workflowId,
+ workflowStepId: seedData.startStepId,
+ });
+ await saveSidebarLayout(
+ apiClient,
+ seedData.workspaceId,
+ defaultNodes([
+ {
+ id: "group-1",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ shortcuts: [
+ { id: "automation-shortcut", target: { kind: "automation", id: automation.id } },
+ ],
+ },
+ ]),
+ );
+
+ await testPage.goto("/");
+ const shortcut = testPage.getByTestId("sidebar-shortcut-automation-shortcut");
+ const indicator = shortcut.getByTestId("sidebar-shortcut-activity-indicator");
+ await expect(indicator).toHaveAttribute("data-state", "idle", { timeout: 15_000 });
+
+ await apiClient.seedAutomationRun(automation.id, "triggered");
+ await expect(indicator).toHaveAttribute("data-state", "running", { timeout: 15_000 });
+ });
+});
diff --git a/apps/web/hooks/domains/sidebar/use-shortcut-activity.test.ts b/apps/web/hooks/domains/sidebar/use-shortcut-activity.test.ts
new file mode 100644
index 0000000000..c9bd635a0d
--- /dev/null
+++ b/apps/web/hooks/domains/sidebar/use-shortcut-activity.test.ts
@@ -0,0 +1,129 @@
+import { renderHook } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Automation, AutomationSummary } from "@/lib/types/automation";
+
+const WORKSPACE_ID = "workspace-1";
+
+const summaryState = vi.hoisted(() => ({
+ value: {
+ summaries: [] as AutomationSummary[],
+ loading: true,
+ error: null as string | null,
+ refresh: vi.fn(),
+ },
+}));
+
+vi.mock("@/components/runs/use-automation-summaries", () => ({
+ useAutomationSummaries: () => summaryState.value,
+}));
+
+vi.mock("@/components/runs/use-live-refresh", () => ({
+ useLiveRefresh: vi.fn(),
+}));
+
+vi.mock("@/hooks/use-foreground-refresh", () => ({
+ useForegroundRefresh: vi.fn(),
+}));
+
+import { useShortcutActivity } from "./use-shortcut-activity";
+
+function automation(id: string, enabled = true): Automation {
+ return {
+ id,
+ workspace_id: WORKSPACE_ID,
+ name: id,
+ enabled,
+ max_concurrent_runs: 1,
+ triggers: [],
+ created_at: "2026-01-01T00:00:00Z",
+ description: "",
+ workflow_id: "workflow-1",
+ workflow_step_id: "step-1",
+ agent_profile_id: "agent-1",
+ executor_profile_id: "executor-1",
+ repository_ids: [],
+ prompt: "",
+ task_title_template: "",
+ last_triggered_at: null,
+ updated_at: "2026-01-01T00:00:00Z",
+ };
+}
+
+function summary(id: string, openRuns: number): AutomationSummary {
+ return { automation_id: id, open_runs: openRuns, last_run: undefined };
+}
+
+describe("useShortcutActivity", () => {
+ beforeEach(() => {
+ summaryState.value = {
+ summaries: [],
+ loading: true,
+ error: null,
+ refresh: vi.fn(),
+ };
+ });
+
+ it("does not claim idle while the summary collection is loading", () => {
+ const { result } = renderHook(() =>
+ useShortcutActivity({
+ workspaceId: WORKSPACE_ID,
+ automations: [automation("automation-1")],
+ automationIds: ["automation-1"],
+ }),
+ );
+
+ expect(result.current.getActivity("automation-1").state).toBe("unknown");
+ });
+
+ it("derives running, idle, and paused from the existing automation state semantics", () => {
+ summaryState.value = {
+ ...summaryState.value,
+ loading: false,
+ summaries: [summary("running", 1), summary("idle", 0), summary("paused", 0)],
+ };
+ const automations = [automation("running"), automation("idle"), automation("paused", false)];
+ const { result } = renderHook(() =>
+ useShortcutActivity({
+ workspaceId: WORKSPACE_ID,
+ automations,
+ automationIds: automations.map(({ id }) => id),
+ }),
+ );
+
+ expect(result.current.getActivity("running").state).toBe("running");
+ expect(result.current.getActivity("idle").state).toBe("idle");
+ expect(result.current.getActivity("paused").state).toBe("paused");
+ });
+
+ it("marks a failed refresh unknown and aggregates only known running pins", () => {
+ summaryState.value = {
+ ...summaryState.value,
+ loading: false,
+ error: "offline",
+ summaries: [summary("running", 1)],
+ };
+ const { result } = renderHook(() =>
+ useShortcutActivity({
+ workspaceId: WORKSPACE_ID,
+ automations: [automation("running")],
+ automationIds: ["running", "missing"],
+ }),
+ );
+
+ expect(result.current.getActivity("running").state).toBe("unknown");
+ expect(result.current.hasRunningActivity(["running", "missing"])).toBe(false);
+ });
+
+ it("keeps workspace-scoped definitions and summaries from becoming idle when the pin is missing", () => {
+ summaryState.value = { ...summaryState.value, loading: false, summaries: [] };
+ const { result } = renderHook(() =>
+ useShortcutActivity({
+ workspaceId: "workspace-2",
+ automations: [],
+ automationIds: ["workspace-1-automation"],
+ }),
+ );
+
+ expect(result.current.getActivity("workspace-1-automation").state).toBe("unknown");
+ });
+});
diff --git a/apps/web/hooks/domains/sidebar/use-shortcut-activity.ts b/apps/web/hooks/domains/sidebar/use-shortcut-activity.ts
new file mode 100644
index 0000000000..fd295039c2
--- /dev/null
+++ b/apps/web/hooks/domains/sidebar/use-shortcut-activity.ts
@@ -0,0 +1,102 @@
+"use client";
+
+import { useCallback, useMemo } from "react";
+import { useAutomationSummaries } from "@/components/runs/use-automation-summaries";
+import { useLiveRefresh } from "@/components/runs/use-live-refresh";
+import { automationState, type AutomationActivityState } from "@/components/runs/automation-rows";
+import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
+import type { Automation } from "@/lib/types/automation";
+
+export type ShortcutActivityState = AutomationActivityState | "unknown";
+
+export type ShortcutActivity = {
+ state: ShortcutActivityState;
+ loading: boolean;
+ error: boolean;
+};
+
+export type ShortcutActivityReader = (automationId: string) => ShortcutActivity | undefined;
+
+type UseShortcutActivityOptions = {
+ workspaceId?: string;
+ automations: Automation[];
+ automationIds: string[];
+ /** Catalog data can still be loading while the saved layout is available. */
+ definitionsLoading?: boolean;
+ definitionsError?: string | null;
+ /** Hidden navigation surfaces do not keep a summary polling loop alive. */
+ active?: boolean;
+};
+
+type UseShortcutActivityResult = {
+ getActivity: (automationId: string) => ShortcutActivity;
+ hasRunningActivity: (automationIds: string[]) => boolean;
+ refresh: () => void;
+};
+
+const UNKNOWN_ACTIVITY: ShortcutActivity = { state: "unknown", loading: true, error: false };
+
+export function useShortcutActivity({
+ workspaceId,
+ automations,
+ automationIds,
+ definitionsLoading = false,
+ definitionsError = null,
+ active = true,
+}: UseShortcutActivityOptions): UseShortcutActivityResult {
+ const hasPins = automationIds.length > 0;
+ const summaryWorkspaceId = active && hasPins ? workspaceId : undefined;
+ const summaries = useAutomationSummaries(summaryWorkspaceId);
+ const refresh = useCallback(() => summaries.refresh(), [summaries.refresh]);
+
+ useLiveRefresh(Boolean(summaryWorkspaceId), refresh);
+ useForegroundRefresh(refresh, Boolean(summaryWorkspaceId), summaryWorkspaceId);
+
+ const activities = useMemo(() => {
+ const summariesByAutomationId = new Map(
+ summaries.summaries.map((item) => [item.automation_id, item]),
+ );
+ const automationsById = new Map(automations.map((item) => [item.id, item]));
+ const unavailable = definitionsError != null || summaries.error != null;
+ const pending = definitionsLoading || summaries.loading;
+ const next = new Map();
+
+ for (const automationId of automationIds) {
+ const automation = automationsById.get(automationId);
+ if (pending || unavailable || !automation) {
+ next.set(automationId, {
+ state: "unknown",
+ loading: pending,
+ error: unavailable,
+ });
+ continue;
+ }
+ const item = summariesByAutomationId.get(automationId);
+ next.set(automationId, {
+ state: automationState(automation, item?.open_runs ?? 0),
+ loading: false,
+ error: false,
+ });
+ }
+ return next;
+ }, [
+ automationIds,
+ automations,
+ definitionsError,
+ definitionsLoading,
+ summaries.error,
+ summaries.loading,
+ summaries.summaries,
+ ]);
+
+ const getActivity = useCallback(
+ (automationId: string) => activities.get(automationId) ?? UNKNOWN_ACTIVITY,
+ [activities],
+ );
+ const hasRunningActivity = useCallback(
+ (ids: string[]) => ids.some((id) => activities.get(id)?.state === "running"),
+ [activities],
+ );
+
+ return { getActivity, hasRunningActivity, refresh };
+}
diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-layout-navigation.ts b/apps/web/hooks/domains/sidebar/use-sidebar-layout-navigation.ts
new file mode 100644
index 0000000000..f926020e29
--- /dev/null
+++ b/apps/web/hooks/domains/sidebar/use-sidebar-layout-navigation.ts
@@ -0,0 +1,81 @@
+"use client";
+
+import { useMemo } from "react";
+import { useTranslation } from "react-i18next";
+import { useAppStore } from "@/components/state-provider";
+import { useSidebarShortcutCatalog } from "./use-sidebar-shortcut-catalog";
+import { useShortcutActivity } from "./use-shortcut-activity";
+import { fromApiSidebarLayout } from "@/lib/sidebar/layout-types";
+import { projectSidebarLayout } from "@/lib/sidebar/layout-projection";
+
+const BUILTIN_LABEL_KEYS: Record = {
+ home: "sidebar:home",
+ new_task: "sidebar:newTask",
+ automations: "common:automations",
+ canvases: "canvases:canvases",
+ integrations: "common:integrations",
+};
+
+export function useHasSavedSidebarLayout(): boolean {
+ return useAppStore((state) => {
+ const workspaceId = state.workspaces.activeId;
+ // The boot payload contains a projected default for every accessible
+ // workspace. Revision zero means the user has never selected a custom
+ // layout, so keep the legacy navigation path for that workspace.
+ return Boolean(
+ workspaceId && state.userSettings.sidebarLayoutsByWorkspace?.[workspaceId]?.revision > 0,
+ );
+ });
+}
+
+export function useSidebarLayoutNavigation({ active = true }: { active?: boolean } = {}) {
+ const { t } = useTranslation();
+ const workspaceId = useAppStore((state) => state.workspaces.activeId ?? undefined);
+ const savedLayout = useAppStore((state) => {
+ const activeWorkspaceId = state.workspaces.activeId;
+ return activeWorkspaceId
+ ? state.userSettings.sidebarLayoutsByWorkspace?.[activeWorkspaceId]
+ : undefined;
+ });
+ const catalog = useSidebarShortcutCatalog({ active });
+ const layout = useMemo(() => fromApiSidebarLayout(savedLayout), [savedLayout]);
+ const projection = useMemo(
+ () =>
+ projectSidebarLayout(layout, catalog.catalog, {
+ unavailableLabel: t("common:unavailable"),
+ builtinLabels: Object.fromEntries(
+ Object.entries(BUILTIN_LABEL_KEYS).map(([id, key]) => [id, t(key)]),
+ ),
+ }),
+ [catalog.catalog, layout, t],
+ );
+ const automationIds = useMemo(() => {
+ const ids = new Set();
+ for (const node of projection.nodes) {
+ if (!node.visible) continue;
+ if (node.destinationId === "automations") {
+ for (const automation of catalog.automations) ids.add(automation.id);
+ }
+ for (const shortcut of node.shortcuts) {
+ if (shortcut.target.kind === "automation") ids.add(shortcut.target.id);
+ }
+ }
+ return [...ids];
+ }, [catalog.automations, projection.nodes]);
+ const activity = useShortcutActivity({
+ workspaceId,
+ automations: catalog.automations,
+ automationIds,
+ definitionsLoading: catalog.definitionsLoading,
+ definitionsError: catalog.definitionsError,
+ active,
+ });
+
+ return {
+ workspaceId,
+ catalog,
+ layout,
+ projection,
+ activity,
+ };
+}
diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.test.ts b/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.test.ts
new file mode 100644
index 0000000000..0d17fe13be
--- /dev/null
+++ b/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.test.ts
@@ -0,0 +1,95 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { IconList } from "@tabler/icons-react";
+
+const mocks = vi.hoisted(() => ({
+ state: {
+ workspaces: { activeId: "workspace-1" },
+ automations: { items: [] },
+ },
+ canvasesEnabled: false,
+ canvases: vi.fn(),
+ automationRefresh: vi.fn(),
+}));
+
+vi.mock("@/components/state-provider", () => ({
+ useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
+}));
+
+vi.mock("@/hooks/domains/features/use-feature", () => ({
+ useFeature: () => mocks.canvasesEnabled,
+}));
+
+vi.mock("@/hooks/use-app-destinations", () => ({
+ useStaticDestinations: () => [
+ { id: "home", label: "Home", icon: IconList, section: "primary", href: "/" },
+ ],
+ useAppDestinations: () => [],
+}));
+
+vi.mock("@/components/runs/use-workspace-automations", () => ({
+ useWorkspaceAutomations: () => ({
+ automations: [],
+ loading: false,
+ error: null,
+ refresh: mocks.automationRefresh,
+ }),
+}));
+
+vi.mock("@/lib/api/domains/canvas-api", () => ({
+ listWorkspaceCanvases: (...args: unknown[]) => mocks.canvases(...args),
+}));
+
+vi.mock("@/lib/canvas-lifecycle", () => ({
+ useCanvasLifecycleRevision: () => 0,
+}));
+
+vi.mock("@/components/runs/use-live-refresh", () => ({ useLiveRefresh: vi.fn() }));
+vi.mock("@/hooks/use-foreground-refresh", () => ({ useForegroundRefresh: vi.fn() }));
+vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
+
+import { useSidebarShortcutCatalog } from "./use-sidebar-shortcut-catalog";
+
+describe("useSidebarShortcutCatalog", () => {
+ beforeEach(() => {
+ mocks.canvasesEnabled = false;
+ mocks.canvases.mockReset();
+ mocks.automationRefresh.mockReset();
+ });
+
+ it("settles the disabled canvas source without hiding built-in choices", async () => {
+ const { result } = renderHook(() => useSidebarShortcutCatalog());
+
+ await waitFor(() => expect(result.current.loading).toBe(false));
+
+ expect(mocks.canvases).not.toHaveBeenCalled();
+ expect(result.current.canvasLoading).toBe(false);
+ expect(result.current.canvasError).toBeNull();
+ expect(result.current.catalog.some((entry) => entry.target.id === "home")).toBe(true);
+ expect(result.current.definitionsError).toBeNull();
+ });
+
+ it("keeps a canvas failure independent from automation definitions", async () => {
+ mocks.canvasesEnabled = true;
+ mocks.canvases.mockRejectedValue(new Error("canvas unavailable"));
+
+ const { result } = renderHook(() => useSidebarShortcutCatalog());
+
+ await waitFor(() => expect(result.current.canvasError).toBe("canvas unavailable"));
+
+ expect(result.current.error).toBeNull();
+ expect(result.current.loading).toBe(false);
+ expect(result.current.catalog.some((entry) => entry.target.id === "home")).toBe(true);
+ });
+
+ it("does not fetch canvases while the navigation surface is inactive", async () => {
+ mocks.canvasesEnabled = true;
+
+ const { result } = renderHook(() => useSidebarShortcutCatalog({ active: false }));
+
+ await waitFor(() => expect(result.current.canvasLoading).toBe(false));
+
+ expect(mocks.canvases).not.toHaveBeenCalled();
+ expect(result.current.canvasError).toBeNull();
+ });
+});
diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.ts b/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.ts
new file mode 100644
index 0000000000..94f9dfc26e
--- /dev/null
+++ b/apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.ts
@@ -0,0 +1,141 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { useAppStore } from "@/components/state-provider";
+import { useFeature } from "@/hooks/domains/features/use-feature";
+import { useStaticDestinations, useAppDestinations } from "@/hooks/use-app-destinations";
+import { listWorkspaceCanvases, type Canvas } from "@/lib/api/domains/canvas-api";
+import { buildShortcutCatalog, type ShortcutCatalogEntry } from "@/lib/sidebar/shortcut-catalog";
+import { useWorkspaceAutomations } from "@/components/runs/use-workspace-automations";
+import { useLiveRefresh } from "@/components/runs/use-live-refresh";
+import { useForegroundRefresh } from "@/hooks/use-foreground-refresh";
+import { useCanvasLifecycleRevision } from "@/lib/canvas-lifecycle";
+
+const EMPTY_CANVASES: Canvas[] = [];
+
+type CanvasState = {
+ workspaceId: string | undefined;
+ items: Canvas[];
+ status: "loading" | "ready" | "error";
+ error: string | null;
+};
+
+function settledState(workspaceId: string | undefined): CanvasState {
+ return {
+ workspaceId,
+ items: EMPTY_CANVASES,
+ status: "ready",
+ error: null,
+ };
+}
+
+function useSidebarCanvases(workspaceId: string | undefined, enabled: boolean, active: boolean) {
+ const lifecycleRevision = useCanvasLifecycleRevision();
+ const [state, setState] = useState(() =>
+ enabled && workspaceId
+ ? { workspaceId, items: EMPTY_CANVASES, status: "loading", error: null }
+ : settledState(workspaceId),
+ );
+ const requestRef = useRef(0);
+
+ const refresh = useCallback(() => {
+ const requestId = ++requestRef.current;
+ if (!workspaceId || !enabled || !active) {
+ setState(settledState(workspaceId));
+ return;
+ }
+ setState({ workspaceId, items: EMPTY_CANVASES, status: "loading", error: null });
+ listWorkspaceCanvases(workspaceId)
+ .then((response) => {
+ if (requestRef.current !== requestId) return;
+ setState({
+ workspaceId,
+ items: response.canvases ?? EMPTY_CANVASES,
+ status: "ready",
+ error: null,
+ });
+ })
+ .catch((error: unknown) => {
+ if (requestRef.current !== requestId) return;
+ setState({
+ workspaceId,
+ items: EMPTY_CANVASES,
+ status: "error",
+ error: error instanceof Error ? error.message : null,
+ });
+ });
+ }, [active, enabled, workspaceId]);
+
+ useEffect(() => {
+ refresh();
+ }, [lifecycleRevision, refresh]);
+
+ useLiveRefresh(Boolean(workspaceId && enabled && active), refresh);
+ useForegroundRefresh(refresh, Boolean(workspaceId && enabled && active), workspaceId);
+
+ const switching = state.workspaceId !== workspaceId;
+ return {
+ items: switching ? EMPTY_CANVASES : state.items,
+ loading: Boolean(workspaceId && active) && (switching || state.status === "loading"),
+ error: switching ? null : state.error,
+ refresh,
+ };
+}
+
+export function useSidebarShortcutCatalog({ active = true }: { active?: boolean } = {}) {
+ const { t } = useTranslation();
+ const workspaceId = useAppStore((state) => state.workspaces.activeId) ?? undefined;
+ const automationStoreItems = useAppStore((state) => state.automations.items);
+ const canvasesEnabled = useFeature("canvases");
+ const staticDestinations = useStaticDestinations("sidebar", ["primary", "plugins", "insights"]);
+ const integrationDestinations = useAppDestinations("sidebar", "integrations");
+ const automations = useWorkspaceAutomations(workspaceId, { active });
+ const canvases = useSidebarCanvases(workspaceId, canvasesEnabled, active);
+ const previousAutomationItems = useRef(automationStoreItems);
+ useEffect(() => {
+ if (previousAutomationItems.current === automationStoreItems) return;
+ previousAutomationItems.current = automationStoreItems;
+ if (active && workspaceId) automations.refresh();
+ }, [active, automationStoreItems, automations.refresh, workspaceId]);
+ useLiveRefresh(Boolean(workspaceId && active), automations.refresh);
+ useForegroundRefresh(automations.refresh, Boolean(workspaceId && active), workspaceId);
+ const catalog = useMemo(
+ () =>
+ buildShortcutCatalog({
+ workspaceId,
+ destinations: [...staticDestinations, ...integrationDestinations],
+ canvases: canvases.items,
+ automations: automations.automations,
+ translate: t,
+ }),
+ [
+ automations.automations,
+ canvases.items,
+ integrationDestinations,
+ staticDestinations,
+ t,
+ workspaceId,
+ ],
+ );
+
+ return {
+ workspaceId,
+ catalog,
+ automations: automations.automations,
+ // Canvas availability is independent from the automation definitions and
+ // from the built-in/plugin destination catalog. Keep those source states
+ // separate so a disabled or unavailable canvas API cannot hide choices or
+ // turn a pinned automation's activity bubble into an error.
+ loading: automations.loading,
+ error: automations.error,
+ definitionsLoading: automations.loading,
+ definitionsError: automations.error,
+ canvasLoading: canvases.loading,
+ canvasError: canvases.error,
+ refresh: () => {
+ automations.refresh();
+ canvases.refresh();
+ },
+ };
+}
diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts
index f7b90ff67a..dfaa5689b2 100644
--- a/apps/web/hooks/use-ensure-user-settings.test.ts
+++ b/apps/web/hooks/use-ensure-user-settings.test.ts
@@ -83,6 +83,7 @@ function makeUnloadedSettings(): UserSettingsState {
savedLayouts: [],
sidebarViews: [],
sidebarViewsByWorkspace: {},
+ sidebarLayoutsByWorkspace: {},
sidebarActiveViewId: null,
sidebarDraft: null,
threadViews: [],
diff --git a/apps/web/lib/settings-discovery/catalog.test.ts b/apps/web/lib/settings-discovery/catalog.test.ts
index 8411bcddf1..4bb7efe9ac 100644
--- a/apps/web/lib/settings-discovery/catalog.test.ts
+++ b/apps/web/lib/settings-discovery/catalog.test.ts
@@ -51,6 +51,16 @@ const translate = (key: string) =>
)[key] ?? key;
describe("settings discovery catalog invariants", () => {
+ it("places sidebar customization under the Layouts page", () => {
+ const sidebar = SETTINGS_DISCOVERY_DEFINITIONS.find((entry) => entry.id === "layouts-sidebar");
+
+ expect(sidebar).toMatchObject({
+ kind: "section",
+ parentId: "preferences-layouts",
+ href: "/settings/preferences/layouts?tab=sidebar",
+ });
+ });
+
it("uses unique ids, valid parents, and targets for every control", () => {
const ids = new Set(SETTINGS_DISCOVERY_DEFINITIONS.map((entry) => entry.id));
const targets = SETTINGS_DISCOVERY_DEFINITIONS.flatMap((entry) =>
diff --git a/apps/web/lib/settings-discovery/catalog/index.ts b/apps/web/lib/settings-discovery/catalog/index.ts
index 3076e4640a..2b9894fbc7 100644
--- a/apps/web/lib/settings-discovery/catalog/index.ts
+++ b/apps/web/lib/settings-discovery/catalog/index.ts
@@ -44,6 +44,7 @@ export const SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS: Record = {
"/settings/general/editors": TO_TERMINAL_EDITORS,
"/settings/general/keyboard-shortcuts": TO_KEYBOARD_SHORTCUTS,
"/settings/general/layouts": "Redirects to the canonical Layouts page.",
+ "/settings/sidebar": "Redirects to the Sidebar tab on the canonical Layouts page.",
"/settings/general/message-queue": TO_TASK_BEHAVIOR,
"/settings/general/notifications": "Redirects to the canonical Notifications page.",
"/settings/general/resource-metrics": TO_APPEARANCE,
diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts
index 5acb62c5fb..2a465414c4 100644
--- a/apps/web/lib/settings-discovery/catalog/preferences.ts
+++ b/apps/web/lib/settings-discovery/catalog/preferences.ts
@@ -6,7 +6,9 @@ export const TERMINAL_LINKS_TARGET = "setting-terminal-links";
export const PREFERENCES_SETTINGS_HREF = "/settings/preferences";
const APPEARANCE_ID = "preferences-appearance";
export const APPEARANCE_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/appearance`;
+export const SIDEBAR_SETTINGS_HREF = "/settings/sidebar";
export const LAYOUTS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/layouts`;
+export const SIDEBAR_LAYOUT_TAB_HREF = `${LAYOUTS_SETTINGS_HREF}?tab=sidebar`;
const TERMINAL_EDITORS_ID = "preferences-terminal-editors";
export const TERMINAL_EDITORS_SETTINGS_HREF = `${PREFERENCES_SETTINGS_HREF}/terminal-editors`;
const NOTIFICATIONS_ID = "preferences-notifications";
@@ -197,6 +199,15 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] =
targetId: GENERAL_SETTINGS_TARGETS.layoutProfiles,
order: 21,
},
+ {
+ id: "layouts-sidebar",
+ kind: "section",
+ labelKey: "settings:sidebar",
+ parentId: "preferences-layouts",
+ groupId: "preferences",
+ href: SIDEBAR_LAYOUT_TAB_HREF,
+ order: 22,
+ },
{
id: TERMINAL_EDITORS_ID,
kind: "page",
diff --git a/apps/web/lib/settings-discovery/contract.generated.json b/apps/web/lib/settings-discovery/contract.generated.json
index db69df2820..d0001e580a 100644
--- a/apps/web/lib/settings-discovery/contract.generated.json
+++ b/apps/web/lib/settings-discovery/contract.generated.json
@@ -4865,6 +4865,24 @@
"change_timing": "future_views",
"settings_href": "/settings/preferences"
},
+ {
+ "key": "user_settings.sidebar_layout_state",
+ "field_path": "sidebar_layouts_by_workspace",
+ "label": "sidebar layout state",
+ "description": "Portable preference owned by the current user.",
+ "json_type": "object",
+ "support": "supported",
+ "classification": "writable",
+ "owner": "user-settings",
+ "writable": true,
+ "validator": "user-settings",
+ "authority": "user.self",
+ "change_timing": "future_views",
+ "settings_href": "/settings/preferences",
+ "schema": {
+ "type": "object"
+ }
+ },
{
"key": "user_settings.sidebar_active_view_id",
"field_path": "sidebar_active_view_id",
@@ -6305,6 +6323,7 @@
"sidebar_draft",
"sidebar_hover_delay_ms",
"sidebar_hover_enabled",
+ "sidebar_layouts_by_workspace",
"sidebar_task_color_automation",
"sidebar_task_color_patch",
"sidebar_task_colors",
diff --git a/apps/web/lib/settings-discovery/profile-contract.generated.json b/apps/web/lib/settings-discovery/profile-contract.generated.json
index 74a461fc1b..a534e5c022 100644
--- a/apps/web/lib/settings-discovery/profile-contract.generated.json
+++ b/apps/web/lib/settings-discovery/profile-contract.generated.json
@@ -434,6 +434,7 @@
"sidebar_draft",
"sidebar_hover_delay_ms",
"sidebar_hover_enabled",
+ "sidebar_layouts_by_workspace",
"sidebar_task_color_automation",
"sidebar_task_color_patch",
"sidebar_task_colors",
diff --git a/apps/web/lib/sidebar/layout-operations.test.ts b/apps/web/lib/sidebar/layout-operations.test.ts
new file mode 100644
index 0000000000..8ce18b3e3e
--- /dev/null
+++ b/apps/web/lib/sidebar/layout-operations.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+import {
+ addShortcut,
+ createShortcutSection,
+ moveShortcut,
+ moveSection,
+ removeShortcut,
+ renameShortcutSection,
+ setShortcutSectionNameDraft,
+ toggleNodeVisibility,
+ validateSidebarLayout,
+} from "./layout-operations";
+import { defaultSidebarLayout, SIDEBAR_LAYOUT_LIMITS, type SidebarLayout } from "./layout-types";
+
+const GROUP_A = "group-a";
+const SHORTCUT_A = "shortcut-a";
+
+function layoutWithSections(): SidebarLayout {
+ return {
+ ...defaultSidebarLayout(),
+ nodes: [
+ {
+ id: GROUP_A,
+ kind: "shortcuts",
+ visible: true,
+ name: "A",
+ shortcuts: [
+ { id: SHORTCUT_A, target: { kind: "destination", id: "github" } },
+ { id: "shortcut-b", target: { kind: "canvas", id: "canvas-1" } },
+ ],
+ },
+ {
+ id: "group-b",
+ kind: "shortcuts",
+ visible: true,
+ name: "B",
+ shortcuts: [],
+ },
+ ],
+ };
+}
+
+describe("sidebar layout operations", () => {
+ it("creates and renames an empty section", () => {
+ const created = createShortcutSection(defaultSidebarLayout(), " Team ", "group-1");
+
+ expect(created.nodes.at(-1)).toMatchObject({
+ id: "group-1",
+ kind: "shortcuts",
+ name: "Team",
+ shortcuts: [],
+ });
+ expect(renameShortcutSection(created, "group-1", "Personal").nodes.at(-1)?.name).toBe(
+ "Personal",
+ );
+ });
+
+ it("moves a shortcut between sections without changing its instance id", () => {
+ const moved = moveShortcut(layoutWithSections(), SHORTCUT_A, GROUP_A, "group-b", 0);
+
+ expect(moved.nodes[0]?.shortcuts).toEqual([
+ { id: "shortcut-b", target: { kind: "canvas", id: "canvas-1" } },
+ ]);
+ expect(moved.nodes[1]?.shortcuts).toEqual([
+ { id: SHORTCUT_A, target: { kind: "destination", id: "github" } },
+ ]);
+ });
+
+ it("rejects a duplicate target within the destination section", () => {
+ expect(() =>
+ addShortcut(layoutWithSections(), GROUP_A, {
+ id: "shortcut-c",
+ target: { kind: "canvas", id: "canvas-1" },
+ }),
+ ).toThrowError("duplicate_target");
+ });
+
+ it("keeps section order and visibility changes immutable", () => {
+ const layout = layoutWithSections();
+ const reordered = moveSection(layout, "group-b", 0);
+ const hidden = toggleNodeVisibility(reordered, "group-b", false);
+
+ expect(hidden.nodes.map((node) => node.id)).toEqual(["group-b", "group-a"]);
+ expect(hidden.nodes[0]?.visible).toBe(false);
+ expect(layout.nodes.map((node) => node.id)).toEqual(["group-a", "group-b"]);
+ });
+
+ it("keeps raw input while a section name is being edited", () => {
+ const withGroup = createShortcutSection(defaultSidebarLayout(), "Team", "group-1");
+ const cleared = setShortcutSectionNameDraft(withGroup, "group-1", "");
+ const spaced = setShortcutSectionNameDraft(cleared, "group-1", "Team ");
+
+ expect(cleared.nodes.at(-1)?.name).toBe("");
+ expect(spaced.nodes.at(-1)?.name).toBe("Team ");
+ expect(() => renameShortcutSection(cleared, "group-1", "")).toThrowError("invalid_group_name");
+ });
+
+ it("rejects limit and duplicate failures at the operation boundary", () => {
+ const layout = layoutWithSections();
+ const duplicate = {
+ ...layout,
+ nodes: layout.nodes.map((node) => ({
+ ...node,
+ shortcuts:
+ node.id === "group-b"
+ ? [{ id: "existing", target: { kind: "destination" as const, id: "github" } }]
+ : node.shortcuts,
+ })),
+ };
+
+ expect(() => moveShortcut(duplicate, SHORTCUT_A, GROUP_A, "group-b", 1)).toThrowError(
+ "duplicate_target",
+ );
+
+ let groups = defaultSidebarLayout();
+ for (let index = 0; index < SIDEBAR_LAYOUT_LIMITS.groups; index += 1) {
+ groups = createShortcutSection(groups, `Group ${index}`, `group-${index}`);
+ }
+ expect(() => createShortcutSection(groups, "One too many", "overflow")).toThrowError(
+ "limit_reached",
+ );
+ });
+
+ it("removes a shortcut and validates the resulting layout", () => {
+ const layout = removeShortcut(layoutWithSections(), GROUP_A, SHORTCUT_A);
+
+ expect(validateSidebarLayout(layout)).toEqual({ valid: true, errors: [] });
+ expect(layout.nodes[0]?.shortcuts?.map((shortcut) => shortcut.id)).toEqual(["shortcut-b"]);
+ });
+});
diff --git a/apps/web/lib/sidebar/layout-operations.ts b/apps/web/lib/sidebar/layout-operations.ts
new file mode 100644
index 0000000000..8092d9cae5
--- /dev/null
+++ b/apps/web/lib/sidebar/layout-operations.ts
@@ -0,0 +1,329 @@
+import { generateUUID } from "@/lib/utils";
+import {
+ SIDEBAR_LAYOUT_LIMITS,
+ SIDEBAR_LAYOUT_VERSION,
+ targetKey,
+ type SidebarLayout,
+ type SidebarLayoutNode,
+ type SidebarShortcut,
+} from "./layout-types";
+
+export type SidebarLayoutErrorCode =
+ | "invalid_version"
+ | "invalid_group_name"
+ | "missing_node"
+ | "not_a_group"
+ | "duplicate_target"
+ | "duplicate_id"
+ | "protected_node"
+ | "limit_reached"
+ | "invalid_position";
+
+export type SidebarLayoutValidation = {
+ valid: boolean;
+ errors: SidebarLayoutErrorCode[];
+};
+
+export class SidebarLayoutOperationError extends Error {
+ readonly code: SidebarLayoutErrorCode;
+
+ constructor(code: SidebarLayoutErrorCode) {
+ super(code);
+ this.name = "SidebarLayoutOperationError";
+ this.code = code;
+ }
+}
+
+const PROTECTED_NODE_IDS = new Set(["tasks", "inbox", "needs-you-inbox"]);
+function copyLayout(layout: SidebarLayout): SidebarLayout {
+ return {
+ ...layout,
+ nodes: layout.nodes.map((node) => ({
+ ...node,
+ ...(node.shortcuts ? { shortcuts: node.shortcuts.map((shortcut) => ({ ...shortcut })) } : {}),
+ })),
+ };
+}
+
+function codePoints(value: string): number {
+ return Array.from(value).length;
+}
+
+function groupAt(layout: SidebarLayout, nodeId: string): SidebarLayoutNode {
+ const node = layout.nodes.find((candidate) => candidate.id === nodeId);
+ if (!node) throw new SidebarLayoutOperationError("missing_node");
+ if (node.kind !== "shortcuts") throw new SidebarLayoutOperationError("not_a_group");
+ return node;
+}
+
+function shortcutCount(layout: SidebarLayout): number {
+ return layout.nodes.reduce((total, node) => total + (node.shortcuts?.length ?? 0), 0);
+}
+
+function validateShortcutGroup(node: SidebarLayoutNode): SidebarLayoutErrorCode[] {
+ const errors: SidebarLayoutErrorCode[] = [];
+ const name = node.name?.trim() ?? "";
+ if (codePoints(name) === 0 || codePoints(name) > SIDEBAR_LAYOUT_LIMITS.sectionNameCodePoints) {
+ errors.push("invalid_group_name");
+ }
+ if ((node.shortcuts?.length ?? 0) > SIDEBAR_LAYOUT_LIMITS.shortcutsPerGroup) {
+ errors.push("limit_reached");
+ }
+ const shortcutIds = new Set();
+ const targets = new Set();
+ for (const shortcut of node.shortcuts ?? []) {
+ if (shortcutIds.has(shortcut.id)) errors.push("duplicate_id");
+ shortcutIds.add(shortcut.id);
+ const key = targetKey(shortcut.target);
+ if (targets.has(key)) errors.push("duplicate_target");
+ targets.add(key);
+ }
+ return errors;
+}
+
+function validateNode(node: SidebarLayoutNode, nodeIds: Set): SidebarLayoutErrorCode[] {
+ const errors: SidebarLayoutErrorCode[] = [];
+ if (nodeIds.has(node.id)) errors.push("duplicate_id");
+ nodeIds.add(node.id);
+ if (node.kind === "shortcuts") return [...errors, ...validateShortcutGroup(node)];
+ if (node.id.startsWith("tasks") || PROTECTED_NODE_IDS.has(node.id)) errors.push("protected_node");
+ return errors;
+}
+
+export function validateSidebarLayout(layout: SidebarLayout): SidebarLayoutValidation {
+ const errors: SidebarLayoutErrorCode[] = [];
+ if (layout.version !== SIDEBAR_LAYOUT_VERSION) errors.push("invalid_version");
+ if (
+ layout.nodes.filter((node) => node.kind === "shortcuts").length > SIDEBAR_LAYOUT_LIMITS.groups
+ ) {
+ errors.push("limit_reached");
+ }
+ const nodeIds = new Set();
+ for (const node of layout.nodes) errors.push(...validateNode(node, nodeIds));
+ if (shortcutCount(layout) > SIDEBAR_LAYOUT_LIMITS.shortcuts) errors.push("limit_reached");
+ return { valid: errors.length === 0, errors: [...new Set(errors)] };
+}
+
+function replaceNode(
+ layout: SidebarLayout,
+ nodeId: string,
+ replace: (node: SidebarLayoutNode) => SidebarLayoutNode,
+) {
+ const next = copyLayout(layout);
+ const index = next.nodes.findIndex((node) => node.id === nodeId);
+ if (index < 0) throw new SidebarLayoutOperationError("missing_node");
+ next.nodes[index] = replace(next.nodes[index]);
+ return next;
+}
+
+export function createShortcutSection(
+ layout: SidebarLayout,
+ name: string,
+ id = generateUUID(),
+): SidebarLayout {
+ const trimmed = name.trim();
+ if (
+ codePoints(trimmed) === 0 ||
+ codePoints(trimmed) > SIDEBAR_LAYOUT_LIMITS.sectionNameCodePoints
+ ) {
+ throw new SidebarLayoutOperationError("invalid_group_name");
+ }
+ if (
+ layout.nodes.filter((node) => node.kind === "shortcuts").length >= SIDEBAR_LAYOUT_LIMITS.groups
+ ) {
+ throw new SidebarLayoutOperationError("limit_reached");
+ }
+ if (layout.nodes.some((node) => node.id === id))
+ throw new SidebarLayoutOperationError("duplicate_id");
+ const next = copyLayout(layout);
+ next.nodes.push({ id, kind: "shortcuts", visible: true, name: trimmed, shortcuts: [] });
+ return next;
+}
+
+export function renameShortcutSection(
+ layout: SidebarLayout,
+ nodeId: string,
+ name: string,
+): SidebarLayout {
+ const trimmed = name.trim();
+ if (
+ codePoints(trimmed) === 0 ||
+ codePoints(trimmed) > SIDEBAR_LAYOUT_LIMITS.sectionNameCodePoints
+ ) {
+ throw new SidebarLayoutOperationError("invalid_group_name");
+ }
+ groupAt(layout, nodeId);
+ return replaceNode(layout, nodeId, (node) => ({ ...node, name: trimmed }));
+}
+
+/**
+ * Applies the value currently in a section name input without validating or
+ * normalizing it. Validation belongs to the commit boundary so a user can
+ * clear and retype a name, and spaces typed between words are not discarded.
+ */
+export function setShortcutSectionNameDraft(
+ layout: SidebarLayout,
+ nodeId: string,
+ name: string,
+): SidebarLayout {
+ groupAt(layout, nodeId);
+ return replaceNode(layout, nodeId, (node) => ({ ...node, name }));
+}
+
+export function removeShortcutSection(layout: SidebarLayout, nodeId: string): SidebarLayout {
+ if (PROTECTED_NODE_IDS.has(nodeId)) throw new SidebarLayoutOperationError("protected_node");
+ groupAt(layout, nodeId);
+ return {
+ ...copyLayout(layout),
+ nodes: layout.nodes.filter((node) => node.id !== nodeId),
+ };
+}
+
+export function toggleNodeVisibility(
+ layout: SidebarLayout,
+ nodeId: string,
+ visible: boolean,
+): SidebarLayout {
+ if (PROTECTED_NODE_IDS.has(nodeId)) throw new SidebarLayoutOperationError("protected_node");
+ return replaceNode(layout, nodeId, (node) => ({ ...node, visible }));
+}
+
+export function addShortcut(
+ layout: SidebarLayout,
+ nodeId: string,
+ shortcut: SidebarShortcut,
+): SidebarLayout {
+ const group = groupAt(layout, nodeId);
+ if (layout.nodes.some((node) => node.shortcuts?.some((item) => item.id === shortcut.id))) {
+ throw new SidebarLayoutOperationError("duplicate_id");
+ }
+ if (shortcutCount(layout) >= SIDEBAR_LAYOUT_LIMITS.shortcuts) {
+ throw new SidebarLayoutOperationError("limit_reached");
+ }
+ if ((group.shortcuts?.length ?? 0) >= SIDEBAR_LAYOUT_LIMITS.shortcutsPerGroup) {
+ throw new SidebarLayoutOperationError("limit_reached");
+ }
+ if (group.shortcuts?.some((item) => targetKey(item.target) === targetKey(shortcut.target))) {
+ throw new SidebarLayoutOperationError("duplicate_target");
+ }
+ return replaceNode(layout, nodeId, (node) => ({
+ ...node,
+ shortcuts: [...(node.shortcuts ?? []), { ...shortcut }],
+ }));
+}
+
+export function removeShortcut(
+ layout: SidebarLayout,
+ nodeId: string,
+ shortcutId: string,
+): SidebarLayout {
+ const group = groupAt(layout, nodeId);
+ if (!group.shortcuts?.some((shortcut) => shortcut.id === shortcutId)) {
+ throw new SidebarLayoutOperationError("missing_node");
+ }
+ return replaceNode(layout, nodeId, (node) => ({
+ ...node,
+ shortcuts: (node.shortcuts ?? []).filter((shortcut) => shortcut.id !== shortcutId),
+ }));
+}
+
+function assertMovePosition(position: number): void {
+ if (!Number.isInteger(position) || position < 0) {
+ throw new SidebarLayoutOperationError("invalid_position");
+ }
+}
+
+function assertTargetIsUnique(
+ sourceNodeId: string,
+ destinationNodeId: string,
+ destinationItems: SidebarShortcut[],
+ item: SidebarShortcut,
+): void {
+ const duplicate = destinationItems.some(
+ (shortcut) => targetKey(shortcut.target) === targetKey(item.target),
+ );
+ if (sourceNodeId !== destinationNodeId && duplicate) {
+ throw new SidebarLayoutOperationError("duplicate_target");
+ }
+}
+
+function insertMovedShortcut({
+ layout,
+ sourceNodeId,
+ destinationNodeId,
+ sourceIndex,
+ position,
+ item,
+}: {
+ layout: SidebarLayout;
+ sourceNodeId: string;
+ destinationNodeId: string;
+ sourceIndex: number;
+ position: number;
+ item: SidebarShortcut;
+}): SidebarLayout {
+ const next = copyLayout(layout);
+ const source = groupAt(next, sourceNodeId);
+ const destination = groupAt(next, destinationNodeId);
+ const sourceItems = source.shortcuts ?? [];
+ const destinationItems = destination.shortcuts ?? [];
+ sourceItems.splice(sourceIndex, 1);
+ const targetPosition =
+ sourceNodeId === destinationNodeId && position > sourceIndex ? position - 1 : position;
+ if (targetPosition > destinationItems.length) {
+ throw new SidebarLayoutOperationError("invalid_position");
+ }
+ destination.shortcuts = destinationItems;
+ destinationItems.splice(targetPosition, 0, item);
+ return next;
+}
+
+export function moveShortcut(
+ layout: SidebarLayout,
+ shortcutId: string,
+ sourceNodeId: string,
+ destinationNodeId: string,
+ position: number,
+): SidebarLayout {
+ const source = groupAt(layout, sourceNodeId);
+ const destination = groupAt(layout, destinationNodeId);
+ const sourceItems = source.shortcuts ?? [];
+ const destinationItems = destination.shortcuts ?? [];
+ const sourceIndex = sourceItems.findIndex((shortcut) => shortcut.id === shortcutId);
+ const item = sourceItems[sourceIndex];
+ if (!item) throw new SidebarLayoutOperationError("missing_node");
+ assertMovePosition(position);
+ if (
+ sourceNodeId !== destinationNodeId &&
+ destinationItems.length >= SIDEBAR_LAYOUT_LIMITS.shortcutsPerGroup
+ ) {
+ throw new SidebarLayoutOperationError("limit_reached");
+ }
+ assertTargetIsUnique(sourceNodeId, destinationNodeId, destinationItems, item);
+ return insertMovedShortcut({
+ layout,
+ sourceNodeId,
+ destinationNodeId,
+ sourceIndex,
+ position,
+ item,
+ });
+}
+
+export function moveSection(
+ layout: SidebarLayout,
+ nodeId: string,
+ position: number,
+): SidebarLayout {
+ if (PROTECTED_NODE_IDS.has(nodeId)) throw new SidebarLayoutOperationError("protected_node");
+ if (!Number.isInteger(position) || position < 0 || position >= layout.nodes.length) {
+ throw new SidebarLayoutOperationError("invalid_position");
+ }
+ const index = layout.nodes.findIndex((node) => node.id === nodeId);
+ if (index < 0) throw new SidebarLayoutOperationError("missing_node");
+ const next = copyLayout(layout);
+ const [node] = next.nodes.splice(index, 1);
+ if (!node) throw new SidebarLayoutOperationError("missing_node");
+ next.nodes.splice(position, 0, node);
+ return next;
+}
diff --git a/apps/web/lib/sidebar/layout-projection.test.ts b/apps/web/lib/sidebar/layout-projection.test.ts
new file mode 100644
index 0000000000..c914312a4b
--- /dev/null
+++ b/apps/web/lib/sidebar/layout-projection.test.ts
@@ -0,0 +1,126 @@
+import { describe, expect, it } from "vitest";
+import { defaultSidebarLayout, type SidebarLayout } from "./layout-types";
+import { materializeSidebarPluginNodes, projectSidebarLayout } from "./layout-projection";
+
+const SLACK_ID = "plugin:p:slack";
+
+const catalog = [
+ { target: { kind: "destination" as const, id: "github" }, label: "GitHub", available: true },
+ {
+ target: { kind: "destination" as const, id: SLACK_ID },
+ label: "Slack",
+ section: "integrations" as const,
+ source: "plugin" as const,
+ available: true,
+ },
+];
+
+describe("sidebar layout projection", () => {
+ it("keeps protected defaults and appends newly registered plugin entries", () => {
+ const projected = projectSidebarLayout(defaultSidebarLayout(), catalog, {
+ builtinLabels: {
+ home: "Home",
+ new_task: "New Task",
+ automations: "Automations",
+ canvases: "Canvases",
+ integrations: "Integrations",
+ },
+ });
+
+ expect(projected.nodes.map((node) => node.id)).toEqual([
+ "home",
+ "new-task",
+ "automations",
+ "canvases",
+ "integrations",
+ SLACK_ID,
+ ]);
+ expect(projected.nodes.map((node) => node.label)).toEqual([
+ "Home",
+ "New Task",
+ "Automations",
+ "Canvases",
+ "Integrations",
+ "Slack",
+ ]);
+ expect(projected.protectedNodeIds).toContain("tasks");
+ });
+
+ it("retains unavailable references in their saved position", () => {
+ const layout: SidebarLayout = {
+ ...defaultSidebarLayout(),
+ nodes: [
+ {
+ id: "group",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ shortcuts: [
+ { id: "missing", target: { kind: "automation", id: "gone" } },
+ { id: "github", target: { kind: "destination", id: "github" } },
+ ],
+ },
+ ],
+ };
+
+ const projected = projectSidebarLayout(layout, catalog, { unavailableLabel: "Unavailable" });
+ const shortcuts = projected.nodes[0]?.shortcuts ?? [];
+
+ expect(shortcuts.map((shortcut) => shortcut.target.id)).toEqual(["gone", "github"]);
+ expect(shortcuts[0]).toMatchObject({ label: "Unavailable", available: false });
+ expect(shortcuts[1]).toMatchObject({ label: "GitHub", available: true });
+ });
+
+ it("materializes a hidden plugin node without hiding its pinned shortcut", () => {
+ const layout: SidebarLayout = {
+ ...defaultSidebarLayout(),
+ nodes: [
+ {
+ id: SLACK_ID,
+ kind: "plugin",
+ visible: false,
+ destinationId: SLACK_ID,
+ },
+ {
+ id: "group",
+ kind: "shortcuts",
+ visible: true,
+ name: "Pinned",
+ shortcuts: [{ id: "slack-pin", target: { kind: "destination", id: SLACK_ID } }],
+ },
+ ],
+ };
+ const materialized = materializeSidebarPluginNodes(layout, catalog);
+ const projected = projectSidebarLayout(materialized, catalog, {
+ unavailableLabel: "Unavailable",
+ });
+
+ expect(projected.nodes.find((node) => node.id === SLACK_ID)?.visible).toBe(false);
+ expect(projected.nodes.find((node) => node.id === "group")?.shortcuts[0]).toMatchObject({
+ label: "Slack",
+ available: true,
+ });
+ });
+
+ it("marks a saved plugin destination unavailable while retaining its placement", () => {
+ const layout: SidebarLayout = {
+ ...defaultSidebarLayout(),
+ nodes: [
+ {
+ id: "plugin:removed:home",
+ kind: "plugin",
+ visible: true,
+ destinationId: "plugin:removed:home",
+ },
+ ],
+ };
+
+ const projected = projectSidebarLayout(layout, catalog, { unavailableLabel: "Unavailable" });
+
+ expect(projected.nodes[0]).toMatchObject({
+ id: "plugin:removed:home",
+ label: "Unavailable",
+ available: false,
+ });
+ });
+});
diff --git a/apps/web/lib/sidebar/layout-projection.ts b/apps/web/lib/sidebar/layout-projection.ts
new file mode 100644
index 0000000000..3ded404391
--- /dev/null
+++ b/apps/web/lib/sidebar/layout-projection.ts
@@ -0,0 +1,193 @@
+import {
+ IconBolt,
+ IconLayoutGrid,
+ IconList,
+ IconPlus,
+ IconQuestionMark,
+} from "@tabler/icons-react";
+import type { DestinationIcon } from "@/lib/navigation/types";
+import {
+ DEFAULT_SIDEBAR_NODE_IDS,
+ targetKey,
+ type SidebarLayout,
+ type SidebarLayoutNode,
+ type SidebarShortcut,
+} from "./layout-types";
+import type { ShortcutCatalogEntry } from "./shortcut-catalog";
+import { catalogEntryKey, unavailableShortcutIcon } from "./shortcut-catalog";
+
+export type ProjectedShortcut = SidebarShortcut & {
+ label: string;
+ icon: DestinationIcon;
+ href?: string;
+ source: NonNullable;
+ available: boolean;
+};
+
+export type ProjectedSidebarNode = Omit & {
+ label: string;
+ icon: DestinationIcon;
+ shortcuts: ProjectedShortcut[];
+ available?: boolean;
+};
+
+export type SidebarLayoutProjection = {
+ nodes: ProjectedSidebarNode[];
+ protectedNodeIds: string[];
+};
+
+type ProjectionOptions = {
+ unavailableLabel?: string;
+ builtinLabels?: Record;
+};
+
+const BUILTIN_ICONS: Record = {
+ home: IconList,
+ new_task: IconPlus,
+ automations: IconBolt,
+ canvases: IconLayoutGrid,
+ integrations: IconList,
+};
+
+function unavailableShortcut(shortcut: SidebarShortcut, label: string): ProjectedShortcut {
+ return {
+ ...shortcut,
+ label,
+ icon: unavailableShortcutIcon,
+ source: shortcut.target.kind === "destination" ? "builtin" : shortcut.target.kind,
+ available: false,
+ };
+}
+
+function projectShortcut(
+ shortcut: SidebarShortcut,
+ catalog: Map,
+ unavailableLabel: string,
+): ProjectedShortcut {
+ const entry = catalog.get(catalogEntryKey({ target: shortcut.target }));
+ return entry
+ ? {
+ ...shortcut,
+ ...entry,
+ source: entry.source ?? "builtin",
+ icon: entry.icon ?? unavailableShortcutIcon,
+ }
+ : unavailableShortcut(shortcut, unavailableLabel);
+}
+
+function pluginInsertionIndex(
+ nodes: SidebarLayoutNode[],
+ section: ShortcutCatalogEntry["section"],
+): number {
+ if (section === "plugins") {
+ const newTaskIndex = nodes.findIndex((node) => node.destinationId === "new_task");
+ return newTaskIndex >= 0 ? newTaskIndex + 1 : 0;
+ }
+ if (section === "integrations") {
+ const integrationsIndex = nodes.findIndex((node) => node.destinationId === "integrations");
+ return integrationsIndex >= 0 ? integrationsIndex + 1 : nodes.length;
+ }
+ return nodes.length;
+}
+
+/**
+ * Adds registered plugin destinations to the editable node list. The section
+ * comes from the navigation manifest, while the saved node controls visibility
+ * and ordering after the user has moved it.
+ */
+export function materializeSidebarPluginNodes(
+ layout: SidebarLayout,
+ catalog: ShortcutCatalogEntry[],
+): SidebarLayout {
+ const pluginEntries = catalog.filter(
+ (entry) => entry.source === "plugin" && entry.target.kind === "destination",
+ );
+ if (pluginEntries.length === 0) return layout;
+
+ const nodes = layout.nodes.map((node) => {
+ const entry = pluginEntries.find((item) => item.target.id === node.destinationId);
+ return entry?.section && node.kind === "plugin"
+ ? { ...node, pluginSection: entry.section }
+ : node;
+ });
+
+ for (const entry of [...pluginEntries].reverse()) {
+ if (nodes.some((node) => node.id === entry.target.id)) continue;
+ const index = pluginInsertionIndex(nodes, entry.section);
+ nodes.splice(index, 0, {
+ id: entry.target.id,
+ kind: "plugin",
+ visible: true,
+ destinationId: entry.target.id,
+ ...(entry.section ? { pluginSection: entry.section } : {}),
+ });
+ }
+ return nodes.length === layout.nodes.length &&
+ nodes.every((node, index) => node === layout.nodes[index])
+ ? layout
+ : { ...layout, nodes };
+}
+
+function projectNodePresentation(
+ node: SidebarLayoutNode,
+ catalog: Map,
+ unavailableLabel: string,
+ builtinLabels: Record,
+): Pick {
+ const builtinIcon = node.destinationId ? BUILTIN_ICONS[node.destinationId] : undefined;
+ const destination = node.destinationId
+ ? catalog.get(`destination:${node.destinationId}`)
+ : undefined;
+ if (node.kind === "plugin" && !destination) {
+ return { label: unavailableLabel, icon: unavailableShortcutIcon, available: false };
+ }
+ const label =
+ node.name ??
+ destination?.label ??
+ (node.destinationId ? builtinLabels[node.destinationId] : undefined) ??
+ node.id;
+ const icon = destination?.icon ?? builtinIcon ?? IconQuestionMark;
+ return { label, icon, available: true };
+}
+
+function projectNode(
+ node: SidebarLayoutNode,
+ catalog: Map,
+ unavailableLabel: string,
+ builtinLabels: Record,
+): ProjectedSidebarNode {
+ const presentation = projectNodePresentation(node, catalog, unavailableLabel, builtinLabels);
+ const shortcuts = (node.shortcuts ?? []).map((shortcut) =>
+ projectShortcut(shortcut, catalog, unavailableLabel),
+ );
+ return {
+ ...node,
+ ...presentation,
+ shortcuts,
+ };
+}
+
+export function projectSidebarLayout(
+ layout: SidebarLayout,
+ catalog: ShortcutCatalogEntry[],
+ options: ProjectionOptions = {},
+): SidebarLayoutProjection {
+ const byTarget = new Map(catalog.map((entry) => [catalogEntryKey(entry), entry]));
+ const nodes = materializeSidebarPluginNodes(layout, catalog).nodes.map((node) =>
+ projectNode(node, byTarget, options.unavailableLabel ?? node.id, options.builtinLabels ?? {}),
+ );
+ return {
+ nodes,
+ protectedNodeIds: ["tasks", "inbox", "needs-you-inbox"],
+ };
+}
+
+export function hasShortcut(layout: SidebarLayout, target: SidebarShortcut["target"]): boolean {
+ return layout.nodes.some((node) =>
+ node.shortcuts?.some((shortcut) => targetKey(shortcut.target) === targetKey(target)),
+ );
+}
+
+export function canonicalSidebarNodeIds(): readonly string[] {
+ return DEFAULT_SIDEBAR_NODE_IDS;
+}
diff --git a/apps/web/lib/sidebar/layout-types.ts b/apps/web/lib/sidebar/layout-types.ts
new file mode 100644
index 0000000000..5ae72c4acf
--- /dev/null
+++ b/apps/web/lib/sidebar/layout-types.ts
@@ -0,0 +1,144 @@
+import type {
+ SidebarLayoutApi,
+ SidebarLayoutNodeApi,
+ SidebarShortcutApi,
+ SidebarShortcutTargetApi,
+} from "@/lib/types/http-user-settings";
+import type { NavSection } from "@/lib/navigation/types";
+
+export const SIDEBAR_LAYOUT_VERSION = 1;
+export const SIDEBAR_LAYOUT_LIMITS = {
+ groups: 20,
+ shortcutsPerGroup: 20,
+ shortcuts: 100,
+ sectionNameCodePoints: 60,
+} as const;
+
+export type SidebarShortcutTargetKind = SidebarShortcutTargetApi["kind"];
+export type SidebarShortcutTarget = SidebarShortcutTargetApi;
+export type SidebarShortcut = SidebarShortcutApi;
+export type SidebarLayoutNodeKind = SidebarLayoutNodeApi["kind"];
+
+export type SidebarLayoutNode = {
+ id: string;
+ kind: SidebarLayoutNodeKind;
+ visible: boolean;
+ destinationId?: string;
+ /** Frontend metadata used to retain a registered destination's source section. */
+ pluginSection?: NavSection;
+ name?: string;
+ shortcuts?: SidebarShortcut[];
+};
+
+export type SidebarLayout = {
+ version: number;
+ revision: number;
+ nodes: SidebarLayoutNode[];
+ unsupportedVersion?: boolean;
+};
+
+export const DEFAULT_SIDEBAR_NODE_IDS = [
+ "home",
+ "new-task",
+ "automations",
+ "canvases",
+ "integrations",
+] as const;
+
+export type SidebarBuiltinNodeId = (typeof DEFAULT_SIDEBAR_NODE_IDS)[number];
+
+export function defaultSidebarLayout(): SidebarLayout {
+ return {
+ version: SIDEBAR_LAYOUT_VERSION,
+ revision: 0,
+ nodes: [
+ {
+ id: "home",
+ kind: "builtin",
+ visible: true,
+ destinationId: "home",
+ },
+ {
+ id: "new-task",
+ kind: "builtin",
+ visible: true,
+ destinationId: "new_task",
+ },
+ {
+ id: "automations",
+ kind: "builtin",
+ visible: true,
+ destinationId: "automations",
+ },
+ {
+ id: "canvases",
+ kind: "builtin",
+ visible: true,
+ destinationId: "canvases",
+ },
+ {
+ id: "integrations",
+ kind: "builtin",
+ visible: true,
+ destinationId: "integrations",
+ },
+ ],
+ };
+}
+
+export function targetKey(target: SidebarShortcutTarget): string {
+ return `${target.kind}:${target.id}`;
+}
+
+export function fromApiSidebarLayout(value: SidebarLayoutApi | null | undefined): SidebarLayout {
+ if (!value) return defaultSidebarLayout();
+ if (value.version !== SIDEBAR_LAYOUT_VERSION) {
+ return {
+ ...defaultSidebarLayout(),
+ revision: Math.max(0, value.revision),
+ unsupportedVersion: true,
+ };
+ }
+ return {
+ version: value.version,
+ revision: Math.max(0, value.revision),
+ ...(value.unsupported_version ? { unsupportedVersion: true } : {}),
+ nodes: value.nodes.map((node) => ({
+ id: node.id,
+ kind: node.kind,
+ visible: node.visible,
+ ...(node.destination_id !== undefined ? { destinationId: node.destination_id } : {}),
+ ...(node.name !== undefined ? { name: node.name } : {}),
+ ...(node.shortcuts
+ ? {
+ shortcuts: node.shortcuts.map((shortcut) => ({
+ id: shortcut.id,
+ target: { ...shortcut.target },
+ })),
+ }
+ : {}),
+ })),
+ };
+}
+
+export function toApiSidebarLayout(value: SidebarLayout): SidebarLayoutApi {
+ return {
+ version: value.version,
+ revision: value.revision,
+ nodes: value.nodes.map((node) => ({
+ id: node.id,
+ kind: node.kind,
+ visible: node.visible,
+ ...(node.destinationId !== undefined ? { destination_id: node.destinationId } : {}),
+ ...(node.name !== undefined ? { name: node.name } : {}),
+ ...(node.shortcuts
+ ? {
+ shortcuts: node.shortcuts.map((shortcut) => ({
+ id: shortcut.id,
+ target: { ...shortcut.target },
+ })),
+ }
+ : {}),
+ })),
+ };
+}
diff --git a/apps/web/lib/sidebar/shortcut-catalog.test.ts b/apps/web/lib/sidebar/shortcut-catalog.test.ts
new file mode 100644
index 0000000000..27e9e94d2d
--- /dev/null
+++ b/apps/web/lib/sidebar/shortcut-catalog.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from "vitest";
+import { IconBrandGithub } from "@tabler/icons-react";
+import { buildShortcutCatalog } from "./shortcut-catalog";
+
+const WORKSPACE_ONE = "workspace-1";
+const WORKSPACE_TWO = "workspace-2";
+
+describe("sidebar shortcut catalog", () => {
+ it("keeps plugin destination ids owner-qualified and adds typed resources", () => {
+ const result = buildShortcutCatalog({
+ destinations: [
+ {
+ id: "plugin:slack:home",
+ label: "Slack",
+ icon: IconBrandGithub,
+ section: "plugins",
+ href: "/plugins/slack",
+ source: "plugin",
+ pluginItemId: "home",
+ },
+ ],
+ canvases: [
+ {
+ id: "canvas-1",
+ plugin_instance_id: "instance-1",
+ plugin_id: "canvas-plugin",
+ workspace_id: WORKSPACE_ONE,
+ scope_kind: "workspace",
+ title: "Canvas",
+ status: "active",
+ active_release_status: "valid",
+ },
+ ],
+ automations: [
+ {
+ id: "automation-1",
+ workspace_id: WORKSPACE_ONE,
+ name: "Nightly",
+ enabled: true,
+ max_concurrent_runs: 1,
+ triggers: [],
+ created_at: "2026-01-01T00:00:00Z",
+ },
+ ],
+ translate: (key) => key,
+ });
+
+ expect(result.find((entry) => entry.target.kind === "destination")).toMatchObject({
+ target: { id: "plugin:slack:home" },
+ source: "plugin",
+ });
+ expect(result.find((entry) => entry.target.kind === "canvas")).toMatchObject({
+ target: { kind: "canvas", id: "canvas-1" },
+ href: "/canvases/canvas-1",
+ });
+ expect(result.find((entry) => entry.target.kind === "automation")).toMatchObject({
+ target: { kind: "automation", id: "automation-1" },
+ href: "/automations/automation-1",
+ });
+ });
+
+ it("does not include inactive canvases or resources from another workspace", () => {
+ const result = buildShortcutCatalog({
+ workspaceId: WORKSPACE_ONE,
+ destinations: [],
+ canvases: [
+ {
+ id: "wrong-workspace",
+ plugin_instance_id: "instance-1",
+ plugin_id: "canvas-plugin",
+ workspace_id: WORKSPACE_TWO,
+ scope_kind: "workspace",
+ title: "Private",
+ status: "active",
+ active_release_status: "valid",
+ },
+ {
+ id: "disabled",
+ plugin_instance_id: "instance-2",
+ plugin_id: "canvas-plugin",
+ workspace_id: WORKSPACE_ONE,
+ scope_kind: "workspace",
+ title: "Disabled",
+ status: "disabled",
+ active_release_status: "valid",
+ },
+ ],
+ automations: [],
+ translate: (key) => key,
+ });
+
+ expect(
+ result.filter((entry) => entry.source === "canvas" || entry.source === "automation"),
+ ).toEqual([]);
+ });
+});
diff --git a/apps/web/lib/sidebar/shortcut-catalog.ts b/apps/web/lib/sidebar/shortcut-catalog.ts
new file mode 100644
index 0000000000..6a453017ac
--- /dev/null
+++ b/apps/web/lib/sidebar/shortcut-catalog.ts
@@ -0,0 +1,124 @@
+import {
+ IconBolt,
+ IconLayoutGrid,
+ IconMessageCircle,
+ IconPlus,
+ IconPuzzle,
+ IconTerminal2,
+} from "@tabler/icons-react";
+import type { Canvas } from "@/lib/api/domains/canvas-api";
+import { canvasHref } from "@/lib/api/domains/canvas-api";
+import { AUTOMATIONS_HREF } from "@/components/runs/runs-view";
+import type { Automation } from "@/lib/types/automation";
+import type { ResolvedDestination, DestinationIcon } from "@/lib/navigation/types";
+import type { SidebarShortcutTarget } from "./layout-types";
+
+export type SidebarHostActionId = "new_task" | "quick_chat" | "quick_terminal";
+
+export type ShortcutCatalogEntry = {
+ target: SidebarShortcutTarget;
+ label: string;
+ icon?: DestinationIcon;
+ href?: string;
+ /** Original navigation section for registered plugin destinations. */
+ section?: ResolvedDestination["section"];
+ source?: "builtin" | "plugin" | "canvas" | "automation" | "host_action";
+ available: boolean;
+};
+
+type AutomationCatalogItem = Pick & Partial;
+
+type HostAction = {
+ id: SidebarHostActionId;
+ label: string;
+ icon: DestinationIcon;
+};
+
+export type ShortcutCatalogOptions = {
+ workspaceId?: string;
+ destinations: ResolvedDestination[];
+ canvases: Canvas[];
+ automations: AutomationCatalogItem[];
+ hostActions?: HostAction[];
+ translate?: (key: string) => string;
+};
+
+const DEFAULT_HOST_ACTIONS: Array<{
+ id: SidebarHostActionId;
+ labelKey: string;
+ icon: DestinationIcon;
+}> = [
+ { id: "new_task", labelKey: "sidebar:newTask", icon: IconPlus },
+ { id: "quick_chat", labelKey: "sidebar:quickChat", icon: IconMessageCircle },
+ { id: "quick_terminal", labelKey: "sidebar:quickTerminal", icon: IconTerminal2 },
+];
+
+function isEligibleCanvas(canvas: Canvas, workspaceId: string | undefined): boolean {
+ return (
+ (!workspaceId || canvas.workspace_id === workspaceId) &&
+ canvas.scope_kind === "workspace" &&
+ canvas.status === "active" &&
+ canvas.active_release_status === "valid"
+ );
+}
+
+export function buildShortcutCatalog({
+ workspaceId,
+ destinations,
+ canvases,
+ automations,
+ hostActions,
+ translate = (key) => key,
+}: ShortcutCatalogOptions): ShortcutCatalogEntry[] {
+ const destinationEntries = destinations.map((destination) => ({
+ target: { kind: "destination" as const, id: destination.id },
+ label: destination.label,
+ icon: destination.icon,
+ href: destination.href,
+ section: destination.section,
+ source: destination.source === "plugin" ? ("plugin" as const) : ("builtin" as const),
+ available: true,
+ }));
+ const hostEntries = (
+ hostActions ??
+ DEFAULT_HOST_ACTIONS.map((action) => ({
+ id: action.id,
+ label: translate(action.labelKey),
+ icon: action.icon,
+ }))
+ ).map((action) => ({
+ target: { kind: "host_action" as const, id: action.id },
+ label: action.label,
+ icon: action.icon,
+ source: "host_action" as const,
+ available: true,
+ }));
+ const canvasEntries = canvases
+ .filter((canvas) => isEligibleCanvas(canvas, workspaceId))
+ .map((canvas) => ({
+ target: { kind: "canvas" as const, id: canvas.id },
+ label: canvas.title,
+ icon: IconLayoutGrid,
+ href: canvasHref(canvas.id),
+ source: "canvas" as const,
+ available: true,
+ }));
+ const automationEntries = automations
+ .filter((automation) => !workspaceId || automation.workspace_id === workspaceId)
+ .map((automation) => ({
+ target: { kind: "automation" as const, id: automation.id },
+ label: automation.name,
+ icon: IconBolt,
+ href: `${AUTOMATIONS_HREF}/${encodeURIComponent(automation.id)}`,
+ source: "automation" as const,
+ available: true,
+ }));
+
+ return [...destinationEntries, ...hostEntries, ...canvasEntries, ...automationEntries];
+}
+
+export function catalogEntryKey(entry: Pick): string {
+ return `${entry.target.kind}:${entry.target.id}`;
+}
+
+export const unavailableShortcutIcon = IconPuzzle;
diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts
index a0e65f8d27..c4b3e212a7 100644
--- a/apps/web/lib/ssr/user-settings.test.ts
+++ b/apps/web/lib/ssr/user-settings.test.ts
@@ -51,6 +51,22 @@ describe("user settings revision ordering", () => {
expect(result.revision).toBeNull();
});
+
+ it("hydrates workspace sidebar layouts without dropping their revisions", () => {
+ const result = buildCoreFields({
+ sidebar_layouts_by_workspace: {
+ "workspace-a": {
+ version: 1,
+ revision: 3,
+ nodes: [],
+ },
+ },
+ } as never) as Record;
+
+ expect(result.sidebarLayoutsByWorkspace).toEqual({
+ "workspace-a": { version: 1, revision: 3, nodes: [] },
+ });
+ });
});
describe("startup page user settings", () => {
diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts
index 0e25be646a..2af5df490b 100644
--- a/apps/web/lib/ssr/user-settings.ts
+++ b/apps/web/lib/ssr/user-settings.ts
@@ -68,6 +68,7 @@ export function createDefaultUserSettings(): UserSettingsState {
savedLayouts: [],
sidebarViews: [],
sidebarViewsByWorkspace: {},
+ sidebarLayoutsByWorkspace: {},
sidebarActiveViewId: null,
sidebarDraft: null,
threadViews: [DEFAULT_THREAD_VIEW],
@@ -319,6 +320,7 @@ export function buildCoreFields(
...buildAppearanceFields(s, current),
savedLayouts: s.saved_layouts ?? current.savedLayouts,
sidebarViewsByWorkspace: s.sidebar_views_by_workspace ?? current.sidebarViewsByWorkspace,
+ sidebarLayoutsByWorkspace: s.sidebar_layouts_by_workspace ?? current.sidebarLayoutsByWorkspace,
sidebarViews: mapDefined(s.sidebar_views, current.sidebarViews, (views) =>
views.map(fromApiSidebarView),
) as SidebarView[],
diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts
index 851b0db8ac..733018a2d7 100644
--- a/apps/web/lib/state/slices/settings/types.ts
+++ b/apps/web/lib/state/slices/settings/types.ts
@@ -17,6 +17,7 @@ import type {
MCPTaskAgentProfileDefault,
StartupPage,
} from "@/lib/types/http";
+import type { SidebarLayoutApi } from "@/lib/types/http-user-settings";
import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types";
import type { ThreadView, ThreadViewDraft } from "@/lib/state/slices/ui/thread-view-types";
import type { SidebarTaskPrefsState } from "@/lib/state/slices/ui/types";
@@ -450,6 +451,7 @@ export type UserSettingsState = {
savedLayouts: SavedLayout[];
sidebarViews: SidebarView[];
sidebarViewsByWorkspace: Record;
+ sidebarLayoutsByWorkspace: Record;
sidebarActiveViewId: string | null;
sidebarDraft: SidebarViewDraft | null;
threadViews: ThreadView[];
diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts
index 6a73ec3c3b..e0222d8f50 100644
--- a/apps/web/lib/types/http-user-settings.ts
+++ b/apps/web/lib/types/http-user-settings.ts
@@ -152,6 +152,32 @@ export type AppStatusBarOrderApi = {
right_item_ids?: string[];
};
+export type SidebarShortcutTargetApi = {
+ kind: "destination" | "host_action" | "canvas" | "automation";
+ id: string;
+};
+
+export type SidebarShortcutApi = {
+ id: string;
+ target: SidebarShortcutTargetApi;
+};
+
+export type SidebarLayoutNodeApi = {
+ id: string;
+ kind: "builtin" | "plugin" | "shortcuts";
+ visible: boolean;
+ destination_id?: string;
+ name?: string;
+ shortcuts?: SidebarShortcutApi[];
+};
+
+export type SidebarLayoutApi = {
+ version: number;
+ revision: number;
+ nodes: SidebarLayoutNodeApi[];
+ unsupported_version?: boolean;
+};
+
export type UserSettings = {
user_id: string;
workspace_id: WorkspaceId;
@@ -188,6 +214,7 @@ export type UserSettings = {
lsp_status_location?: LspStatusLocation;
saved_layouts?: SavedLayout[];
sidebar_views_by_workspace?: Record;
+ sidebar_layouts_by_workspace?: Record;
sidebar_views?: SidebarViewApi[];
sidebar_active_view_id?: string;
sidebar_draft?: SidebarViewDraftApi | null;
@@ -266,6 +293,11 @@ export type UserSettingsUpdatePayload = {
lsp_server_configs?: Record>;
lsp_status_location?: LspStatusLocation;
saved_layouts?: SavedLayout[];
+ sidebar_layout_state?: {
+ workspace_id: string;
+ expected_revision: number;
+ layout: SidebarLayoutApi | null;
+ };
sidebar_view_state?: {
workspace_id: string;
views?: SidebarViewApi[];
diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json
index b350169a2b..b33e2234ec 100644
--- a/apps/web/src/locales/en/settings.json
+++ b/apps/web/src/locales/en/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "Open newly created tasks automatically. Turn this off to stay on your current view. Tasks and agents still start as requested.",
"chatAnimations": "Chat animations",
"chatAnimationsDescription": "Animate incoming text, new chat items, and scrolling on this device. Respects reduced motion.",
- "discoveryAliasesChatMotion": "chat animations smooth scrolling text motion"
+ "discoveryAliasesChatMotion": "chat animations smooth scrolling text motion",
+ "sidebar": "Sidebar",
+ "sidebarDescription": "Customize the navigation for this workspace. Changes apply to your account and follow you across clients.",
+ "sidebarLayout": "Sidebar layout",
+ "sectionName": "Section name",
+ "sidebarSectionNameError": "Section names must contain 1 to 60 characters.",
+ "sidebarDuplicateShortcutError": "A shortcut can appear only once in a section.",
+ "sidebarLayoutLimitError": "The sidebar layout has reached its limit.",
+ "sidebarLayoutInvalidError": "Review the sidebar layout before saving.",
+ "sidebarToggleVisibility": "Toggle visibility",
+ "dragToReorder": "Drag to reorder",
+ "moveUp": "Move up",
+ "moveDown": "Move down",
+ "moveToSection": "Move to section",
+ "moveShortcutToSection": "Move {{label}} to section",
+ "addShortcutSection": "Add shortcut section",
+ "sidebarRestoreDefaults": "Restore defaults",
+ "sidebarConflict": "This layout changed elsewhere. Your draft is still here.",
+ "sidebarLoadLatest": "Load latest",
+ "sidebarSaveError": "The sidebar layout could not be saved. Try again.",
+ "sidebarUnsupportedVersion": "This layout uses a newer version. Editing is paused until the app supports it.",
+ "sidebarNoWorkspace": "Select a workspace to customize its sidebar.",
+ "sidebarPreview": "Draft preview",
+ "addShortcut": "Add shortcut",
+ "editSection": "Edit section",
+ "sidebarShortcutLoadError": "Shortcuts could not be loaded. Try again.",
+ "noMatchingShortcuts": "No matching shortcuts",
+ "searchShortcuts": "Search shortcuts",
+ "sidebarPreviewEmpty": "No visible navigation entries"
}
diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json
index 3c4f78748e..14e89d1668 100644
--- a/apps/web/src/locales/pseudo/settings.json
+++ b/apps/web/src/locales/pseudo/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "Ōƥēń ńēŵĺŷ ćŕēàţēď ţàśķś àũţōḿàţĩćàĺĺŷ. Ţũŕń ţĥĩś ōƒƒ ţō śţàŷ ōń ŷōũŕ ćũŕŕēńţ vĩēŵ. Ţàśķś àńď àĝēńţś śţĩĺĺ śţàŕţ àś ŕēqũēśţēď.",
"chatAnimations": "Ćĥàţ àńĩḿàţĩōńś",
"chatAnimationsDescription": "Àńĩḿàţē ĩńćōḿĩńĝ ţēxţ, ńēŵ ćĥàţ ĩţēḿś, àńď śćŕōĺĺĩńĝ ōń ţĥĩś ďēvĩćē. Ŕēśƥēćţś ŕēďũćēď ḿōţĩōń.",
- "discoveryAliasesChatMotion": "ćĥàţ àńĩḿàţĩōńś śḿōōţĥ śćŕōĺĺĩńĝ ţēxţ ḿōţĩōń"
+ "discoveryAliasesChatMotion": "ćĥàţ àńĩḿàţĩōńś śḿōōţĥ śćŕōĺĺĩńĝ ţēxţ ḿōţĩōń",
+ "sidebar": "Śĩďēƀàŕ",
+ "sidebarDescription": "Ćũśţōḿĩźē ţĥē ńàvĩĝàţĩōń ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
+ "sidebarLayout": "Śĩďēƀàŕ ĺàŷōũţ",
+ "sectionName": "Śēćţĩōń ńàḿē",
+ "sidebarSectionNameError": "Śēćţĩōń ńàḿēś ḿũśţ ƀē 1 ţō 60 ćĥàŕàćţēŕś.",
+ "sidebarDuplicateShortcutError": "À śĥōŕţćũţ ćàń àƥƥēàŕ ōńĺŷ ōńćē ĩń à śēćţĩōń.",
+ "sidebarLayoutLimitError": "Ţĥē śĩďēƀàŕ ĺàŷōũţ ĥàś ŕēàćĥēď ĩţś ĺĩḿĩţ.",
+ "sidebarLayoutInvalidError": "Ŕēvĩēŵ ţĥē śĩďēƀàŕ ĺàŷōũţ ƀēƒōŕē śàvĩńĝ.",
+ "sidebarToggleVisibility": "Ţōĝĝĺē vĩśĩƀĩĺĩţŷ",
+ "dragToReorder": "Đŕàĝ ţō ŕēōŕďēŕ",
+ "moveUp": "Ḿōvē ũƥ",
+ "moveDown": "Ḿōvē ďōŵń",
+ "moveToSection": "Ḿōvē ţō śēćţĩōń",
+ "moveShortcutToSection": "Ḿōvē {{label}} ţō śēćţĩōń",
+ "addShortcutSection": "Àďď śĥōŕţćũţ śēćţĩōń",
+ "sidebarRestoreDefaults": "Ŕēśţōŕē ďēƒàũĺţś",
+ "sidebarConflict": "Ţĥĩś ĺàŷōũţ ćĥàńĝēď ēĺśēŵĥēŕē. Ŷōũŕ ďŕàƒţ ĩś śţĩĺĺ ĥēŕē.",
+ "sidebarLoadLatest": "Ĺōàď ĺàţēśţ",
+ "sidebarSaveError": "Ţĥē śĩďēƀàŕ ĺàŷōũţ ćōũĺď ńōţ ƀē śàvēď. Ţŕŷ àĝàĩń.",
+ "sidebarUnsupportedVersion": "Ţĥĩś ĺàŷōũţ ũśēś à ńēŵēŕ vēŕśĩōń. Ēďĩţĩńĝ ĩś ƥàũśēď ũńţĩĺ ţĥē àƥƥ śũƥƥōŕţś ĩţ.",
+ "sidebarNoWorkspace": "Śēĺēćţ à ŵōŕķśƥàćē ţō ćũśţōḿĩźē ĩţś śĩďēƀàŕ.",
+ "sidebarPreview": "Đŕàƒţ ƥŕēvĩēŵ",
+ "addShortcut": "Àďď śĥōŕţćũţ",
+ "editSection": "Ēďĩţ śēćţĩōń",
+ "sidebarShortcutLoadError": "Śĥōŕţćũţś ćōũĺď ńōţ ƀē ĺōàďēď. Ţŕŷ àĝàĩń.",
+ "noMatchingShortcuts": "Ńō ḿàţćĥĩńĝ śĥōŕţćũţś",
+ "searchShortcuts": "Śēàŕćĥ śĥōŕţćũţś",
+ "sidebarPreviewEmpty": "Ńō vĩśĩƀĺē ńàvĩĝàţĩōń ēńţŕĩēś"
}
diff --git a/apps/web/src/locales/pt-pt/settings.json b/apps/web/src/locales/pt-pt/settings.json
index a0727c808e..79eaf7de92 100644
--- a/apps/web/src/locales/pt-pt/settings.json
+++ b/apps/web/src/locales/pt-pt/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "Abre automaticamente as tarefas recém-criadas. Desative esta opção para manter a vista atual. As tarefas e os agentes continuam a iniciar conforme solicitado.",
"chatAnimations": "Animações do chat",
"chatAnimationsDescription": "Animar o texto recebido, os novos elementos do chat e a deslocação neste dispositivo. Respeita a preferência de movimento reduzido.",
- "discoveryAliasesChatMotion": "chat animações deslocação suave texto movimento"
+ "discoveryAliasesChatMotion": "chat animações deslocação suave texto movimento",
+ "sidebar": "Barra lateral",
+ "sidebarDescription": "Personalize a navegação desta área de trabalho. As alterações aplicam-se à sua conta e acompanham-no entre clientes.",
+ "sidebarLayout": "Esquema da barra lateral",
+ "sectionName": "Nome da secção",
+ "sidebarSectionNameError": "Os nomes das secções devem ter entre 1 e 60 caracteres.",
+ "sidebarDuplicateShortcutError": "Um atalho só pode aparecer uma vez numa secção.",
+ "sidebarLayoutLimitError": "O esquema da barra lateral atingiu o limite.",
+ "sidebarLayoutInvalidError": "Reveja o esquema da barra lateral antes de guardar.",
+ "sidebarToggleVisibility": "Alternar visibilidade",
+ "dragToReorder": "Arrastar para reordenar",
+ "moveUp": "Mover para cima",
+ "moveDown": "Mover para baixo",
+ "moveToSection": "Mover para a secção",
+ "moveShortcutToSection": "Mover {{label}} para a secção",
+ "addShortcutSection": "Adicionar secção de atalhos",
+ "sidebarRestoreDefaults": "Restaurar predefinições",
+ "sidebarConflict": "Este esquema foi alterado noutro local. O seu rascunho foi mantido.",
+ "sidebarLoadLatest": "Carregar a versão mais recente",
+ "sidebarSaveError": "Não foi possível guardar o esquema da barra lateral. Tente novamente.",
+ "sidebarUnsupportedVersion": "Este esquema usa uma versão mais recente. A edição está pausada até a aplicação a suportar.",
+ "sidebarNoWorkspace": "Selecione uma área de trabalho para personalizar a barra lateral.",
+ "sidebarPreview": "Pré-visualização do rascunho",
+ "addShortcut": "Adicionar atalho",
+ "editSection": "Editar secção",
+ "sidebarShortcutLoadError": "Não foi possível carregar os atalhos. Tente novamente.",
+ "noMatchingShortcuts": "Nenhum atalho correspondente",
+ "searchShortcuts": "Pesquisar atalhos",
+ "sidebarPreviewEmpty": "Não há entradas de navegação visíveis"
}
diff --git a/apps/web/src/locales/zh-cn/settings.json b/apps/web/src/locales/zh-cn/settings.json
index a0cec8fdf5..03a5388fb1 100644
--- a/apps/web/src/locales/zh-cn/settings.json
+++ b/apps/web/src/locales/zh-cn/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "自动打开新创建的任务。关闭此选项可保留当前视图。任务和代理仍会按要求启动。",
"chatAnimations": "聊天动画",
"chatAnimationsDescription": "在此设备上为新文本、新聊天项目和滚动添加动画。遵循减少动态效果的偏好设置。",
- "discoveryAliasesChatMotion": "聊天 动画 平滑滚动 文本 动态效果"
+ "discoveryAliasesChatMotion": "聊天 动画 平滑滚动 文本 动态效果",
+ "sidebar": "侧边栏",
+ "sidebarDescription": "自定义此工作区的导航。更改会应用到你的帐户,并在各个客户端同步。",
+ "sidebarLayout": "侧边栏布局",
+ "sectionName": "分区名称",
+ "sidebarSectionNameError": "分区名称必须包含 1 到 60 个字符。",
+ "sidebarDuplicateShortcutError": "同一分区中不能重复添加快捷方式。",
+ "sidebarLayoutLimitError": "侧边栏布局已达到上限。",
+ "sidebarLayoutInvalidError": "请在保存前检查侧边栏布局。",
+ "sidebarToggleVisibility": "切换可见性",
+ "dragToReorder": "拖动以重新排序",
+ "moveUp": "上移",
+ "moveDown": "下移",
+ "moveToSection": "移动到分区",
+ "moveShortcutToSection": "将 {{label}} 移动到分区",
+ "addShortcutSection": "添加快捷方式分区",
+ "sidebarRestoreDefaults": "恢复默认设置",
+ "sidebarConflict": "此布局已在其他位置更改。你的草稿仍然保留。",
+ "sidebarLoadLatest": "加载最新版本",
+ "sidebarSaveError": "无法保存侧边栏布局。请重试。",
+ "sidebarUnsupportedVersion": "此布局使用较新版本。在应用支持该版本前,编辑已暂停。",
+ "sidebarNoWorkspace": "请选择一个工作区来自定义其侧边栏。",
+ "sidebarPreview": "草稿预览",
+ "addShortcut": "添加快捷方式",
+ "editSection": "编辑分区",
+ "sidebarShortcutLoadError": "无法加载快捷方式。请重试。",
+ "noMatchingShortcuts": "没有匹配的快捷方式",
+ "searchShortcuts": "搜索快捷方式",
+ "sidebarPreviewEmpty": "没有可见的导航项"
}
diff --git a/apps/web/src/locales/zh-hk/settings.json b/apps/web/src/locales/zh-hk/settings.json
index c1689ba0a9..9889399687 100644
--- a/apps/web/src/locales/zh-hk/settings.json
+++ b/apps/web/src/locales/zh-hk/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前視圖。任務和代理仍會按要求啓動。",
"chatAnimations": "聊天動畫",
"chatAnimationsDescription": "在此設備上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。",
- "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果"
+ "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果",
+ "sidebar": "側邊欄",
+ "sidebarDescription": "自訂此工作區的導覽。變更會套用到你的帳戶,並在各個用戶端同步。",
+ "sidebarLayout": "側邊欄配置",
+ "sectionName": "區段名稱",
+ "sidebarSectionNameError": "分區名稱必須包含 1 至 60 個字元。",
+ "sidebarDuplicateShortcutError": "同一分區中不能重複加入捷徑。",
+ "sidebarLayoutLimitError": "側邊欄配置已達到上限。",
+ "sidebarLayoutInvalidError": "請在儲存前檢查側邊欄配置。",
+ "sidebarToggleVisibility": "切換顯示狀態",
+ "dragToReorder": "拖曳以重新排序",
+ "moveUp": "上移",
+ "moveDown": "下移",
+ "moveToSection": "移到分區",
+ "moveShortcutToSection": "將 {{label}} 移到分區",
+ "addShortcutSection": "加入捷徑分區",
+ "sidebarRestoreDefaults": "還原預設值",
+ "sidebarConflict": "此配置已在其他位置變更。你的草稿仍然保留。",
+ "sidebarLoadLatest": "載入最新版本",
+ "sidebarSaveError": "無法儲存側邊欄配置。請重試。",
+ "sidebarUnsupportedVersion": "此配置使用較新版本。在應用程式支援該版本前,編輯已暫停。",
+ "sidebarNoWorkspace": "請選擇工作區來自訂其側邊欄。",
+ "sidebarPreview": "草稿預覽",
+ "addShortcut": "加入捷徑",
+ "editSection": "編輯分區",
+ "sidebarShortcutLoadError": "無法載入捷徑。請重試。",
+ "noMatchingShortcuts": "沒有相符的捷徑",
+ "searchShortcuts": "搜尋捷徑",
+ "sidebarPreviewEmpty": "沒有可見的導覽項目"
}
diff --git a/apps/web/src/locales/zh-tw/settings.json b/apps/web/src/locales/zh-tw/settings.json
index 2daacf6b1c..a80170140f 100644
--- a/apps/web/src/locales/zh-tw/settings.json
+++ b/apps/web/src/locales/zh-tw/settings.json
@@ -810,5 +810,33 @@
"autoFocusNewTasksHelp": "自動開啟新建立的任務。關閉此選項可保留目前檢視。任務和代理仍會按要求啟動。",
"chatAnimations": "聊天動畫",
"chatAnimationsDescription": "在此裝置上為新文字、新聊天項目和滾動新增動畫。遵循減少動態效果的偏好設定。",
- "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果"
+ "discoveryAliasesChatMotion": "聊天 動畫 平滑滾動 文字 動態效果",
+ "sidebar": "側邊欄",
+ "sidebarDescription": "自訂此工作區的導覽。變更會套用到你的帳戶,並在各個用戶端同步。",
+ "sidebarLayout": "側邊欄配置",
+ "sectionName": "區段名稱",
+ "sidebarSectionNameError": "分區名稱必須包含 1 至 60 個字元。",
+ "sidebarDuplicateShortcutError": "同一分區中不能重複加入捷徑。",
+ "sidebarLayoutLimitError": "側邊欄配置已達到上限。",
+ "sidebarLayoutInvalidError": "請在儲存前檢查側邊欄配置。",
+ "sidebarToggleVisibility": "切換顯示狀態",
+ "dragToReorder": "拖曳以重新排序",
+ "moveUp": "上移",
+ "moveDown": "下移",
+ "moveToSection": "移到分區",
+ "moveShortcutToSection": "將 {{label}} 移到分區",
+ "addShortcutSection": "加入捷徑分區",
+ "sidebarRestoreDefaults": "還原預設值",
+ "sidebarConflict": "此配置已在其他位置變更。你的草稿仍然保留。",
+ "sidebarLoadLatest": "載入最新版本",
+ "sidebarSaveError": "無法儲存側邊欄配置。請重試。",
+ "sidebarUnsupportedVersion": "此配置使用較新版本。在應用程式支援該版本前,編輯已暫停。",
+ "sidebarNoWorkspace": "請選擇工作區來自訂其側邊欄。",
+ "sidebarPreview": "草稿預覽",
+ "addShortcut": "加入捷徑",
+ "editSection": "編輯分區",
+ "sidebarShortcutLoadError": "無法載入捷徑。請重試。",
+ "noMatchingShortcuts": "沒有相符的捷徑",
+ "searchShortcuts": "搜尋捷徑",
+ "sidebarPreviewEmpty": "沒有可見的導覽項目"
}
diff --git a/apps/web/src/settings-routes.test.ts b/apps/web/src/settings-routes.test.ts
index 33440820cc..7e1ade6d95 100644
--- a/apps/web/src/settings-routes.test.ts
+++ b/apps/web/src/settings-routes.test.ts
@@ -211,6 +211,14 @@ describe("renderSettingsRoute", () => {
expect(((route as ReactElement).type as { name?: string }).name).toBe("LayoutSettings");
});
+ it("redirects the legacy sidebar page into the Layouts sidebar tab", () => {
+ const route = renderSettingsRoute("/settings/sidebar") as ReactElement<{ to: string }>;
+
+ expect(isValidElement(route)).toBe(true);
+ expect((route.type as { name?: string }).name).toBe("SettingsRedirect");
+ expect(route.props.to).toBe("/settings/preferences/layouts?tab=sidebar");
+ });
+
it("redirects the legacy task actions page into Task behavior", () => {
const route = renderSettingsRoute("/settings/general/task-actions") as ReactElement<{
to: string;
diff --git a/apps/web/src/settings-routes.tsx b/apps/web/src/settings-routes.tsx
index 438e397156..d6eebe656a 100644
--- a/apps/web/src/settings-routes.tsx
+++ b/apps/web/src/settings-routes.tsx
@@ -64,6 +64,7 @@ import {
KEYBOARD_SHORTCUTS_SETTINGS_HREF,
LAYOUTS_SETTINGS_HREF,
NOTIFICATIONS_SETTINGS_HREF,
+ SIDEBAR_LAYOUT_TAB_HREF,
TASK_BEHAVIOR_SETTINGS_HREF,
TERMINAL_EDITORS_SETTINGS_HREF,
} from "@/lib/settings-discovery/catalog/preferences";
@@ -137,6 +138,7 @@ const SETTINGS_ROUTES: Record = {
"/settings/preferences/notifications": () => ,
"/settings/preferences/task-behavior": () => ,
"/settings/preferences/terminal-editors": () => ,
+ "/settings/sidebar": () => ,
// Legacy /settings/general paths, one redirect per page that lived there.
"/settings/general": () => ,
"/settings/general/appearance": () => ,
diff --git a/docs/plans/sidebar-customization/plan.md b/docs/plans/sidebar-customization/plan.md
new file mode 100644
index 0000000000..0d5153003f
--- /dev/null
+++ b/docs/plans/sidebar-customization/plan.md
@@ -0,0 +1,322 @@
+---
+created: 2026-09-19
+status: implemented
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-002
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-003
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-005
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+legacy_specs: []
+---
+
+# Implementation Plan: Sidebar Customization
+
+## Overview
+
+Deliver personal workspace layouts, named collapsible shortcut sections, and
+live automation bubbles. Preserve the agreed direct-action icon header and
+matching labelled list. The five work orders are implemented sequentially in
+the primary session. This task does not include commits, pushes, or PR
+publication.
+
+## Scope
+
+### In scope
+
+- Visibility and ordering of optional navigation entries.
+- Named groups mixing GitHub, registered plugin links, canvases, and automations.
+- Header actions, expanded lists, drag/drop, and explicit move controls.
+- Portable per-user/per-workspace settings, defaults, reset, and conflict recovery.
+- Running/idle/paused bubbles, including folded groups and overflow.
+- Desktop, collapsed rail, phone editor/menu, accessibility, and localization.
+
+### Out of scope
+
+Arbitrary plugin slot buttons, a new plugin SDK API, direct automation execution,
+custom scripts/URLs, shared team layouts, task filter changes, and reordering
+Office-only sections. Tasks and required inbox entries remain fixed. Header
+icons with text labels are deferred in favor of the accepted icon header.
+
+## Technical approach
+
+1. Extend `internal/user` models, DTOs, service CAS, store, boot/WS projection,
+ and settings catalog with `sidebar_layouts_by_workspace` and a scoped patch.
+2. Add typed operations and a reference catalog under `apps/web/lib/sidebar/`.
+ Adapt `resolveDestinations`, owner-qualified plugin IDs, canvas eligibility,
+ and existing host launchers. Keep task-view settings independent.
+3. Add `/settings/sidebar`, Appearance/discovery entry points, and a
+ `useSettingsSaveContributor` editor with a local draft preview.
+4. Replace optional composition in `AppSidebarModeNav`/`AppSidebarPrimaryNav`
+ with the saved projection. Extend `AppNavSections` for phone navigation.
+ Retain `TasksSection`, required inbox entries, and settings takeover.
+5. Share automation summary reads and existing `automationState` derivation;
+ reuse Quick Chat bubble geometry without its unread semantics.
+
+See the [requirements](../../specs/ui/requirements/sidebar-customization.md)
+and [design](../../specs/ui/system-design/sidebar-customization.md) for contracts.
+The work orders below record the implementation modules and verification
+evidence for this package.
+
+## ASCII UI preview
+
+The disclosure/action split, icon header, matching list, fixed task region,
+and dedicated phone composition are structural requirements. Spacing, icons,
+and example names are illustrative. All product copy uses localization.
+
+### UI-01: Desktop editor, draft with hidden entries
+
+Entry: Appearance > Sidebar or Customize sidebar.
+
+```text
+Sidebar Workspace: Default
+Changes apply to your navigation in this workspace.
+
+LAYOUT DRAFT PREVIEW
+:: Home [off] > Shortcuts [GH][S][C][A]
+:: New Task [on] Tasks
+:: Shortcuts [on] ...
+ Name [Shortcuts ]
+ :: [GH] GitHub [x]
+ :: [S] Slack [x]
+ :: [C] My canvas [x]
+ :: [A] My automation [x]
+ [+ Add shortcut]
+:: Automations [off]
+:: Canvases [off]
+:: Integrations [off]
+ Tasks [fixed]
+[+ Add shortcut section] [Restore defaults]
+ [Discard] [Save changes]*
+```
+
+`::` is a drag handle with explicit move controls. `*` is the existing shared
+save control, not a new local footer. The draft preview does not change live
+navigation. Fixed Tasks and required inbox entries remain outside reorder scope.
+
+### UI-02: Phone editor, focused section
+
+Entry: Menu > Customize sidebar. Full-page settings navigation.
+
+```text
+< Sidebar Default
+Shortcuts
+Name [Shortcuts ]
+
+[GH] GitHub [...]
+[S] Slack [...]
+[C] My canvas [...]
+[A] My automation [...]
+[+ Add shortcut]
+
+... menu:
+ Move up
+ Move down
+ Move to section >
+ Remove
+
+ [Save changes]*
+```
+
+Back returns to the layout list with visibility switches. Add shortcut opens a
+searchable picker; selection returns to this section draft. The page has one
+scroll owner and safe-area clearance. Explicit move controls are always reachable.
+
+### UI-03: Picker and save failures, shared content
+
+```text
+< Add shortcut
+[Search shortcuts... ]
+Built-in / Plugins / Canvases / Automations
+[GH] GitHub
+[S] Slack
+[C] My canvas
+[A] My automation
+
+Empty: No matching shortcuts
+Loading: Loading shortcuts...
+Error: Could not load shortcuts [Retry]
+
+Save conflict: Layout changed elsewhere.
+Your draft is retained. [Load latest]
+Save error: Could not save changes. [Retry]
+```
+
+Desktop uses the existing picker overlay; phone uses the focused picker step.
+Loading, empty, and error are alternatives, not simultaneous messages.
+
+### UI-04: Expanded desktop sidebar, section folded and unfolded
+
+Entry: workspace navigation after saving the layout.
+
+```text
+Default [collapse]
+> Shortcuts [GH][S][C][A]
+Tasks All tasks
+ ...
+
+v Shortcuts [GH][S][C][A]
+ GitHub
+ Slack
+ My canvas
+ My automation
+Tasks All tasks
+ ...
+```
+
+The label/chevron toggles expansion; sibling icons navigate directly. Tasks
+retains the remaining scroll space. Header and list use the same ordering.
+
+### UI-05: Narrow header, collapsed rail, and unavailable target
+
+```text
+> My longer group [GH][S][...]
+
+56px rail: [Group]
+ opened menu:
+ GitHub
+ Slack
+ My canvas
+ My automation
+
+v Shortcuts [GH][?][C][A]
+ GitHub
+ Unavailable [disabled]
+ My canvas
+ My automation
+```
+
+More and the rail launcher reveal all remaining items with names. Saved
+unavailable references remain removable in settings. A new empty group shows
+its name; its editor offers Add shortcut. Icons never shrink to force a fit.
+
+### UI-06: Phone navigation drawer
+
+Entry: app menu; same layout, dedicated touch composition.
+
+```text
+Menu [close]
+Workspace: Default
+v Shortcuts
+ [GH] [S] [C] [A] [...]
+ GitHub
+ Slack
+ My canvas
+ My automation
+Tasks
+Settings
+Customize sidebar
+```
+
+The fixed menu header sits above one scrolling body. The icon strip occupies a
+separate line to retain 44px hit targets. Folding hides labelled entries only.
+Navigating closes the menu. Disclosure and overflow actions keep it open.
+
+### UI-07: Activity in folded, expanded, and overflow states
+
+```text
+> Shortcuts [GH][S][C][A*]
+
+v Shortcuts [GH][S][C][A*]
+ GitHub
+ Slack
+ My canvas
+ My automation Running *
+
+> Shortcuts [GH][S][...*]
+Rail: [Group*]
+Phone:
+> Shortcuts
+ [GH] [S] [C] [A*]
+```
+
+`*` represents an overlaid bubble, not a literal glyph. Running is blue,
+idle green, and paused muted. Unknown/loading/error has a distinct neutral
+indicator and explanatory text. Overflow and rail launchers aggregate hidden
+running activity. No bubble click target is added; the parent opens the shortcut.
+
+## Tests
+
+All test names below are implementation targets. Each work order adds failing
+coverage before its production changes, then records actual results.
+
+| Acceptance criteria (AC-UI-SIDEBAR-CUSTOMIZATION-) | Proposed evidence and test cases |
+| --- | --- |
+| 001.2, 001.3, 004.3 | `internal/user/service/sidebar_layout_test.go`: `TestSidebarLayoutScopedCAS`, `TestSidebarLayoutResetRevision`, `TestSidebarLayoutAccess`; handlers/store round trips |
+| 004.5 | Same service test: `TestSidebarLayoutValidation`; `layout-operations.test.ts`: `validates groups and moves without duplicates` |
+| 001.1, 001.4, 001.5 | `layout-projection.test.ts`: `preserves protected entries and palette`; visibility/navigation E2E |
+| 002.1, 002.6, 004.4 | `shortcut-catalog.test.ts`: `resolves typed targets`, `isolates plugin owners`, `retains unavailable references`, `navigates without executing` |
+| 002.4, 004.1, 004.2, 004.3 | `sidebar-layout-editor.test.tsx`: `saves and discards scoped drafts`, `keeps draft after conflict`, `moves across groups`; `sidebar-layout-editor-dnd.test.ts`: group reorder, cross-group move, duplicate rejection; editor E2E |
+| 002.2, 002.3, 002.5 | `shortcut-section.test.tsx`: `separates disclosure and navigation`, `shares order`, `offers overflow and rail menu`; navigation E2E |
+| 003.1, 003.2, 003.3, 003.4 | `use-shortcut-activity.test.ts`: `refreshes idle pins`, `does not claim idle before load`, `rejects stale workspace results`, `shares polling`; activity E2E |
+| 005.1, 005.2, 005.3, 005.4 | Phone E2E below, `typecheck`, `i18n:check`, and `i18n:ratchet` |
+
+## E2E tests
+
+Files live under `apps/web/e2e/tests/`. Each desktop scenario has a phone scenario
+with the same values and its own composition assertions.
+
+| Files | Project | Scenarios and acceptance coverage |
+| --- | --- | --- |
+| `settings/sidebar-customization.spec.ts`, `settings/mobile-sidebar-customization.spec.ts` | chromium / mobile-chrome | Added and passing. Desktop covers four picker additions, hidden Home, group reorder, save/reload, navigation rendering, and live pinned-automation activity. Phone covers the focused editor, four shortcuts, same-group move controls, touch sizing, and horizontal-overflow safety. AC 001.1-.5, 002.1/.4, 003.1, 004.1-.5, 005.1-.4. |
+| Existing `automations-sidebar.spec.ts` plus sidebar unit/component tests | chromium / existing regression | Covers the established automation list lifecycle, shortcut disclosure/actions, overflow, and rail behavior. These checks remain separate from the new mixed-layout browser scenario. |
+
+Use `test-base` and existing automation/plugin fixtures. Stub summary responses
+only for deterministic failure tests; prove at least one real run lifecycle.
+Phone tests assert 44px bounds, safe-area scrolling, no horizontal page overflow,
+and focus return. Test just below/above 768px plus the standard phone viewport.
+
+## Work orders
+
+- [x] [Task 01: Persist workspace sidebar layouts](task-01-persist-layouts.md)
+- [x] [Task 02: Resolve shortcut references and layout operations](task-02-resolve-shortcuts.md)
+- [x] [Task 03: Build the desktop and phone settings editor](task-03-edit-layouts.md)
+- [x] [Task 04: Render shortcut sections across navigation surfaces](task-04-render-navigation.md)
+- [x] [Task 05: Show automation activity on shortcut icons](task-05-show-activity.md)
+
+Dependency order: 01 -> 02 -> 03 -> 04 -> 05. No parallel agents are authorized.
+Work orders contain exact commands and proposed files. Install workspace
+dependencies once before implementation checks if this worktree lacks them.
+Managed E2E builds fresh assets; never use `--no-build` after source changes.
+
+## Verification results
+
+Product checks passed:
+
+- `(cd apps/backend && go test ./internal/user/... ./internal/settingscatalog/...)` passed.
+- Focused frontend Vitest coverage passed for settings mapping, layout operations and projection, shortcut catalog, editor save/conflict flows, drag-and-drop handlers, navigation, and activity.
+- `(cd apps/web && pnpm run typecheck)` passed.
+- `(cd apps/web && pnpm run lint)` passed.
+- `(cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet)` passed.
+- `(cd apps/web && pnpm run build:vite)` passed.
+- Existing desktop regression E2E passed 5 tests across automation sidebar activity and system navigation.
+- Existing mobile regression E2E passed 5 tests across settings navigation, sidebar access, and touch sizing.
+- New desktop sidebar customization E2E passed 2 tests, including four shortcut editing, visibility, group reorder, save/reload, navigation rendering, and a pinned automation status transition.
+- New phone sidebar customization E2E passed 1 test, including the focused editor, four shortcuts, same-group move controls, touch sizing, save/reload, and horizontal-overflow safety.
+- `node scripts/validate-public-docs.mjs` and `node --test scripts/validate-public-docs.test.mjs` passed.
+- `python3 scripts/list-docs.py validate` and `python3 scripts/lint-spec-files.py --all` passed.
+- `git diff --check` passed.
+
+The two dedicated sidebar-customization E2E files are now present and recorded
+above. The broader `layout/sidebar-shortcuts.spec.ts` and
+`layout/sidebar-shortcut-activity.spec.ts` matrix files were not added; their
+covered behavior remains represented by the named existing regression and
+unit/component suites. No unrun scenarios are reported as verified here.
+
+## Risks
+
+- Shortcut status polling must stay shared and remain active when groups fold.
+- Plugin navigation links are supported; arbitrary plugin slot buttons are not.
+- Concurrent edits require a conflict response instead of silent overwrites.
+- Existing Office/inbox contracts require fixed entries and mode-aware defaults.
+- Phone controls need a separate icon strip; squeezing the desktop header fails
+ touch sizing. The saved order stays shared.
+
+## Completion
+
+Implementation is reconciled with the design. Requirement and design statuses
+are active/current, this plan is implemented, work-order results record the
+checks, and public docs describe the shipped behavior. No commit, push, or PR
+is part of this task.
diff --git a/docs/plans/sidebar-customization/task-01-persist-layouts.md b/docs/plans/sidebar-customization/task-01-persist-layouts.md
new file mode 100644
index 0000000000..f7b135ad21
--- /dev/null
+++ b/docs/plans/sidebar-customization/task-01-persist-layouts.md
@@ -0,0 +1,88 @@
+---
+id: "01-persist-layouts"
+title: "Persist workspace sidebar layouts"
+status: done
+wave: 1
+depends_on: []
+plan: "plan.md"
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+acceptance_criteria:
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.5
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+---
+
+# Task 01: Persist workspace sidebar layouts
+
+## Summary
+
+Add a validated scoped user-settings contract and complete all delivery paths. The result is testable through settings reads and writes before UI adoption.
+
+## In scope
+
+- Models, DTOs, service validation and CAS, access filtering, revision conflicts, reset tombstones, defaults, and store round trips.
+- HTTP, boot, WebSocket, settings catalog/schema parity, and frontend wire/store mapping.
+
+## Out of scope
+
+- Visual editor, resource execution, and task-view preference changes.
+
+## Acceptance
+
+- Round trips preserve layout order and unrelated settings across restart and scoped concurrent saves.
+- Invalid, inaccessible, and stale-revision writes fail atomically; reset does not admit pre-reset writes.
+- Boot, HTTP, and WS expose the same defaults and versioned layout data.
+
+## Verification
+
+Run from the repository root. Install dependencies once with
+`(cd apps && pnpm install --frozen-lockfile)` if this worktree has no install.
+Use TDD for new logic and the named E2E scenarios. Managed E2E commands rebuild
+assets; run desktop and phone commands sequentially without worker overrides.
+
+```bash
+(cd apps/backend && go test ./internal/user/... ./internal/settingscatalog/...)
+(cd apps/web && pnpm exec vitest run lib/ssr/user-settings.test.ts)
+(cd apps/web && pnpm run typecheck)
+```
+
+## Files likely touched
+
+- `apps/backend/internal/user/{models,dto,service,handlers,store}/`
+- `apps/backend/internal/backendapp/boot_state_routes.go`
+- `apps/backend/internal/settingscatalog/`
+- `apps/web/lib/state/slices/settings/`
+- `apps/web/lib/ws/handlers/users.ts`
+- `apps/web/lib/ssr/user-settings.ts and user-settings.test.ts`
+
+## Dependencies
+
+None.
+
+## Risks
+
+Incomplete settings delivery can make reload or live updates erase preferences. Preserve current identity/access rules and cloned CAS values.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/sidebar-customization.md), IDs above.
+- [System design](../../specs/ui/system-design/sidebar-customization.md), corresponding sections.
+- [Plan](plan.md), test matrix and previews.
+- Scoped `apps/web/AGENTS.md` and `apps/backend/AGENTS.md` where applicable.
+
+## Results
+
+Implemented workspace-scoped sidebar layout models, DTOs, validation, CAS
+updates, reset handling, store persistence, boot/HTTP/WS exposure, and frontend
+settings mapping. Verification passed with
+`(cd apps/backend && go test ./internal/user/... ./internal/settingscatalog/...)`,
+the frontend SSR settings tests, and `pnpm run typecheck`.
diff --git a/docs/plans/sidebar-customization/task-02-resolve-shortcuts.md b/docs/plans/sidebar-customization/task-02-resolve-shortcuts.md
new file mode 100644
index 0000000000..dea895a180
--- /dev/null
+++ b/docs/plans/sidebar-customization/task-02-resolve-shortcuts.md
@@ -0,0 +1,93 @@
+---
+id: "02-resolve-shortcuts"
+title: "Resolve shortcut references and layout operations"
+status: done
+wave: 2
+depends_on:
+ - "01-persist-layouts"
+plan: "plan.md"
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-002
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+acceptance_criteria:
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.5
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.6
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.5
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+---
+
+# Task 02: Resolve shortcut references and layout operations
+
+## Summary
+
+Build the shared typed layout operations and shortcut projection. Resolve existing domain references without copying navigation or execution authority.
+
+## In scope
+
+- Stable IDs, normalization, limits, reorder/move operations, default projection, protected entries, and unavailable references.
+- Adapters for built-in destinations, registered plugin navigation, canvas/automation links, and allowed host launchers.
+
+## Out of scope
+
+- New plugin SDK API, arbitrary callbacks/URLs, and rendered surfaces.
+
+## Acceptance
+
+- Typed resolution preserves access gates, owner-qualified plugin identities, and workspace isolation.
+- Moves preserve instance identity and reject duplicate targets within one group.
+- Automation and canvas shortcuts resolve navigation only; hidden defaults do not remove palette commands.
+
+## Verification
+
+Run from the repository root. Install dependencies once with
+`(cd apps && pnpm install --frozen-lockfile)` if this worktree has no install.
+Use TDD for new logic and the named E2E scenarios. Managed E2E commands rebuild
+assets; run desktop and phone commands sequentially without worker overrides.
+
+```bash
+(cd apps/web && pnpm exec vitest run lib/sidebar/layout-operations.test.ts lib/sidebar/layout-projection.test.ts lib/sidebar/shortcut-catalog.test.ts lib/navigation/core-destinations.test.ts)
+(cd apps/web && pnpm run typecheck)
+```
+
+## Files likely touched
+
+- `apps/web/lib/sidebar/layout-types.ts (new)`
+- `apps/web/lib/sidebar/layout-operations.ts (new)`
+- `apps/web/lib/sidebar/layout-projection.ts (new)`
+- `apps/web/lib/sidebar/shortcut-catalog.ts (new)`
+- `apps/web/lib/navigation/{resolve-destinations,plugin-destinations,surface-policy}.ts`
+- `apps/web/hooks/domains/sidebar/`
+
+## Dependencies
+
+01-persist-layouts.
+
+## Risks
+
+Do not confuse hidden with unavailable. Preserve unresolved identities during plugin loading and workspace transitions.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/sidebar-customization.md), IDs above.
+- [System design](../../specs/ui/system-design/sidebar-customization.md), corresponding sections.
+- [Plan](plan.md), test matrix and previews.
+- Scoped `apps/web/AGENTS.md` and `apps/backend/AGENTS.md` where applicable.
+
+## Results
+
+Implemented typed shortcut references, catalog resolution for built-ins, host
+actions, plugins, canvases, and automations, unavailable-target projection, and
+identity-preserving reorder/move operations. Verification passed with the
+sidebar operation, projection, catalog, and navigation Vitest coverage and
+`pnpm run typecheck`.
diff --git a/docs/plans/sidebar-customization/task-03-edit-layouts.md b/docs/plans/sidebar-customization/task-03-edit-layouts.md
new file mode 100644
index 0000000000..c6947566f7
--- /dev/null
+++ b/docs/plans/sidebar-customization/task-03-edit-layouts.md
@@ -0,0 +1,197 @@
+---
+id: "03-edit-layouts"
+title: "Build the desktop and phone settings editor"
+status: done
+wave: 3
+depends_on:
+ - "02-resolve-shortcuts"
+plan: "plan.md"
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-002
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-005
+acceptance_criteria:
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.5
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.4
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+---
+
+# Task 03: Build the desktop and phone settings editor
+
+## Summary
+
+Deliver Appearance > Sidebar with a draft preview, group editing, and a searchable picker. Use the shared save coordinator and focused phone settings flow.
+
+## In scope
+
+- SPA route and settings discovery; visibility, group CRUD, drag/drop, explicit moves, picker readiness and errors.
+- Workspace-bound draft, saved revision, save/discard/reset/conflict handling, locale catalogs, and phone composition.
+
+## Out of scope
+
+- Applying saved layouts to production navigation; live automation bubbles.
+
+## Acceptance
+
+- Users can build the four-shortcut example, move entries, hide sections, and save/reload through both editor presentations.
+- Save failures/conflicts preserve drafts; workspace changes never cross-apply edits.
+- Phone flow has one scroll owner, 44px targets, accessible moves, and localized validation.
+
+## ASCII UI preview
+
+See the [combined previews](plan.md#ascii-ui-preview). These views implement the
+acceptance criteria listed above.
+
+### UI-01: Desktop editor, draft with hidden entries
+
+Entry: Appearance > Sidebar or Customize sidebar.
+
+```text
+Sidebar Workspace: Default
+Changes apply to your navigation in this workspace.
+
+LAYOUT DRAFT PREVIEW
+:: Home [off] > Shortcuts [GH][S][C][A]
+:: New Task [on] Tasks
+:: Shortcuts [on] ...
+ Name [Shortcuts ]
+ :: [GH] GitHub [x]
+ :: [S] Slack [x]
+ :: [C] My canvas [x]
+ :: [A] My automation [x]
+ [+ Add shortcut]
+:: Automations [off]
+:: Canvases [off]
+:: Integrations [off]
+ Tasks [fixed]
+[+ Add shortcut section] [Restore defaults]
+ [Discard] [Save changes]*
+```
+
+`::` is a drag handle with explicit move controls. `*` is the existing shared
+save control, not a new local footer. The draft preview does not change live
+navigation. Fixed Tasks and required inbox entries remain outside reorder scope.
+
+### UI-02: Phone editor, focused section
+
+Entry: Menu > Customize sidebar. Full-page settings navigation.
+
+```text
+< Sidebar Default
+Shortcuts
+Name [Shortcuts ]
+
+[GH] GitHub [...]
+[S] Slack [...]
+[C] My canvas [...]
+[A] My automation [...]
+[+ Add shortcut]
+
+... menu:
+ Move up
+ Move down
+ Move to section >
+ Remove
+
+ [Save changes]*
+```
+
+Back returns to the layout list with visibility switches. Add shortcut opens a
+searchable picker; selection returns to this section draft. The page has one
+scroll owner and safe-area clearance. Explicit move controls are always reachable.
+
+### UI-03: Picker and save failures, shared content
+
+```text
+< Add shortcut
+[Search shortcuts... ]
+Built-in / Plugins / Canvases / Automations
+[GH] GitHub
+[S] Slack
+[C] My canvas
+[A] My automation
+
+Empty: No matching shortcuts
+Loading: Loading shortcuts...
+Error: Could not load shortcuts [Retry]
+
+Save conflict: Layout changed elsewhere.
+Your draft is retained. [Load latest]
+Save error: Could not save changes. [Retry]
+```
+
+Desktop uses the existing picker overlay; phone uses the focused picker step.
+Loading, empty, and error are alternatives, not simultaneous messages.
+
+## Verification
+
+Run from the repository root. Install dependencies once with
+`(cd apps && pnpm install --frozen-lockfile)` if this worktree has no install.
+Use TDD for new logic and the named E2E scenarios. Managed E2E commands rebuild
+assets; run desktop and phone commands sequentially without worker overrides.
+
+```bash
+(cd apps/web && pnpm exec vitest run components/settings/sidebar-layout-editor.test.tsx)
+(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet)
+(cd apps/web && pnpm e2e:run --project chromium tests/settings/sidebar-customization.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/settings/mobile-sidebar-customization.spec.ts)
+```
+
+## Files likely touched
+
+- `apps/web/app/settings/sidebar/page.tsx (new)`
+- `apps/web/components/settings/sidebar-layout-editor.tsx and focused helpers (new)`
+- `apps/web/components/settings/general-settings.tsx`
+- `apps/web/components/settings/settings-save-provider.tsx (reuse)`
+- `apps/web/lib/settings-discovery/`
+- `apps/web/src/spa-routes.tsx`
+- `apps/web/src/locales/`
+- `apps/web/e2e/tests/settings/*sidebar-customization.spec.ts (new)`
+
+## Dependencies
+
+02-resolve-shortcuts.
+
+## Risks
+
+The settings takeover hides the regular sidebar. Keep an explicit draft preview so editing remains understandable.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/sidebar-customization.md), IDs above.
+- [System design](../../specs/ui/system-design/sidebar-customization.md), corresponding sections.
+- [Plan](plan.md), test matrix and previews.
+- Scoped `apps/web/AGENTS.md` and `apps/backend/AGENTS.md` where applicable.
+
+The editor E2E assertions in this work order target saved settings and the draft
+preview. Task 04 extends these same tests with live navigation assertions.
+
+## Results
+
+Implemented the `/settings/sidebar` editor with workspace-bound drafts,
+visibility and section controls, shortcut picker, conflict/error retention,
+restore defaults, shared save coordination, and a focused phone section editor.
+Drag-and-drop group and shortcut reordering is supported with explicit 44px move
+controls as the accessible and phone-friendly alternative.
+The editor and supporting operation tests passed, together with typecheck and
+the i18n checks. The dedicated desktop and phone customization E2E scenarios
+also passed, covering deferred edits, four shortcut additions, visibility,
+group reorder, save/reload, the focused phone flow, and touch sizing.
diff --git a/docs/plans/sidebar-customization/task-04-render-navigation.md b/docs/plans/sidebar-customization/task-04-render-navigation.md
new file mode 100644
index 0000000000..0324610c3b
--- /dev/null
+++ b/docs/plans/sidebar-customization/task-04-render-navigation.md
@@ -0,0 +1,181 @@
+---
+id: "04-render-navigation"
+title: "Render shortcut sections across navigation surfaces"
+status: done
+wave: 4
+depends_on:
+ - "03-edit-layouts"
+plan: "plan.md"
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-002
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-005
+acceptance_criteria:
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-001.5
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.5
+ - AC-UI-SIDEBAR-CUSTOMIZATION-002.6
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-004.5
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.4
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+---
+
+# Task 04: Render shortcut sections across navigation surfaces
+
+## Summary
+
+Apply saved layouts to desktop and phone navigation. Preserve the agreed header/list behavior, fixed task region, and every shortcut through overflow and the collapsed rail.
+
+## In scope
+
+- Saved projection, disclosure/action separation, plugin placement visibility, workspace-qualified collapse, overflow and rail menus.
+- Phone menu integration, customization entry point, empty/unavailable states, focus and navigation behavior.
+- Update user navigation documentation and screenshot references for implemented layout behavior.
+
+## Out of scope
+
+- Activity polling and new automation semantics.
+
+## Acceptance
+
+- The exact four-icon header opens each target and independently expands the same ordered labelled list.
+- Hiding default sections leaves pinned shortcuts functional; protected navigation and palette behavior remain intact.
+- Desktop narrow/rail and phone overflow keep every shortcut reachable without clipped controls.
+
+## ASCII UI preview
+
+See the [combined previews](plan.md#ascii-ui-preview). These views implement the
+acceptance criteria listed above.
+
+### UI-04: Expanded desktop sidebar, section folded and unfolded
+
+Entry: workspace navigation after saving the layout.
+
+```text
+Default [collapse]
+> Shortcuts [GH][S][C][A]
+Tasks All tasks
+ ...
+
+v Shortcuts [GH][S][C][A]
+ GitHub
+ Slack
+ My canvas
+ My automation
+Tasks All tasks
+ ...
+```
+
+The label/chevron toggles expansion; sibling icons navigate directly. Tasks
+retains the remaining scroll space. Header and list use the same ordering.
+
+### UI-05: Narrow header, collapsed rail, and unavailable target
+
+```text
+> My longer group [GH][S][...]
+
+56px rail: [Group]
+ opened menu:
+ GitHub
+ Slack
+ My canvas
+ My automation
+
+v Shortcuts [GH][?][C][A]
+ GitHub
+ Unavailable [disabled]
+ My canvas
+ My automation
+```
+
+More and the rail launcher reveal all remaining items with names. Saved
+unavailable references remain removable in settings. A new empty group shows
+its name; its editor offers Add shortcut. Icons never shrink to force a fit.
+
+### UI-06: Phone navigation drawer
+
+Entry: app menu; same layout, dedicated touch composition.
+
+```text
+Menu [close]
+Workspace: Default
+v Shortcuts
+ [GH] [S] [C] [A] [...]
+ GitHub
+ Slack
+ My canvas
+ My automation
+Tasks
+Settings
+Customize sidebar
+```
+
+The fixed menu header sits above one scrolling body. The icon strip occupies a
+separate line to retain 44px hit targets. Folding hides labelled entries only.
+Navigating closes the menu. Disclosure and overflow actions keep it open.
+
+## Verification
+
+Run from the repository root. Install dependencies once with
+`(cd apps && pnpm install --frozen-lockfile)` if this worktree has no install.
+Use TDD for new logic and the named E2E scenarios. Managed E2E commands rebuild
+assets; run desktop and phone commands sequentially without worker overrides.
+
+```bash
+(cd apps/web && pnpm exec vitest run components/app-sidebar/shortcut-section.test.tsx components/app-sidebar/app-sidebar.test.tsx)
+(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet)
+(cd apps/web && pnpm e2e:run --project chromium tests/layout/sidebar-shortcuts.spec.ts tests/settings/sidebar-customization.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/layout/mobile-sidebar-shortcuts.spec.ts tests/settings/mobile-sidebar-customization.spec.ts)
+git diff --check -- docs/public
+```
+
+## Files likely touched
+
+- `apps/web/components/app-sidebar/app-sidebar.tsx`
+- `apps/web/components/app-sidebar/app-sidebar-primary-nav.tsx`
+- `apps/web/components/app-sidebar/shortcut-section.tsx (new)`
+- `apps/web/components/navigation/app-nav-sections.tsx`
+- `apps/web/components/kanban/mobile-menu-sheet.tsx`
+- `apps/web/components/plugins/plugin-nav-items.tsx`
+- `apps/web/e2e/tests/layout/*sidebar-shortcuts.spec.ts (new)`
+- `docs/public/use-kandev.md`
+- `docs/public/configuration.md`
+
+## Dependencies
+
+03-edit-layouts.
+
+## Risks
+
+Section lists own scrolling and may own fetch effects. Preserve task height and required data subscriptions while hiding optional navigation.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/sidebar-customization.md), IDs above.
+- [System design](../../specs/ui/system-design/sidebar-customization.md), corresponding sections.
+- [Plan](plan.md), test matrix and previews.
+- Scoped `apps/web/AGENTS.md` and `apps/backend/AGENTS.md` where applicable.
+
+## Results
+
+Implemented saved-layout rendering for desktop, the collapsed rail, and the
+phone navigation menu while preserving Tasks, required inbox entries, and
+Office composition. Shortcut section headers keep direct actions separate from
+disclosure, and overflow plus unavailable states remain reachable. Navigation
+component tests, the untouched Office phone fallback test, workspace-picker
+guard coverage, the dedicated desktop and phone customization E2E scenarios,
+the existing five-test desktop sidebar regression suite, the existing five-test
+mobile regression suite, and the Vite build passed.
diff --git a/docs/plans/sidebar-customization/task-05-show-activity.md b/docs/plans/sidebar-customization/task-05-show-activity.md
new file mode 100644
index 0000000000..72e9325ef1
--- /dev/null
+++ b/docs/plans/sidebar-customization/task-05-show-activity.md
@@ -0,0 +1,129 @@
+---
+id: "05-show-activity"
+title: "Show automation activity on shortcut icons"
+status: done
+wave: 5
+depends_on:
+ - "04-render-navigation"
+plan: "plan.md"
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-003
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-005
+acceptance_criteria:
+ - AC-UI-SIDEBAR-CUSTOMIZATION-003.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-003.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-003.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-003.4
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.1
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.2
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.3
+ - AC-UI-SIDEBAR-CUSTOMIZATION-005.4
+system_design:
+ - ../../specs/ui/system-design/sidebar-customization.md
+---
+
+# Task 05: Show automation activity on shortcut icons
+
+## Summary
+
+Add current automation state bubbles to header icons and expanded entries. Keep state fresh for folded groups, overflow menus, and the collapsed rail.
+
+## In scope
+
+- Shared workspace-scoped summary controller, readiness/error state, idle polling, reconnect/visibility refresh, and late-response protection.
+- Bubble geometry, accessible state labels, aggregate hidden-running markers, and documentation of status meanings.
+
+## Out of scope
+
+- Quick Chat unread changes, invented automation lifecycle states, and direct Run automation actions.
+
+## Acceptance
+
+- Running, idle, and paused match automationState; loading/failure never renders idle.
+- One shared refresh loop updates visible pins every ten seconds, including folded sections, and stops for hidden consumers.
+- Desktop and phone tests prove start/finish transitions, error recovery, aggregation, and workspace isolation.
+
+## ASCII UI preview
+
+See the [combined previews](plan.md#ascii-ui-preview). These views implement the
+acceptance criteria listed above.
+
+### UI-07: Activity in folded, expanded, and overflow states
+
+```text
+> Shortcuts [GH][S][C][A*]
+
+v Shortcuts [GH][S][C][A*]
+ GitHub
+ Slack
+ My canvas
+ My automation Running *
+
+> Shortcuts [GH][S][...*]
+Rail: [Group*]
+Phone:
+> Shortcuts
+ [GH] [S] [C] [A*]
+```
+
+`*` represents an overlaid bubble, not a literal glyph. Running is blue,
+idle green, and paused muted. Unknown/loading/error has a distinct neutral
+indicator and explanatory text. Overflow and rail launchers aggregate hidden
+running activity. No bubble click target is added; the parent opens the shortcut.
+
+## Verification
+
+Run from the repository root. Install dependencies once with
+`(cd apps && pnpm install --frozen-lockfile)` if this worktree has no install.
+Use TDD for new logic and the named E2E scenarios. Managed E2E commands rebuild
+assets; run desktop and phone commands sequentially without worker overrides.
+
+```bash
+(cd apps/web && pnpm exec vitest run hooks/domains/sidebar/use-shortcut-activity.test.ts components/app-sidebar/shortcut-section.test.tsx components/runs/automation-rows.test.ts)
+(cd apps/web && pnpm run typecheck && pnpm run i18n:check && pnpm run i18n:ratchet)
+(cd apps/web && pnpm e2e:run --project chromium tests/layout/sidebar-shortcut-activity.spec.ts)
+(cd apps/web && pnpm e2e:run --project mobile-chrome tests/layout/mobile-sidebar-shortcut-activity.spec.ts)
+python3 scripts/list-docs.py validate
+python3 scripts/lint-spec-files.py --all
+git diff --check
+```
+
+## Files likely touched
+
+- `apps/web/hooks/domains/sidebar/use-shortcut-activity.ts (new)`
+- `apps/web/components/runs/use-automation-summaries.ts`
+- `apps/web/components/runs/use-live-refresh.ts`
+- `apps/web/components/runs/automation-rows.ts (reuse semantics)`
+- `apps/web/components/quick-chat/quick-chat-activity-indicator.tsx (presentation reuse only)`
+- `apps/web/components/app-sidebar/shortcut-section.tsx`
+- `apps/web/e2e/tests/layout/*sidebar-shortcut-activity.spec.ts (new)`
+- `docs/public/use-kandev.md`
+
+## Dependencies
+
+04-render-navigation.
+
+## Risks
+
+An unloaded empty summary looks like idle in existing derivation. Gate by readiness and do not use unread-chat semantics for automation activity.
+
+## Parallelism
+
+`sequential`
+
+## Inputs
+
+- [Requirements](../../specs/ui/requirements/sidebar-customization.md), IDs above.
+- [System design](../../specs/ui/system-design/sidebar-customization.md), corresponding sections.
+- [Plan](plan.md), test matrix and previews.
+- Scoped `apps/web/AGENTS.md` and `apps/backend/AGENTS.md` where applicable.
+
+## Results
+
+Implemented shared automation summary activity for shortcut headers, expanded
+rows, overflow, and phone surfaces. Running, idle, paused, loading, and error
+states use the existing automation semantics; folded groups retain activity
+markers and refreshes are gated by visible consumers. Activity, shortcut
+section, automation-row, canvas-readiness, and inactive-surface tests passed, as
+did typecheck and the i18n checks. The desktop customization E2E also verified
+an idle pinned automation bubble changing to running after a seeded run.
diff --git a/docs/public/coverage.json b/docs/public/coverage.json
index 85b7c02fe9..20bbbbcf11 100644
--- a/docs/public/coverage.json
+++ b/docs/public/coverage.json
@@ -544,13 +544,28 @@
"apps/web/src/settings-routes.tsx",
"apps/web/components/settings/settings-index.tsx",
"apps/web/components/settings/general-settings.tsx",
- "apps/web/components/settings/layouts/layout-settings.tsx"
+ "apps/web/components/settings/layouts/layout-settings.tsx",
+ "apps/backend/internal/user/service/sidebar_layouts.go",
+ "apps/web/components/settings/sidebar-layout-editor.tsx",
+ "apps/web/hooks/domains/sidebar/use-sidebar-layout-navigation.ts",
+ "docs/public/use-kandev.md"
],
"tests": [
"apps/web/src/settings-routes.test.ts",
"apps/web/components/settings/settings-index.test.tsx",
"apps/web/components/settings/terminal-settings.test.ts",
- "apps/web/lib/layout/layout-profiles.test.ts"
+ "apps/web/lib/layout/layout-profiles.test.ts",
+ "apps/backend/internal/user/service/sidebar_layout_test.go",
+ "apps/web/components/settings/sidebar-layout-editor.test.tsx",
+ "apps/web/components/app-sidebar/shortcut-section.test.tsx",
+ "apps/web/hooks/domains/sidebar/use-shortcut-activity.test.ts",
+ "apps/web/lib/sidebar/layout-operations.test.ts",
+ "apps/web/hooks/domains/sidebar/use-sidebar-shortcut-catalog.test.ts",
+ "apps/web/components/runs/use-workspace-automations.test.ts",
+ "apps/web/components/navigation/app-nav-sheet.test.tsx",
+ "apps/web/e2e/tests/automations-sidebar.spec.ts",
+ "apps/web/e2e/tests/settings/sidebar-customization.spec.ts",
+ "apps/web/e2e/tests/settings/mobile-sidebar-customization.spec.ts"
],
"settingsRoutes": [
"/settings",
@@ -561,6 +576,7 @@
"/settings/preferences/notifications",
"/settings/preferences/task-behavior",
"/settings/preferences/terminal-editors",
+ "/settings/sidebar",
"/settings/secrets",
"/settings/general",
"/settings/general/appearance",
diff --git a/docs/public/use-kandev.md b/docs/public/use-kandev.md
index 52cf5ad25f..9de999e154 100644
--- a/docs/public/use-kandev.md
+++ b/docs/public/use-kandev.md
@@ -105,6 +105,23 @@ settings appear only after typing, while **Go to Settings** remains in the comma
Discovery searches setting names and curated aliases, never saved values, secrets, paths, or other
configuration content.
+## Customize the sidebar
+
+Open **Settings > Preferences > Sidebar** to customize the optional navigation for the active
+workspace. You can hide or reorder Home, New Task, Automations, Canvases, Integrations, and
+available plugin links. The setting belongs to your account and workspace, so it follows you
+across clients without changing another user's layout.
+
+Create named shortcut sections for destinations, canvases, automations, and plugin links. Fold a
+section to keep its header icons visible, or open the labelled list to use a shortcut. Automation
+icons show running, idle, or paused activity. Tasks and required inbox entries remain available in
+their fixed navigation area, and hiding a shortcut does not disable the underlying feature.
+
+On a phone, open the menu and choose **Sidebar** to edit the same layout. Use the move controls to
+reorder entries or move a shortcut to another section. **Restore defaults** resets the draft for the
+active workspace; the shared **Save changes** action persists it. If another client saves first, the
+editor keeps your draft and reports the conflict so you can reconcile it.
+
## Switch workspace
The workspace picker sits in the sidebar header, next to the Kandev brand. It lists every
diff --git a/docs/specs/ui/requirements/sidebar-customization.md b/docs/specs/ui/requirements/sidebar-customization.md
new file mode 100644
index 0000000000..61784b7671
--- /dev/null
+++ b/docs/specs/ui/requirements/sidebar-customization.md
@@ -0,0 +1,75 @@
+---
+status: active
+system: ui
+created: 2026-09-19
+owners:
+ - kandev
+---
+
+# Sidebar Customization
+
+## Overview
+
+Users can hide navigation entries and organize mixed shortcuts into named,
+collapsible sections. UI owns these reusable personal presentation preferences.
+Domain systems retain resource identity, access, execution, and activity state.
+
+The agreed section has a name and direct-action icons on one header line.
+Expanding it shows the same shortcuts as labelled entries below the header.
+A shortcut is a reference to an existing destination or supported host action.
+
+## Requirements
+
+### REQ-UI-SIDEBAR-CUSTOMIZATION-001: Personal workspace layout
+
+- **AC-UI-SIDEBAR-CUSTOMIZATION-001.1:** Users shall hide, show, and reorder Home, New Task, Automations, Canvases, Integrations, and available plugin navigation entries. Hiding an entry shall not disable its underlying capability.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-001.2:** Each user's workspace shall retain its own saved layout across reloads and signed-in clients. Changes shall not affect other users or workspaces.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-001.3:** A workspace without a saved layout shall retain current navigation defaults. Restore defaults shall reset only that workspace's layout after Save changes.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-001.4:** Settings, workspace switching, Tasks, and required inbox navigation shall remain reachable. Hiding Home shall not change the startup destination or brand-link destination.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-001.5:** Layout preferences shall affect the sidebar and corresponding phone navigation, without removing commands from search or changing settings navigation.
+
+### REQ-UI-SIDEBAR-CUSTOMIZATION-002: Mixed shortcut sections
+
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.1:** Users shall create, rename, hide, remove, and reorder shortcut sections. Each section shall accept built-in destinations, supported host actions, plugin navigation links, workspace canvases, and workspace automations.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.2:** The header shall show the section name and ordered shortcut icons. Activating its name or chevron shall toggle the labelled list below. Activating an icon shall invoke only its shortcut.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.3:** The expanded list shall contain the same shortcuts in the same order, with full accessible names. Header icons shall remain present when expanded.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.4:** Users shall reorder shortcuts within a section and move them between sections through drag and drop or explicit keyboard/touch controls.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.5:** Header overflow shall expose remaining shortcuts through a labelled More control. The collapsed sidebar rail shall provide a section launcher with access to every shortcut.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-002.6:** A pinned automation shall open its history, and a pinned canvas shall open that canvas. Pinning or opening shall not execute an automation, send a message, or create a task implicitly.
+
+### REQ-UI-SIDEBAR-CUSTOMIZATION-003: Automation activity bubbles
+
+- **AC-UI-SIDEBAR-CUSTOMIZATION-003.1:** An automation shortcut shall show a running, idle, or paused bubble using the automation list's current state semantics. The header bubble shall remain visible when the section is folded.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-003.2:** While its navigation surface is visible, a pinned automation shall refresh activity at least once every ten seconds. Starting and finishing runs shall update without expanding the section or reloading.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-003.3:** Tooltip and accessible text shall identify the automation and its activity. The expanded row shall show the same state. Loading or failed reads shall never claim idle activity.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-003.4:** Overflow and rail launchers shall indicate hidden running automation activity. Opening them shall identify the specific running shortcuts.
+
+### REQ-UI-SIDEBAR-CUSTOMIZATION-004: Settings editor and recovery
+
+- **AC-UI-SIDEBAR-CUSTOMIZATION-004.1:** Appearance settings and a Customize sidebar entry shall open an editor with workspace context, visibility controls, section editing, and a searchable shortcut picker.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-004.2:** The editor shall preview draft changes. Shared Save changes, discard, and navigation protection shall govern persistence. Failed saves shall retain the draft and explain the failure.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-004.3:** A workspace switch or delayed response shall not apply a draft or result to another workspace. Concurrent saves to different workspaces shall both survive. A conflicting save to the same layout shall retain the draft and require reconciliation.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-004.4:** Missing, disabled, inaccessible, or uninstalled targets shall appear unavailable without activation. Their saved positions shall remain until removal, and eligible returning targets shall resolve again.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-004.5:** Empty sections shall remain editable and offer Add shortcut in the editor. Invalid names, duplicate shortcuts within one section, and exceeded limits shall produce visible validation messages before saving.
+
+### REQ-UI-SIDEBAR-CUSTOMIZATION-005: Phone and accessible navigation
+
+- **AC-UI-SIDEBAR-CUSTOMIZATION-005.1:** Phone navigation shall expose the same saved shortcut groups and activity. Editing shall use a full-page layout with one focused section editor and a searchable picker.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-005.2:** All editing and navigation operations shall work without dragging, hovering, or long pressing. Touch targets shall measure at least 44 pixels.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-005.3:** Phone surfaces shall fit the viewport, contain long-content scrolling, and clear safe areas. Desktop and phone shall share layout data without persisting responsive overflow choices.
+- **AC-UI-SIDEBAR-CUSTOMIZATION-005.4:** Controls shall have accessible names, keyboard operation, visible focus, and predictable focus return. New interface copy shall support every shipped locale.
+
+## Scope boundaries
+
+The first version supports existing registered plugin navigation links, including
+Slack when its installed plugin registers a link. Arbitrary plugin-rendered
+buttons, new Slack behavior, Run automation actions, arbitrary URLs/scripts,
+shared team layouts, and task-list filtering changes are excluded.
+Office-only sections keep their existing composition in this version. Common
+Home, New Task, plugin links, and shortcut groups remain customizable in Office.
+Required inbox entries and the Tasks region remain fixed. Shortcut groups belong
+above that region. The editor identifies these fixed entries.
+
+## Implementation plans
+
+- [Sidebar customization](../../../plans/sidebar-customization/plan.md)
diff --git a/docs/specs/ui/system-design/sidebar-customization.md b/docs/specs/ui/system-design/sidebar-customization.md
new file mode 100644
index 0000000000..157a4226ea
--- /dev/null
+++ b/docs/specs/ui/system-design/sidebar-customization.md
@@ -0,0 +1,262 @@
+---
+status: current
+system: ui
+requirements:
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-001
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-002
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-003
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-004
+ - REQ-UI-SIDEBAR-CUSTOMIZATION-005
+---
+
+# Sidebar Customization Design
+
+## Ownership and existing boundaries
+
+UI owns navigation composition and personal layout. Automation activity,
+canvas lifecycle, plugin registrations, and workspace access keep their owners.
+This extends the portable settings pattern from
+[workspace sidebar task views](workspace-sidebar-task-views.md).
+
+`AppSidebarModeNav` currently assembles fixed sections. `AppSidebarPrimaryNav`
+owns Home and New Task. `TasksSection` owns the remaining scroll space.
+`AppSidebarSettingsMode` temporarily replaces navigation on settings routes.
+`MobileMenuSheet` and `AppNavSections` compose the phone menu separately.
+
+Static links already use `lib/navigation/core-destinations.ts`,
+`resolveDestinations`, and plugin registrations. Reuse these identities and
+availability rules. Do not create another table of hrefs or change global
+catalog order to implement a personal layout.
+
+## Requirement mapping
+
+| Requirement | Design sections |
+| --- | --- |
+| REQ-UI-SIDEBAR-CUSTOMIZATION-001 | Persistence; Composition |
+| REQ-UI-SIDEBAR-CUSTOMIZATION-002 | References and catalog; Composition |
+| REQ-UI-SIDEBAR-CUSTOMIZATION-003 | Activity |
+| REQ-UI-SIDEBAR-CUSTOMIZATION-004 | Persistence; Editor; Recovery |
+| REQ-UI-SIDEBAR-CUSTOMIZATION-005 | Phone; Verification |
+
+## Persistence
+
+Proposed new contracts are marked here; they are not existing symbols.
+Add `SidebarLayoutsByWorkspace` to `internal/user/models.UserSettings`, exposed
+as `sidebar_layouts_by_workspace`. Store entries in existing user-settings JSON,
+using the current settings revision and CAS transaction. No new table is needed.
+
+A proposed `SidebarLayout` contains `version: 1`, `revision`, and ordered `nodes`.
+Each node has a stable client-generated ID and a `visible` boolean:
+
+- `builtin`: a registered layout entry ID, such as `home` or `integrations`.
+- `plugin`: an owner-qualified destination ID for an original plugin nav entry.
+- `shortcuts`: a nonempty display name and ordered shortcut references.
+
+A shortcut has an instance ID and a discriminated target:
+`destination {id}`, `host_action {id}`, `canvas {id}`, or `automation {id}`.
+Names and icons for targets resolve at runtime; they are not copied into settings.
+Section names are user data. Never serialize React nodes, callbacks, tokens,
+provider credentials, resource content, or arbitrary executable strings.
+
+The proposed PATCH object is `sidebar_layout_state` with explicit `workspace_id`,
+`expected_revision`, and a complete `layout`; `layout: null` means reset.
+Omission leaves settings unchanged. Reject whole-map replacement.
+Resolve the authenticated user through the existing user-settings path and
+validate workspace access using the scoped-sidebar service dependency.
+
+Each CAS retry reads fresh settings and changes only the addressed workspace.
+Compare its layout revision; a mismatch returns a conflict without changing it.
+Keep a revision tombstone after reset to reject pre-reset delayed writes.
+Concurrent changes to unrelated settings or another workspace remain intact.
+Validate the full replacement atomically. Reject unsupported schema versions,
+unknown node/target kinds, invalid IDs, and protected built-in nodes.
+Retain syntactically valid unresolved resource and plugin references.
+
+Implementation limits: 20 shortcut groups per workspace, 20 shortcuts per group,
+100 total shortcuts, and 1-60 Unicode code points per trimmed section name.
+Reject duplicate node/instance IDs and duplicate targets within one group.
+The same target may appear in different groups. Mirror validation in the editor;
+the server remains authoritative.
+
+An absent layout resolves to a backend-owned canonical default preserving current
+ordering and visibility. No legacy import is needed: task views and collapse
+preferences are separate. The canonical layout skeleton and version must reach
+the client with complete settings; do not invent defaults in components.
+
+Extend model, DTO, PATCH mapping, store serialization/deserialization, HTTP,
+boot payload, WebSocket projection, and `mapUserSettingsData` in `lib/ssr/user-settings.ts`.
+Expose only accessible workspace entries. Add settings-catalog discovery and
+agent-settings schema coverage through existing domain machinery. Do not create
+a parallel API or MCP-specific preference implementation.
+
+## References and catalog
+
+Create proposed `lib/sidebar/layout-types.ts`, `layout-operations.ts`, and
+`shortcut-catalog.ts`. The catalog adapts existing resolved navigation data,
+not a second navigation manifest. Preserve `pluginDestinationId` ownership:
+`plugin::`.
+
+Include eligible registered main, integrations, and sidebar-footer plugin links.
+Their original positions can be hidden independently of pinned references.
+Unregistered slot components are not selectable. No plugin SDK extension is
+required to pin registered links. GitHub resolves through existing integration
+availability. Slack resolves only through an installed plugin registration.
+
+Resource adapters resolve active workspace canvases and automations by ID.
+Canvas eligibility follows `isActiveWorkspaceCanvas`; use `canvasHref` and
+existing automation history routes. Access or feature gates always take
+precedence over personal visibility. Captured workspace identity accompanies
+picker loads and action callbacks; late results cannot cross workspace scope.
+
+The host action allowlist initially includes New Task, Quick Chat, and Quick
+Terminal through existing launchers. Each invocation remains an explicit click.
+No action transport is persisted. Pinned automations only navigate to history.
+
+Settings holds a searchable catalog grouped by Built-in, Plugins, Canvases,
+and Automations. Availability and resource loading have explicit pending/error
+states. A failed catalog load is not treated as an empty resource collection.
+
+## Composition
+
+Apply layout ordering as a surface-specific projection after destination
+resolution. Leave command palette ordering, feature eligibility, startup page,
+brand links, and settings navigation unchanged. Defaults preserve existing order.
+Newly registered plugin destinations append in their canonical location until
+customized. Retain hidden entries rather than treating omission as hidden.
+Unknown future node kinds must not be written back by an older editor.
+
+Render optional nodes above the fixed Tasks region in regular workspaces.
+Keep Office-only regions in their existing order; apply common customizable
+nodes before them. Required Inbox/Needs you entries retain their existing mode
+and feature gates and cannot be hidden in this editor.
+
+Create proposed `ShortcutSection` with sibling disclosure and action controls.
+Do not nest links/buttons inside the disclosure button. Name and chevron toggle
+only expansion. Header icons activate only their target. The expanded list uses
+the same resolved array, including order, identity, unavailable state, and status.
+Use existing section collapse mechanics with workspace/group-qualified IDs.
+Collapse remains device-local and separate from portable layout revisions.
+
+The header reserves the name/disclosure area and fits as many 28px icon controls
+as remaining width permits. Four icons fit the ordinary expanded desktop width.
+Extra icons enter a More menu; they remain listed when expanded. Overflow never
+shrinks controls below their applicable pointer size. Full names appear on
+hover/focus and in accessible labels. In the 56px rail, render one group launcher
+opening a labelled menu of all shortcuts. These menus use existing responsive
+primitives. Empty groups remain visible as named disclosures with empty content;
+the settings editor supplies Add shortcut. Never add or run anything implicitly.
+
+The agreed icon header and labelled expanded list are fixed interaction choices.
+An optional icon-plus-label header style is deferred: it would crowd this layout.
+
+## Activity
+
+Reuse `automationState(automation, openRuns)` from `automation-rows.ts`.
+The current source returns running, idle, or paused. Running wins when open runs
+exist, even if the automation is disabled. Preserve these meanings; do not map
+paused to failed or invent waiting/error lifecycle states.
+
+Reuse the bubble geometry of `QuickChatActivityIndicator`: an overlaid circular
+marker with a background ring. Extract a presentation-only primitive if needed;
+keep Quick Chat selectors, test IDs, and unread semantics unchanged. Automation
+markers use existing state colors: blue running, green idle, muted paused.
+No new animation is needed. A tooltip and accessible name provide state text;
+expanded rows also show the label. Unknown/loading/error is visually distinct
+from all three domain states and never passes through an idle fallback.
+
+Use one proposed workspace activity controller per mounted navigation surface,
+shared by all shortcut groups and the visible automation section. Adapt existing
+`useWorkspaceAutomations`, `useAutomationSummaries`, and `useLiveRefresh`.
+Do not create one request loop per button. Poll summaries every ten seconds
+while at least one pinned automation is visible, including folded groups and
+rail/overflow launchers. Poll idle pins too, so later starts become visible.
+Refresh immediately on mount, workspace change, reconnect, and return to visible
+navigation. Pause when the document or phone menu is hidden and no consumer needs
+activity. Invalidate/refresh automation definitions for pause changes too.
+
+Keep request generations and workspace identity. Failed refreshes show unknown
+status rather than an authoritative stale running/idle marker; retry at the next
+refresh or through the normal recovery control. Successfully loaded summaries
+with no record may use existing never-run semantics. An unloaded collection may
+not. Aggregate a running bubble on More and collapsed-rail group launchers when
+any shortcut behind them is running. Their accessible text identifies activity;
+opening the menu shows each resource's state.
+
+## Editor
+
+Add an Appearance entry opening `/settings/sidebar` and register it in SPA routes
+and settings discovery. This dedicated page has the workspace name, explanatory
+copy, layout nodes, a draft preview, and Restore defaults. Register one
+workspace-bound contributor with `useSettingsSaveContributor`. Use the existing
+floating Save changes and discard/navigation flows; no page-local save buttons.
+The live sidebar retains saved state while the editor previews drafts locally.
+
+Rows have visibility switches and reorder handles. Shortcut groups additionally
+have a name, ordered contents, Add shortcut, remove, and move-to-group controls.
+Use the repository's existing drag-and-drop dependency and keyboard sensors.
+Explicit Move up/down and Move to group controls provide an alternative.
+Do not implement drag-to-execute or navigation while dragging.
+
+Capture the workspace and base layout revision on edit. A conflicting save
+keeps the draft and offers reload/discard or deliberate reapplication after the
+latest revision is loaded. Do not retry by silently overwriting another client.
+A workspace switch invokes existing dirty-state protection; after discard or
+successful save, rebind the editor. Stale callbacks cannot write the new scope.
+
+## Recovery
+
+Unavailable targets keep their position with a generic icon and localized
+Unavailable label. A disabled menu item explains the missing capability without
+showing cached private resource details. They can be removed or replaced in
+settings. Temporary plugin loading is distinguished from completed registration
+without a matching target. Returning resources re-resolve the saved identity.
+
+Save failures retain draft and authoritative saved layout. Unsupported schema
+versions preserve stored data and offer refresh rather than destructive repair.
+Malformed stored known-version data falls back to defaults for display without
+overwriting the stored entry on read; surface a reset/recovery notice in settings.
+No-workspace state disables editing and never displays a previous workspace's
+resource names. Reset remains an explicit saved operation.
+
+## Phone
+
+Nearest exemplar: `MobileMenuSheet`, with an inset Drawer, fixed header, and one
+internal scroll region. Reuse this navigation surface through `AppNavSections`.
+A phone group uses a full-width disclosure row, followed by a compact icon strip;
+expansion reveals labelled shortcuts beneath it. The extra line preserves 44px
+hit targets and name space. More contains overflow, without horizontal page scroll.
+
+The editor uses direct full-page settings navigation because it is a multi-step
+editing task. Show the layout list, then a focused section editor, then a picker;
+Back returns to the draft without losing changes. Retain the shared save control
+and guard. Use `100dvh`, one active scroll owner, and safe-area padding. Shared
+operations and state power both presentations. No desktop preference is rewritten
+because the phone needs a different composition or overflow count.
+
+## Verification and documentation
+
+Tests cover validation, scoped CAS and revisions, reset, default projection,
+reference resolution, layout operations, fetch readiness, delayed callbacks,
+editor behavior, navigation rendering, and activity states. The dedicated
+desktop and phone sidebar customization E2E files cover the mixed four-shortcut
+editing flow, saved navigation, phone composition, and a pinned automation
+status transition. Existing desktop and phone E2E regression suites continue
+to cover the established automation list, settings navigation, phone access,
+and 44px touch sizing. Broader overflow and plugin lifecycle scenarios remain
+represented by the named unit/component suites rather than unrun browser files.
+
+Implementation updates `docs/public/use-kandev.md` with navigation,
+hide-versus-disable semantics, persistence, and recovery. The public guide and
+coverage manifest are validated with the documentation checks.
+
+## Related decisions
+
+- [Portable settings](../../../decisions/0041-backend-owned-portable-user-settings.md)
+- [Navigation manifest boundaries](../../../decisions/2026-08-04-navigation-manifest-boundaries.md)
+
+This design applies existing ownership rules. The preference model and local
+tradeoffs fit this document; no additional ADR is required.
+
+## Delivery
+
+- [Plan and work orders](../../../plans/sidebar-customization/plan.md)