feat: customize sidebar navigation (#3823)

This commit is contained in:
Carlos Florêncio
2026-09-20 00:09:02 +01:00
committed by GitHub
parent ea73c419c1
commit 9e171c8377
94 changed files with 8632 additions and 115 deletions
@@ -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")
@@ -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),
@@ -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()
@@ -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,
+3
View File
@@ -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),
@@ -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"`
@@ -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)
}
}
@@ -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"},
},
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
@@ -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 &copy
}
func withoutSidebarWorkspaceState(settings *models.UserSettings) *models.UserSettings {
copy := *settings
copy.SidebarViewsByWorkspace = nil
copy.SidebarLayoutsByWorkspace = nil
return &copy
}
@@ -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 {
@@ -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(
<TooltipProvider>
<AppSidebarFooter collapsed={collapsed} onToggleSettingsMode={mocks.toggleSettingsMode} />
<AppSidebarFooter
collapsed={collapsed}
onToggleSettingsMode={mocks.toggleSettingsMode}
layoutManaged={layoutManaged}
/>
</TooltipProvider>,
);
}
@@ -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
@@ -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);
@@ -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 (
<AppSidebarNavItem
icon={IconHome}
label={t("sidebar:home")}
// The same rule the brand link resolves through, so the two "home"
// affordances in this header can never point at different URLs.
href={homeHref}
disabled={mode === "unknown"}
collapsed={collapsed}
exactMatch
/>
);
}
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 (
<div className="flex flex-col gap-0.5">
<AppSidebarNavItem
icon={IconHome}
label={t("sidebar:home")}
// The same rule the brand link resolves through, so the two "home"
// affordances in this header can never point at different URLs.
href={homeHref}
disabled={mode === "unknown"}
collapsed={collapsed}
exactMatch
/>
<>
{inOffice && (
<AppSidebarNavItem
icon={IconInbox}
@@ -81,7 +93,20 @@ export function AppSidebarPrimaryNav({ collapsed }: AppSidebarPrimaryNavProps) {
activity={quickChatActivity}
/>
)}
<AppSidebarNewTaskItem collapsed={collapsed} />
</>
);
}
export function AppSidebarPrimaryNav({
collapsed,
showHome = true,
showNewTask = true,
}: AppSidebarPrimaryNavProps) {
return (
<div className="flex flex-col gap-0.5">
{showHome && <AppSidebarHomeItem collapsed={collapsed} />}
<AppSidebarFixedNav collapsed={collapsed} />
{showNewTask && <AppSidebarNewTaskItem collapsed={collapsed} />}
</div>
);
}
@@ -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;
@@ -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(<AppSidebarWorkspacePicker />);
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(<AppSidebarWorkspacePicker />);
@@ -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],
);
+35 -10
View File
@@ -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 (
<>
<div
@@ -78,18 +81,24 @@ function AppSidebarModeNav({ collapsed, inOffice }: { collapsed: boolean; inOffi
)}
data-testid="app-sidebar-scroll"
>
<AppSidebarPrimaryNav collapsed={collapsed} />
{/* 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 && <AutomationsSection collapsed={collapsed} />}
{!inOffice && <CanvasesSection collapsed={collapsed} />}
<PluginNavItems collapsed={collapsed} />
{hasSavedSidebarLayout ? (
<SidebarLayoutNavigation collapsed={collapsed} inOffice={inOffice} />
) : (
<>
<AppSidebarPrimaryNav collapsed={collapsed} />
{/* 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 && <AutomationsSection collapsed={collapsed} />}
{!inOffice && <CanvasesSection collapsed={collapsed} />}
<PluginNavItems collapsed={collapsed} />
</>
)}
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="work" />}
<ProjectsSection collapsed={collapsed} />
<AgentsSection collapsed={collapsed} />
{inOffice && <OfficeNavigationSection collapsed={collapsed} section="office" />}
{!inOffice && <IntegrationsSection collapsed={collapsed} />}
{!inOffice && !hasSavedSidebarLayout && <IntegrationsSection collapsed={collapsed} />}
</div>
{/* 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 (
<AppSidebarFooter
collapsed={collapsed}
onToggleSettingsMode={onToggleSettingsMode}
layoutManaged={layoutManaged}
/>
);
}
/**
* 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}
/>
<AppSidebarFooter
<AppSidebarFooterSlot
collapsed={visuallyCollapsed}
onToggleSettingsMode={handleToggleSettingsMode}
/>
@@ -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();
@@ -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);
@@ -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<HTMLButtonElement>(null);
@@ -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 (
<AppSidebarSection
@@ -93,7 +102,7 @@ export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) {
}
headerActionVisibility="always"
>
{destinations.map((destination) => (
{visibleDestinations.map((destination) => (
<IntegrationRow
key={destination.id}
href={destination.href}
@@ -0,0 +1,43 @@
"use client";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import type { ShortcutActivity } from "@/hooks/domains/sidebar/use-shortcut-activity";
type ShortcutActivityIndicatorProps = {
activity: ShortcutActivity;
className?: string;
};
const ACTIVITY_LABEL_KEYS = {
running: "automations:stateRunning",
idle: "automations:stateIdle",
paused: "automations:statePaused",
unknown: "common:unknown",
} as const;
export function shortcutActivityLabel(activity: ShortcutActivity, t: (key: string) => string) {
return activity.error
? t("automations:failedToLoadAutomationActivity")
: t(ACTIVITY_LABEL_KEYS[activity.state]);
}
export function ShortcutActivityIndicator({ activity, className }: ShortcutActivityIndicatorProps) {
const { t } = useTranslation();
return (
<span
aria-hidden="true"
className={cn(
"absolute -right-1 -top-1 h-2 w-2 rounded-full ring-2 ring-background",
activity.state === "running" && "bg-blue-500",
activity.state === "idle" && "bg-emerald-500",
activity.state === "paused" && "bg-muted-foreground/60",
activity.state === "unknown" && "bg-muted-foreground/40",
className,
)}
data-state={activity.state}
data-testid="sidebar-shortcut-activity-indicator"
title={shortcutActivityLabel(activity, t)}
/>
);
}
@@ -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 (
<span className="relative flex shrink-0">
<Icon className="h-4 w-4" aria-hidden="true" />
{activity && <ShortcutActivityIndicator activity={activity} />}
</span>
);
}
function activityDescription(
shortcut: ProjectedShortcut,
activity: ShortcutActivity | undefined,
t: (key: string) => string,
descriptionSuffix: string,
) {
if (!activity || shortcut.target.kind !== "automation") return null;
return (
<span id={activityDescriptionId(shortcut, descriptionSuffix)} className="sr-only">
{shortcut.label}: {shortcutActivityLabel(activity, t)}
</span>
);
}
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 ?? <ShortcutIcon shortcut={shortcut} activity={activity} />;
if (!shortcut.available) {
return (
<>
<span
className={baseClass}
aria-label={shortcut.label}
aria-disabled="true"
aria-describedby={describedBy}
data-testid={`sidebar-shortcut-${shortcut.id}`}
>
{content}
{!mobile && <span className="sr-only">{t("common:unavailable")}</span>}
</span>
{description}
</>
);
}
const trigger = shortcut.href ? (
<Link
href={shortcut.href}
onClick={onNavigate}
className={baseClass}
aria-label={shortcut.label}
aria-describedby={describedBy}
data-testid={`sidebar-shortcut-${shortcut.id}`}
>
{content}
{showLabel && <span className="min-w-0 truncate">{shortcut.label}</span>}
</Link>
) : (
<button
type="button"
className={baseClass}
onClick={() => onActivate?.(shortcut)}
aria-label={shortcut.label}
aria-describedby={describedBy}
data-testid={`sidebar-shortcut-${shortcut.id}`}
>
{content}
{showLabel && <span className="min-w-0 truncate">{shortcut.label}</span>}
</button>
);
return (
<>
{mobile ? (
trigger
) : (
<Tooltip>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="right">{shortcut.label}</TooltipContent>
</Tooltip>
)}
{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 (
<div className={cn("flex min-w-0 items-center", mobile ? "gap-1" : "gap-0.5")}>
{inline.map((shortcut) => (
<ShortcutAction
key={shortcut.id}
shortcut={shortcut}
activity={
shortcut.target.kind === "automation" ? activity(shortcut.target.id) : undefined
}
mobile={mobile}
onActivate={onActivate}
onNavigate={onNavigate}
descriptionSuffix="header"
/>
))}
{overflow.length > 0 && (
<ShortcutOverflowMenu
shortcuts={overflow}
activity={activity}
mobile={mobile}
onActivate={onActivate}
onNavigate={onNavigate}
aggregateActivity={
overflowRunning ? { state: "running", loading: false, error: false } : undefined
}
/>
)}
{shortcuts.length === 0 && <span className="sr-only">{t("common:noResults")}</span>}
</div>
);
}
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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className={cn(
"relative cursor-pointer",
mobile ? "size-11" : "h-7 w-7",
triggerClassName,
)}
aria-label={label}
aria-describedby={aggregateActivity ? activityDescriptionId : undefined}
data-testid={triggerTestId ?? "sidebar-shortcut-more"}
>
{triggerContent ?? (
<span className="relative flex">
<IconDots className="h-4 w-4" />
{aggregateActivity && <ShortcutActivityIndicator activity={aggregateActivity} />}
</span>
)}
</Button>
</DropdownMenuTrigger>
{aggregateActivity && (
<span id={activityDescriptionId} className="sr-only">
{shortcutActivityLabel(aggregateActivity, t)}
</span>
)}
<DropdownMenuContent align="start" side={mobile ? "bottom" : "right"}>
{menuLabel && <DropdownMenuLabel>{menuLabel}</DropdownMenuLabel>}
{shortcuts.map((shortcut) => {
const shortcutActivity =
shortcut.target.kind === "automation" ? activity(shortcut.target.id) : undefined;
const Icon = shortcut.icon;
if (shortcut.href && shortcut.available) {
return (
<DropdownMenuItem key={shortcut.id} asChild className="cursor-pointer">
<Link href={shortcut.href} onClick={onNavigate}>
<span className="relative flex shrink-0">
<Icon className="h-4 w-4" />
{shortcutActivity && <ShortcutActivityIndicator activity={shortcutActivity} />}
</span>
<span className="min-w-0 flex-1 truncate">{shortcut.label}</span>
{shortcutActivity && (
<span className="sr-only">{shortcutActivityLabel(shortcutActivity, t)}</span>
)}
</Link>
</DropdownMenuItem>
);
}
return (
<DropdownMenuItem
key={shortcut.id}
disabled={!shortcut.available}
onSelect={() => onActivate?.(shortcut)}
>
<span className="relative flex shrink-0">
<Icon className="h-4 w-4" />
{shortcutActivity && <ShortcutActivityIndicator activity={shortcutActivity} />}
</span>
<span className="min-w-0 flex-1 truncate">{shortcut.label}</span>
{shortcutActivity && (
<span className="sr-only">{shortcutActivityLabel(shortcutActivity, t)}</span>
)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<div className="flex flex-col gap-0.5" data-testid="shortcut-section-rows">
{shortcuts.map((shortcut) => (
<ShortcutAction
key={shortcut.id}
shortcut={shortcut}
activity={
shortcut.target.kind === "automation" ? activity(shortcut.target.id) : undefined
}
mobile={mobile}
className={cn(!mobile && "w-full px-2.5")}
onActivate={onActivate}
onNavigate={onNavigate}
descriptionSuffix="row"
showLabel={mobile}
>
<span className="flex min-w-0 flex-1 items-center gap-2">
<ShortcutIcon
shortcut={shortcut}
activity={
shortcut.target.kind === "automation" ? activity(shortcut.target.id) : undefined
}
/>
<span className="min-w-0 flex-1 truncate">{shortcut.label}</span>
{shortcut.target.kind === "automation" && activity(shortcut.target.id) && (
<span className="shrink-0 text-[11px] text-muted-foreground">
{shortcutActivityLabel(activity(shortcut.target.id)!, t)}
</span>
)}
</span>
</ShortcutAction>
))}
</div>
);
}
@@ -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(
<TooltipProvider>
<ShortcutAction shortcut={node.shortcuts[0]} />
</TooltipProvider>,
);
expect(screen.getByTestId("sidebar-shortcut-github").className).toContain("text-[13px]");
});
it("uses one ordered shortcut collection for header actions and expanded rows", () => {
render(
<ShortcutSection
node={node}
mobile
getActivity={(id) => (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(
<ShortcutSection
node={node}
mobile
getActivity={() => 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(
<ShortcutSection
node={node}
mobile
getActivity={(id) => (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();
@@ -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 (
<Collapsible open={open} onOpenChange={setOpen} className="min-w-0">
<div className="flex min-w-0 flex-col gap-2">
<div className="flex min-h-11 min-w-0 items-center gap-2">
<CollapsibleTrigger asChild>
<button
type="button"
className="min-h-11 min-w-0 flex-1 cursor-pointer truncate rounded-md px-3 text-left text-sm font-medium hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={node.label}
aria-describedby={running ? activityDescriptionId : undefined}
data-testid={`mobile-shortcut-section-toggle-${node.id}`}
>
<span className="flex items-center gap-2">
<node.icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{node.label}</span>
{running && (
<ShortcutActivityIndicator
activity={{ state: "running", loading: false, error: false }}
/>
)}
</span>
</button>
</CollapsibleTrigger>
{running && (
<span id={activityDescriptionId} className="sr-only">
{shortcutActivityLabel({ state: "running", loading: false, error: false }, t)}
</span>
)}
</div>
<div className="min-w-0 overflow-x-auto">
<ShortcutIconStrip
shortcuts={node.shortcuts}
activity={getActivity}
mobile
onActivate={onActivateShortcut}
onNavigate={onNavigate}
/>
</div>
</div>
<CollapsibleContent>
<ShortcutRows
shortcuts={node.shortcuts}
activity={getActivity}
mobile
onActivate={onActivateShortcut}
onNavigate={onNavigate}
/>
</CollapsibleContent>
</Collapsible>
);
}
function DesktopRailShortcutSection({
node,
getActivity,
onActivateShortcut,
onNavigate,
}: ShortcutSectionProps) {
const Icon = node.icon;
const running = shortcutAutomationIds(node.shortcuts).some((id) => isRunning(getActivity(id)));
return (
<ShortcutOverflowMenu
shortcuts={node.shortcuts}
activity={getActivity}
onActivate={onActivateShortcut}
onNavigate={onNavigate}
triggerLabel={node.label}
menuLabel={node.label}
triggerTestId={`sidebar-shortcut-rail-${node.id}`}
triggerClassName="mx-auto h-9 w-9"
triggerContent={
<span className="relative flex">
<Icon className="h-4 w-4" />
{running && (
<ShortcutActivityIndicator
activity={{ state: "running", loading: false, error: false }}
/>
)}
</span>
}
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 (
<MobileShortcutSection
node={node}
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
mobile
/>
);
}
if (collapsed) {
return (
<DesktopRailShortcutSection
node={node}
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
}
return (
<AppSidebarSection
id={`sidebar-shortcuts:${node.id}`}
label={node.label}
collapsed={false}
icon={node.icon}
headerAction={
<ShortcutIconStrip
shortcuts={node.shortcuts}
activity={getActivity}
onActivate={onActivateShortcut}
onNavigate={onNavigate}
/>
}
headerActionVisibility="always"
collapsedSummary={node.shortcuts.length > 0 ? node.shortcuts.length : undefined}
defaultExpanded={false}
>
<ShortcutRows
shortcuts={node.shortcuts}
activity={getActivity}
onActivate={onActivateShortcut}
onNavigate={onNavigate}
/>
{node.shortcuts.length === 0 && (
<span className={cn("px-2.5 py-1.5 text-[13px] text-muted-foreground")}>
{t("common:noResults")}
</span>
)}
</AppSidebarSection>
);
}
@@ -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 (
<AppSidebarNavItem
icon={node.icon}
label={node.label}
href={href}
disabled={!node.destinationId || !href}
collapsed={collapsed}
testId={`plugin-nav-item-${node.destinationId ?? node.id}`}
/>
);
}
function builtinNode(node: ProjectedSidebarNode, collapsed: boolean): React.ReactNode {
switch (node.destinationId) {
case "home":
return <AppSidebarHomeItem collapsed={collapsed} />;
case "new_task":
return <AppSidebarNewTaskItem collapsed={collapsed} />;
case "automations":
return <AutomationsSection collapsed={collapsed} />;
case "canvases":
return <CanvasesSection collapsed={collapsed} />;
case "integrations":
return <IntegrationsSection collapsed={collapsed} includePluginItems={false} />;
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(<AppSidebarFixedNav key="sidebar-fixed-navigation" collapsed={collapsed} />);
fixedRendered = true;
}
if (node.kind === "builtin") {
items.push(<Fragment key={node.id}>{builtinNode(node, collapsed)}</Fragment>);
continue;
}
if (node.kind === "plugin") {
items.push(
<LayoutPluginItem
key={node.id}
node={node}
href={node.destinationId ? destinationHrefs.get(node.destinationId) : undefined}
collapsed={collapsed}
/>,
);
continue;
}
items.push(
<ShortcutSection
key={node.id}
node={node}
collapsed={collapsed}
getActivity={activity.getActivity}
onActivateShortcut={activateShortcut}
/>,
);
}
if (!fixedRendered) {
items.push(<AppSidebarFixedNav key="sidebar-fixed-navigation" collapsed={collapsed} />);
}
if (!hasVisibleNewTask) {
items.push(
<div key="sidebar-hidden-new-task-host" className="hidden" aria-hidden="true">
<AppSidebarNewTaskItem collapsed={collapsed} />
</div>,
);
}
return <div className="flex flex-col gap-1">{items}</div>;
}
function isKanbanOnlyNode(node: ProjectedSidebarNode): boolean {
return (
node.destinationId === "automations" ||
node.destinationId === "canvases" ||
node.destinationId === "integrations"
);
}
@@ -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") && (
<PrimaryNavSection onNavigate={onNavigate} omitDestinations={omitDestinations} />
{hasSavedSidebarLayout ? (
<MobileSidebarLayoutNavigation
onNavigate={onNavigate}
omitSections={omit}
omitDestinations={omitDestinations}
/>
) : (
!omit.has("primary") && (
<PrimaryNavSection onNavigate={onNavigate} omitDestinations={omitDestinations} />
)
)}
{controls.openTaskViews && (
<Button
@@ -151,8 +164,12 @@ export function AppNavSections({
{t("sidebar:taskViews")}
</Button>
)}
{!omit.has("plugins") && <MobilePluginNavSection onNavigate={onNavigate} />}
{!omit.has("integrations") && <MobileIntegrationsSection onNavigate={onNavigate} />}
{!hasSavedSidebarLayout && !omit.has("plugins") && (
<MobilePluginNavSection onNavigate={onNavigate} />
)}
{!hasSavedSidebarLayout && !omit.has("integrations") && (
<MobileIntegrationsSection onNavigate={onNavigate} />
)}
<UtilityNavSection onNavigate={onNavigate} controls={controls} />
</>
);
@@ -199,6 +216,18 @@ function UtilityNavSection({
onNavigate={onNavigate}
className="gap-3 px-3 text-sm"
/>
<Button
asChild
type="button"
variant="outline"
className={utilityRowClass}
data-testid="mobile-customize-sidebar-button"
>
<Link href={SIDEBAR_LAYOUT_TAB_HREF} onClick={onNavigate}>
<IconLayoutList className="h-4 w-4 shrink-0" />
{t("settings:sidebar")}
</Link>
</Button>
{/* #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. */}
@@ -114,6 +114,10 @@ vi.mock("@/components/system-health/health-indicator", () => ({
HealthIssuesDialog: () => <div data-testid="health-dialog" />,
}));
vi.mock("@/components/integrations/integrations-menu", () => ({
MobileIntegrationsSection: () => <div data-testid="mobile-integrations-section" />,
}));
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(<SectionsHost />);
expect(screen.getByTestId("mobile-integrations-section")).not.toBeNull();
});
it("hides the health row while the system is healthy", () => {
render(<SectionsHost />);
@@ -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<string>;
omitDestinations: string[];
};
type MobileLayoutNodeProps = {
node: ProjectedSidebarNode;
homeDestination?: ResolvedDestination;
destinationHrefs: Map<string, string | undefined>;
integrationEntries: ShortcutCatalogEntry[];
canvasEntries: ShortcutCatalogEntry[];
automationEntries: ShortcutCatalogEntry[];
omitSections: Set<string>;
omitDestinations: string[];
getActivity: ShortcutActivityReader;
onActivateShortcut: ShortcutActivation;
onNavigate: () => void;
};
function resourceShortcuts(
node: ProjectedSidebarNode,
entries: ReturnType<typeof useSidebarLayoutNavigation>["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 = (
<>
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate text-left">{node.label}</span>
</>
);
if (!href) {
return (
<Button variant="outline" disabled className="h-11 w-full justify-start gap-3 px-3">
{content}
</Button>
);
}
return (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
>
<Link href={href} onClick={onNavigate} data-testid={`mobile-sidebar-plugin-${node.id}`}>
{content}
</Link>
</Button>
);
}
function MobileNewTaskRow({ onNavigate }: { onNavigate: () => void }) {
const { t } = useTranslation();
return (
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
onClick={() => {
onNavigate();
requestNewTaskCreation();
}}
>
<IconSquarePlus className="h-4 w-4 shrink-0" />
{t("sidebar:newTask")}
</Button>
);
}
function MobileRequiredRows({
onNavigate,
omitSections,
omitDestinations,
}: {
onNavigate: () => void;
omitSections: Set<string>;
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 (
<div className="flex flex-col gap-3" data-testid="mobile-sidebar-fixed-navigation">
{fixedDestinations.length > 0 && (
<DestinationRows
destinations={fixedDestinations}
onNavigate={onNavigate}
className="h-11 gap-3 px-3 text-sm"
/>
)}
{mode === "office" && (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
>
<Link href="/office/inbox" onClick={onNavigate}>
<IconInbox className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">{t("sidebar:inbox")}</span>
{officeInboxCount > 0 && <Badge>{officeInboxCount}</Badge>}
</Link>
</Button>
)}
{needsYouEnabled && workspaceId && (
<Button
asChild
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3"
>
<Link href={NEEDS_YOU_INBOX_HREF} onClick={onNavigate}>
<IconInbox className="h-4 w-4 shrink-0" />
<span className="flex-1 text-left">
{mode === "office" ? t("sidebar:needsYouInbox") : t("sidebar:inbox")}
</span>
{needsYouCount > 0 && <Badge>{`${needsYouCount}${needsYouHasMore ? "+" : ""}`}</Badge>}
</Link>
</Button>
)}
</div>
);
}
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 ? (
<DestinationRows
destinations={[homeDestination]}
onNavigate={onNavigate}
className="h-11 gap-3 px-3 text-sm"
/>
) : null;
case "new_task":
return omitDestinations.includes("new_task") ? null : (
<MobileNewTaskRow onNavigate={onNavigate} />
);
case "automations":
return (
<ShortcutSection
node={resourceShortcuts(node, automationEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
case "canvases":
return (
<ShortcutSection
node={resourceShortcuts(node, canvasEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
case "integrations":
if (omitSections.has("integrations")) return null;
return (
<ShortcutSection
node={resourceShortcuts(node, integrationEntries, omitDestinations)}
mobile
getActivity={getActivity}
onActivateShortcut={onActivateShortcut}
onNavigate={onNavigate}
/>
);
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 (
<MobilePluginRow
node={node}
href={node.destinationId ? destinationHrefs.get(node.destinationId) : undefined}
onNavigate={onNavigate}
/>
);
}
if (node.kind === "shortcuts") {
return (
<ShortcutSection
node={filterNodeShortcuts(node, omitDestinations)}
mobile
getActivity={props.getActivity}
onActivateShortcut={props.onActivateShortcut}
onNavigate={onNavigate}
/>
);
}
return <MobileBuiltinNode {...props} />;
}
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 (
<div className="flex min-w-0 flex-col gap-3" data-testid="mobile-sidebar-layout-navigation">
{visibleNodes.map((node) => (
<MobileLayoutNode
key={node.id}
node={node}
homeDestination={homeDestination}
destinationHrefs={destinationHrefs}
integrationEntries={integrationEntries}
canvasEntries={canvasEntries}
automationEntries={automationEntries}
omitSections={omitSections}
omitDestinations={omitDestinations}
getActivity={activity.getActivity}
onActivateShortcut={activateShortcut}
onNavigate={onNavigate}
/>
))}
<MobileRequiredRows
onNavigate={onNavigate}
omitSections={omitSections}
omitDestinations={omitDestinations}
/>
</div>
);
}
@@ -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"));
@@ -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<LoadedAutomations>(NOTHING_LOADED);
const [loading, setLoading] = useState(Boolean(workspaceId));
const [error, setError] = useState<string | null>(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();
@@ -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 }) => (
<div {...props}>{children}</div>
),
SettingsTarget: ({
children,
targetId: _targetId,
...props
}: {
children: React.ReactNode;
targetId?: string;
}) => <div {...props}>{children}</div>,
}));
vi.mock("./layout-editor", () => ({
LayoutEditor: () => <div data-testid="layout-editor" />,
}));
vi.mock("../sidebar-layout-editor", () => ({
SidebarLayoutEditor: ({ embedded }: { embedded?: boolean }) => (
<div data-testid="sidebar-layout-editor-tab" data-embedded={embedded ? "true" : "false"} />
),
}));
vi.mock("./layout-profile-list", () => ({
LayoutProfileList: () => <div data-testid="layout-profile-list" />,
}));
@@ -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(<LayoutSettings />);
expect(screen.getByRole("tab", { name: "settings:layoutProfiles" })).toBeTruthy();
expect(screen.getByRole("tab", { name: "settings:sidebar" })).toBeTruthy();
cleanup();
mocks.layoutTab = "sidebar";
render(<LayoutSettings />);
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(<LayoutSettings />);
@@ -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 (
<>
<div className="min-w-0">
<h2 className="flex items-center gap-2 text-2xl font-bold">
<IconLayoutDashboard className="h-5 w-5" />
{t("settings:layouts")}
</h2>
<p className="mt-1 text-sm text-muted-foreground">
{t("settings:configureTheInitialDesktopTaskWorkbench")}
</p>
</div>
<SettingsPageHeader
title={
<span className="flex items-center gap-2">
<IconLayoutDashboard className="h-5 w-5" />
{t("settings:layouts")}
</span>
}
description={t("settings:configureTheInitialDesktopTaskWorkbench")}
tabs={tabs}
/>
<Separator />
</>
);
@@ -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<HTMLButtonElement | null>;
requestDelete: () => void;
closeDelete: () => void;
confirmDelete: () => void;
}) {
const { t } = useTranslation();
const controller = useLayoutSettings();
const { isFinePointer } = useResponsiveBreakpoint();
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const [deleteProfileId, setDeleteProfileId] = useState<string | null>(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 (
<SettingsTarget
targetId={GENERAL_SETTINGS_TARGETS.layoutProfiles}
className="min-w-0 space-y-6"
data-testid="layout-settings"
>
<LayoutSettingsHeader />
{controller.error && (
<Alert variant="destructive">
<IconAlertTriangle className="h-4 w-4" />
@@ -228,3 +222,66 @@ export function LayoutSettings() {
</SettingsTarget>
);
}
export function LayoutSettings() {
const { t } = useTranslation();
const controller = useLayoutSettings();
const { isFinePointer } = useResponsiveBreakpoint();
const deleteAnchorRef = useRef<HTMLButtonElement>(null);
const [deleteProfileId, setDeleteProfileId] = useState<string | null>(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 (
<SettingsTabs tabs={tabs} value={value} onValueChange={selectTab}>
<div className="min-w-0 space-y-6" data-testid="layout-settings">
<LayoutSettingsHeader tabs={<SettingsTabsList ariaLabel={t("settings:layouts")} />} />
<SettingsTabsPanel value="profiles">
<LayoutProfilesPanel
controller={controller}
isFinePointer={isFinePointer}
deleteOpen={deleteOpen}
deleteAnchorRef={deleteAnchorRef}
requestDelete={requestDelete}
closeDelete={closeDelete}
confirmDelete={confirmDelete}
/>
</SettingsTabsPanel>
<SettingsTabsPanel value="sidebar" testId="settings-layouts-sidebar">
<SidebarLayoutEditor embedded />
</SettingsTabsPanel>
</div>
</SettingsTabs>
);
}
@@ -76,6 +76,7 @@ const SEGMENT_LABEL_KEYS: Record<string, string> = {
repositories: "sidebar:repositories",
secrets: "settings:secrets",
security: "settings:security",
sidebar: "settings:sidebar",
status: "common:status",
storage: "system:storageTitle",
system: "common:system",
@@ -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" },
);
});
});
@@ -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);
}
@@ -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, unknown>) => string;
}) {
return (
<div className="space-y-2">
{node.shortcuts.map((shortcut, index) => {
const Icon = shortcut.icon;
return (
<div
key={shortcut.id}
className="flex min-h-11 items-center gap-2 rounded-md border px-3"
>
<Icon className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">{shortcut.label}</span>
{!readOnly && (
<ShortcutSectionMoveMenu
shortcut={shortcut}
currentNodeId={node.id}
sections={sections}
onMove={(destinationNodeId) =>
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}
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
onClick={() =>
onApply((current) =>
moveShortcut(current, shortcut.id, node.id, node.id, index - 1),
)
}
disabled={readOnly || index === 0}
aria-label={t("settings:moveUp")}
>
<IconChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
onClick={() =>
onApply((current) =>
moveShortcut(current, shortcut.id, node.id, node.id, index + 1),
)
}
disabled={readOnly || index === node.shortcuts.length - 1}
aria-label={t("settings:moveDown")}
>
<IconChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-11"
onClick={() => onApply((current) => removeShortcut(current, node.id, shortcut.id))}
disabled={readOnly}
aria-label={t("settings:remove")}
>
<IconTrash className="h-4 w-4" />
</Button>
</div>
);
})}
</div>
);
}
function UnsupportedLayoutNotice({
onLoadLatest,
latestLoading,
t,
}: {
onLoadLatest: () => void;
latestLoading: boolean;
t: (key: string) => string;
}) {
return (
<div className="flex flex-wrap items-center gap-2" role="alert">
<p className="text-sm text-destructive">{t("settings:sidebarUnsupportedVersion")}</p>
<Button type="button" variant="outline" onClick={onLoadLatest} disabled={latestLoading}>
{latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
</Button>
</div>
);
}
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 (
<div
className="flex min-h-[100dvh] min-w-0 flex-col gap-4 pb-[calc(6rem+env(safe-area-inset-bottom))]"
data-testid="sidebar-layout-focused-group"
>
{readOnly && (
<UnsupportedLayoutNotice onLoadLatest={onLoadLatest} latestLoading={latestLoading} t={t} />
)}
<Button type="button" variant="ghost" className="min-h-11 w-fit" onClick={onBack}>
<IconArrowLeft className="mr-2 h-4 w-4" />
{t("common:back")}
</Button>
<SettingsPageHeader title={node.name ?? ""} />
<Input
aria-label={t("settings:sectionName")}
value={node.name ?? ""}
onChange={(event) => onSetNameDraft(node.id, event.target.value)}
onBlur={() => onCommitName(node.id, node.name ?? "")}
disabled={readOnly}
className="min-h-11"
/>
<FocusedShortcutRows
node={node}
sections={sections}
readOnly={readOnly}
onApply={onApply}
t={t}
/>
<Button
type="button"
variant="outline"
className="min-h-11 w-full"
onClick={onOpenPicker}
disabled={readOnly}
>
<IconPlus className="mr-2 h-4 w-4" />
{t("settings:addShortcut")}
</Button>
{pickerOpen && !readOnly && (
<SidebarShortcutPicker
catalog={catalog}
loading={loading}
error={error}
canvasError={canvasError}
query={query}
onQueryChange={onQueryChange}
onAdd={onAdd}
onClose={onClosePicker}
existing={node.shortcuts.map(
(shortcut) => `${shortcut.target.kind}:${shortcut.target.id}`,
)}
t={t}
/>
)}
</div>
);
}
@@ -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<string, string> = {
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<HTMLSpanElement> {
return { ...attributes, ...(listeners ?? {}) } as HTMLAttributes<HTMLSpanElement>;
}
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, unknown>) => 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 (
<div
ref={sortable.setNodeRef}
style={style}
className="min-w-0 rounded-md border p-3"
data-testid={`sidebar-layout-node-${node.id}`}
>
<SidebarLayoutNodeHeader
{...props}
dragHandleProps={handleProps(sortable.attributes, sortable.listeners)}
/>
<SidebarLayoutShortcutList {...props} />
</div>
);
}
function SidebarLayoutNodeHeader({
node,
projected,
index,
total,
onToggle,
onMove,
onRemove,
onSetNameDraft,
onCommitName,
dragHandleProps,
readOnly,
t,
}: NodeEditorProps & { dragHandleProps: HTMLAttributes<HTMLSpanElement> }) {
const isGroup = node.kind === "shortcuts";
return (
<div className="flex min-w-0 items-center gap-2">
<span
{...dragHandleProps}
className="flex h-7 w-7 shrink-0 cursor-grab items-center justify-center text-muted-foreground active:cursor-grabbing max-md:h-11 max-md:w-11 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
data-testid={`sidebar-layout-drag-handle-${node.id}`}
aria-label={t("settings:dragToReorder")}
>
<IconGripVertical className="h-4 w-4" aria-hidden="true" />
</span>
{isGroup ? (
<Input
aria-label={t(SECTION_NAME_LABEL_KEY)}
value={node.name ?? ""}
onChange={(event) => 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"
/>
) : (
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{nodeTitle(node, projected, t)}
</span>
)}
<Switch
checked={node.visible}
onCheckedChange={onToggle}
disabled={readOnly}
aria-label={t("settings:sidebarToggleVisibility")}
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMove(Math.max(0, index - 1))}
disabled={readOnly || index === 0}
aria-label={t("settings:moveUp")}
>
<IconChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMove(Math.min(total - 1, index + 1))}
disabled={readOnly || index === total - 1}
aria-label={t("settings:moveDown")}
>
<IconChevronDown className="h-4 w-4" />
</Button>
{isGroup && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={onRemove}
disabled={readOnly}
aria-label={t("settings:remove")}
>
<IconTrash className="h-4 w-4" />
</Button>
)}
</div>
);
}
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 (
<div ref={setNodeRef} className="mt-3 space-y-2 pl-6">
<SortableContext
items={shortcuts.map((shortcut) => shortcutDragId(node.id, shortcut.id))}
strategy={verticalListSortingStrategy}
>
{shortcuts.map((shortcut, shortcutIndex) => (
<SortableSidebarShortcut
key={shortcut.id}
nodeId={node.id}
shortcut={shortcut}
shortcutIndex={shortcutIndex}
shortcutCount={shortcuts.length}
sections={sections}
readOnly={readOnly}
t={t}
onMoveShortcut={onMoveShortcut}
onRemoveShortcut={onRemoveShortcut}
onMoveShortcutToSection={onMoveShortcutToSection}
/>
))}
</SortableContext>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={onOpenPicker}
disabled={readOnly}
>
<IconPlus className="mr-2 h-4 w-4" />
{t("settings:addShortcut")}
</Button>
<Button
type="button"
variant="ghost"
className="min-h-11 md:hidden"
onClick={onFocus}
disabled={readOnly}
>
{t("settings:editSection")}
</Button>
</div>
{pickerOpen && !readOnly && (
<SidebarShortcutPicker
catalog={catalog}
loading={loading}
error={catalogError}
canvasError={canvasError}
query={query}
onQueryChange={onQueryChange}
onAdd={onAdd}
onClose={onClosePicker}
existing={shortcuts.map((shortcut) => `${shortcut.target.kind}:${shortcut.target.id}`)}
t={t}
/>
)}
</div>
);
}
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, unknown>) => 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 (
<div
ref={sortable.setNodeRef}
style={{
transform: CSS.Transform.toString(sortable.transform),
transition: sortable.transition,
}}
className="flex min-w-0 items-center gap-2 rounded-md bg-muted/30 p-2"
>
<span
{...handleProps(sortable.attributes, sortable.listeners)}
className="flex h-7 w-5 shrink-0 cursor-grab items-center justify-center text-muted-foreground active:cursor-grabbing max-md:h-11 max-md:w-11 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11"
aria-label={t("settings:dragToReorder")}
>
<IconGripVertical className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="min-w-0 flex-1 truncate text-sm">{shortcut.label}</span>
{!shortcut.available && (
<span className="text-xs text-muted-foreground">{t("common:unavailable")}</span>
)}
{!readOnly && (
<ShortcutSectionMoveMenu
shortcut={shortcut}
currentNodeId={nodeId}
sections={sections}
onMove={(destinationNodeId) => onMoveShortcutToSection(shortcut.id, destinationNodeId)}
t={t}
/>
)}
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMoveShortcut(shortcut.id, Math.max(0, shortcutIndex - 1))}
disabled={readOnly || shortcutIndex === 0}
aria-label={t("settings:moveUp")}
>
<IconChevronUp className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onMoveShortcut(shortcut.id, Math.min(shortcutCount - 1, shortcutIndex + 1))}
disabled={readOnly || shortcutIndex === shortcutCount - 1}
aria-label={t("settings:moveDown")}
>
<IconChevronDown className="h-4 w-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={() => onRemoveShortcut(shortcut.id)}
disabled={readOnly}
aria-label={t("settings:remove")}
>
<IconX className="h-4 w-4" />
</Button>
</div>
);
}
@@ -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 && (
<div className="flex flex-wrap items-center gap-2" role="alert">
<p className="text-sm text-destructive">{t("settings:sidebarUnsupportedVersion")}</p>
<Button
type="button"
variant="outline"
className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={onLoadLatest}
disabled={latestLoading}
>
{latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
</Button>
</div>
)}
{!validation.valid && (
<p className="text-sm text-destructive" role="alert">
{validationMessage}
</p>
)}
{operationError && (
<p className="text-sm text-destructive" role="alert">
{operationError}
</p>
)}
{saveError && (
<div className="flex flex-wrap items-center gap-2" role="alert">
<p className="text-sm text-destructive">
{t(saveError === "conflict" ? "settings:sidebarConflict" : "settings:sidebarSaveError")}
</p>
{saveError === "conflict" && (
<Button
type="button"
variant="outline"
className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={onLoadLatest}
disabled={latestLoading}
>
{latestLoading ? t("common:loading") : t("settings:sidebarLoadLatest")}
</Button>
)}
</div>
)}
</>
);
}
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, unknown>) => 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, unknown>) => string;
}) {
return (
<div className="flex flex-col gap-2 rounded-md border border-dashed p-3 sm:flex-row">
<Input
aria-label={t(SECTION_NAME_LABEL_KEY)}
value={name}
onChange={(event) => 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"
/>
<Button
type="button"
variant="outline"
className="min-h-7 shrink-0 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={() => {
if (onApply((current) => createShortcutSection(current, name))) setName("");
}}
disabled={readOnly || !name.trim()}
>
<IconPlus className="mr-2 h-4 w-4" />
{t("settings:addShortcutSection")}
</Button>
</div>
);
}
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) => (
<SidebarLayoutNodeEditor
key={node.id}
node={node}
index={index}
total={draft.nodes.length}
projected={projected.nodes.find((item) => 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 (
<Card>
<CardHeader>
<CardTitle>{t("settings:sidebarLayout")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<DndContext
sensors={readOnly ? [] : sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => {
if (!readOnly) handleSidebarLayoutDragEnd(event, draft, onApply);
}}
>
<SortableContext
items={draft.nodes.map((node) => nodeDragId(node.id))}
strategy={verticalListSortingStrategy}
>
<SidebarLayoutNodeList
draft={draft}
projected={projected}
catalog={catalog}
catalogLoading={catalogLoading}
catalogError={catalogError}
canvasError={canvasError}
readOnly={readOnly}
pickerNodeId={pickerNodeId}
pickerQuery={pickerQuery}
setPickerNodeId={setPickerNodeId}
setPickerQuery={setPickerQuery}
onApply={onApply}
onSetNameDraft={onSetNameDraft}
onCommitName={onCommitName}
onSetFocusedNodeId={onSetFocusedNodeId}
t={t}
/>
</SortableContext>
</DndContext>
<NewShortcutSectionForm
readOnly={readOnly}
name={newSectionName}
setName={setNewSectionName}
onApply={onApply}
t={t}
/>
<SidebarLayoutFeedback
readOnly={readOnly}
validation={validation}
validationMessage={validationMessage}
operationError={operationError}
saveError={saveError}
latestLoading={latestLoading}
onLoadLatest={onLoadLatest}
t={t}
/>
</CardContent>
</Card>
);
}
@@ -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 (
<Card data-testid="sidebar-layout-preview">
<CardHeader>
<CardTitle>{t("settings:sidebarPreview")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{nodes.map((node) => (
<div key={node.id} className="rounded-md border p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<node.icon className="h-4 w-4" />
<span className="truncate">{node.label}</span>
{node.kind === "shortcuts" && (
<span className="text-xs text-muted-foreground">{node.shortcuts.length}</span>
)}
</div>
{node.kind === "shortcuts" && node.shortcuts.length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{node.shortcuts.map((shortcut) => {
const Icon = shortcut.icon;
return (
<Icon
key={shortcut.id}
className="h-4 w-4 text-muted-foreground"
aria-label={shortcut.label}
/>
);
})}
</div>
)}
</div>
))}
{nodes.length === 0 && (
<p className="text-sm text-muted-foreground">{t("settings:sidebarPreviewEmpty")}</p>
)}
</CardContent>
</Card>
);
}
@@ -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<string, number>() };
const requestGenerationsRef = { current: new Map<string, number>() };
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<string, number>() };
const requestGenerationsRef = { current: new Map<string, number>() };
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);
});
});
@@ -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<SidebarLayout>;
savedRef: MutableRefObject<SidebarLayout>;
workspaceRef: MutableRefObject<string | null>;
generationsRef: MutableRefObject<Map<string, number>>;
requestGenerationsRef: MutableRefObject<Map<string, number>>;
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<SidebarLayout>;
workspaceRef: MutableRefObject<string | null>;
draftRef: MutableRefObject<SidebarLayout>;
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<SidebarLayout>;
savedRef: MutableRefObject<SidebarLayout>;
setDraft: (next: SidebarLayout) => void;
workspaceRef: MutableRefObject<string | null>;
generationsRef: MutableRefObject<Map<string, number>>;
requestGenerationsRef: MutableRefObject<Map<string, number>>;
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<SidebarLayout>;
savedRef: MutableRefObject<SidebarLayout>;
workspaceRef: MutableRefObject<string | null>;
generationsRef: MutableRefObject<Map<string, number>>;
requestGenerationsRef: MutableRefObject<Map<string, number>>;
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 };
}
@@ -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<string, string> = {
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, unknown>) => string;
}) {
if (sections.length <= 1) return null;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
aria-label={t("settings:moveShortcutToSection", { label: shortcut.label })}
>
<IconArrowsExchange className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>{t("settings:moveToSection")}</DropdownMenuLabel>
{sections
.filter((section) => section.id !== currentNodeId)
.map((section) => (
<DropdownMenuItem key={section.id} onSelect={() => onMove(section.id)}>
{nodeTitle(section, t)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
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 (
<div
className="space-y-2 rounded-md border bg-background p-3"
role="dialog"
aria-label={t("settings:addShortcut")}
>
<div className="flex items-center gap-2">
<Input
value={query}
onChange={(event) => 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"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 max-md:size-11 [@media(pointer:coarse)]:size-11"
onClick={onClose}
aria-label={t("common:close")}
>
<IconX className="h-4 w-4" />
</Button>
</div>
{loading && <p className="text-sm text-muted-foreground">{t("common:loading")}</p>}
{(error || canvasError) && (
<p className="text-sm text-destructive">{t("settings:sidebarShortcutLoadError")}</p>
)}
{!loading && !error && entries.length === 0 && (
<p className="text-sm text-muted-foreground">{t("settings:noMatchingShortcuts")}</p>
)}
{!error && entries.length > 0 && (
<div className="grid gap-1">
{entries.map((entry) => {
const Icon = entry.icon ?? unavailableShortcutIcon;
return (
<Button
key={`${entry.target.kind}:${entry.target.id}`}
type="button"
variant="ghost"
className="min-h-7 justify-start gap-2 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={() => onAdd(entry)}
>
<Icon className="h-4 w-4 shrink-0" />
<span className="truncate">{entry.label}</span>
</Button>
);
})}
</div>
)}
</div>
);
}
@@ -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<string, Parameters<typeof fromApiSidebarLayout>[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<Record<string, ScopedDraft>>({});
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<string | null>(null);
const draftRef = useRef(draft);
const workspaceRef = useRef(workspaceId);
const generationsRef = useRef(new Map<string, number>());
const requestGenerationsRef = useRef(new Map<string, number>());
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,
};
}
@@ -0,0 +1,3 @@
import type { SidebarLayout } from "@/lib/sidebar/layout-types";
export type DraftOperation = (layout: SidebarLayout) => SidebarLayout;
@@ -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, unknown>) => 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 (
<div className="min-w-0 space-y-6" data-testid="sidebar-layout-editor" data-mobile={isMobile}>
{showHeader && (
<>
<SettingsPageHeader
title={t(SIDEBAR_LABEL_KEY)}
description={t("settings:sidebarDescription")}
/>
<Separator />
</>
)}
<div className="grid min-w-0 gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(18rem,0.8fr)]">
<SidebarLayoutNodesCard
draft={draft}
readOnly={readOnly}
projected={projected}
catalog={catalog}
catalogLoading={catalogLoading}
catalogError={catalogError}
canvasError={canvasError}
newSectionName={newSectionName}
setNewSectionName={setNewSectionName}
pickerNodeId={pickerNodeId}
setPickerNodeId={setPickerNodeId}
pickerQuery={pickerQuery}
setPickerQuery={setPickerQuery}
validation={validation}
validationMessage={validationMessage}
operationError={operationError}
saveError={saveError}
latestLoading={latestLoading}
onLoadLatest={onLoadLatest}
onApply={onApply}
onSetNameDraft={onSetNameDraft}
onCommitName={onCommitName}
onSetFocusedNodeId={onSetFocusedNodeId}
t={t}
/>
<SidebarDraftPreview nodes={visibleNodes} t={t} />
</div>
<Button
type="button"
variant="outline"
className="min-h-7 max-md:min-h-11 [@media(pointer:coarse)]:min-h-11"
onClick={onReset}
disabled={readOnly}
>
<IconRefresh className="mr-2 h-4 w-4" />
{t("settings:sidebarRestoreDefaults")}
</Button>
</div>
);
}
export { FocusedSidebarGroup };
@@ -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) => (
<button
{...props}
type="button"
role="switch"
aria-checked={checked}
onClick={() => 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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
mocks.state.workspaces.activeId = "workspace-1";
view.rerender(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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(
<SettingsSaveProvider>
<SidebarLayoutEditor />
</SettingsSaveProvider>,
);
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: {} } });
});
});
@@ -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<string, string> = {
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<typeof validateSidebarLayout>;
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, unknown>) => 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 (
<FocusedSidebarGroup
node={activeFocusedNode}
draft={draft}
readOnly={unsupportedVersion}
catalog={catalog}
loading={catalogLoading}
error={catalogError}
canvasError={canvasError}
query={pickerQuery}
onQueryChange={setPickerQuery}
onBack={() => 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 (
<SidebarLayoutContent
draft={draft}
readOnly={unsupportedVersion}
projected={projected}
visibleNodes={visibleNodes}
catalog={catalog}
catalogLoading={catalogLoading}
catalogError={catalogError}
canvasError={canvasError}
newSectionName={newSectionName}
setNewSectionName={setNewSectionName}
pickerNodeId={pickerNodeId}
setPickerNodeId={setPickerNodeId}
pickerQuery={pickerQuery}
setPickerQuery={setPickerQuery}
validation={validation}
validationMessage={validation.valid ? null : validationCopy(validation.errors[0] ?? "", t)}
operationError={operationError ? validationCopy(operationError, t) : null}
saveError={saveError}
latestLoading={latestLoading}
onLoadLatest={onLoadLatest}
onApply={onApply}
onSetNameDraft={onSetNameDraft}
onCommitName={onCommitName}
onReset={onReset}
onSetFocusedNodeId={onSetFocusedNodeId}
t={t}
isMobile={isMobile}
showHeader={showHeader}
/>
);
}
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<string | null>(null);
const [focusedNodeId, setFocusedNodeId] = useState<string | null>(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 <p className="text-sm text-muted-foreground">{t("settings:sidebarNoWorkspace")}</p>;
}
return (
<SidebarLayoutEditorSurface
draft={draftState.draft}
unsupportedVersion={Boolean(draftState.draft.unsupportedVersion)}
projected={projected}
catalog={catalogState.catalog}
catalogLoading={catalogState.loading}
catalogError={catalogState.error}
canvasError={catalogState.canvasError}
validation={validation}
saveError={save.saveError}
latestLoading={save.latestLoading}
operationError={actions.operationError}
isMobile={isMobile}
newSectionName={newSectionName}
setNewSectionName={setNewSectionName}
pickerNodeId={pickerNodeId}
focusedNodeId={focusedNodeId}
setPickerNodeId={setPickerNodeId}
pickerQuery={pickerQuery}
setPickerQuery={setPickerQuery}
onApply={applyDraftOperation}
onSetNameDraft={actions.setNameDraft}
onCommitName={onCommitName}
onLoadLatest={save.loadLatest}
onReset={onReset}
onSetFocusedNodeId={setFocusedNodeId}
t={t}
showHeader={!embedded}
/>
);
}
function useRefValue(value: SidebarLayout) {
const ref = useMemo(() => ({ current: value }), []);
ref.current = value;
return ref;
}
+6
View File
@@ -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<string, { revision: number; [key: string]: unknown }>;
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;
@@ -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<void> {
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<void> {
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);
});
});
@@ -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<number> {
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 });
});
});
@@ -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");
});
});
@@ -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<string, ShortcutActivity>();
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 };
}
@@ -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<string, string> = {
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<string>();
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,
};
}
@@ -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();
});
});
@@ -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<CanvasState>(() =>
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<ShortcutCatalogEntry[]>(
() =>
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();
},
};
}
@@ -83,6 +83,7 @@ function makeUnloadedSettings(): UserSettingsState {
savedLayouts: [],
sidebarViews: [],
sidebarViewsByWorkspace: {},
sidebarLayoutsByWorkspace: {},
sidebarActiveViewId: null,
sidebarDraft: null,
threadViews: [],
@@ -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) =>
@@ -44,6 +44,7 @@ export const SETTINGS_DISCOVERY_ROUTE_EXCLUSIONS: Record<string, string> = {
"/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,
@@ -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",
@@ -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",
@@ -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",
@@ -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"]);
});
});
+329
View File
@@ -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<string>();
const targets = new Set<string>();
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<string>): 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<string>();
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;
}
@@ -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,
});
});
});
+193
View File
@@ -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<ShortcutCatalogEntry["source"]>;
available: boolean;
};
export type ProjectedSidebarNode = Omit<SidebarLayoutNode, "shortcuts"> & {
label: string;
icon: DestinationIcon;
shortcuts: ProjectedShortcut[];
available?: boolean;
};
export type SidebarLayoutProjection = {
nodes: ProjectedSidebarNode[];
protectedNodeIds: string[];
};
type ProjectionOptions = {
unavailableLabel?: string;
builtinLabels?: Record<string, string>;
};
const BUILTIN_ICONS: Record<string, DestinationIcon> = {
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<string, ShortcutCatalogEntry>,
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<string, ShortcutCatalogEntry>,
unavailableLabel: string,
builtinLabels: Record<string, string>,
): Pick<ProjectedSidebarNode, "label" | "icon" | "available"> {
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<string, ShortcutCatalogEntry>,
unavailableLabel: string,
builtinLabels: Record<string, string>,
): 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;
}
+144
View File
@@ -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 },
})),
}
: {}),
})),
};
}
@@ -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([]);
});
});
+124
View File
@@ -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<Automation, "id" | "workspace_id" | "name"> & Partial<Automation>;
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<ShortcutCatalogEntry, "target">): string {
return `${entry.target.kind}:${entry.target.id}`;
}
export const unavailableShortcutIcon = IconPuzzle;
+16
View File
@@ -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<string, unknown>;
expect(result.sidebarLayoutsByWorkspace).toEqual({
"workspace-a": { version: 1, revision: 3, nodes: [] },
});
});
});
describe("startup page user settings", () => {
+2
View File
@@ -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[],
@@ -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<string, SidebarWorkspaceStateApi>;
sidebarLayoutsByWorkspace: Record<string, SidebarLayoutApi>;
sidebarActiveViewId: string | null;
sidebarDraft: SidebarViewDraft | null;
threadViews: ThreadView[];
+32
View File
@@ -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<string, SidebarWorkspaceStateApi>;
sidebar_layouts_by_workspace?: Record<string, SidebarLayoutApi>;
sidebar_views?: SidebarViewApi[];
sidebar_active_view_id?: string;
sidebar_draft?: SidebarViewDraftApi | null;
@@ -266,6 +293,11 @@ export type UserSettingsUpdatePayload = {
lsp_server_configs?: Record<string, Record<string, unknown>>;
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[];
+29 -1
View File
@@ -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"
}
+29 -1
View File
@@ -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ĩĝàţĩōń ēńţŕĩēś"
}
+29 -1
View File
@@ -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"
}
+29 -1
View File
@@ -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": "没有可见的导航项"
}
+29 -1
View File
@@ -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": "沒有可見的導覽項目"
}
+29 -1
View File
@@ -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": "沒有可見的導覽項目"
}
+8
View File
@@ -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;
+2
View File
@@ -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<string, RouteRenderer> = {
"/settings/preferences/notifications": () => <NotificationsSettings />,
"/settings/preferences/task-behavior": () => <TaskBehaviorSettings />,
"/settings/preferences/terminal-editors": () => <TerminalEditorsSettings />,
"/settings/sidebar": () => <SettingsRedirect to={SIDEBAR_LAYOUT_TAB_HREF} />,
// Legacy /settings/general paths, one redirect per page that lived there.
"/settings/general": () => <SettingsRedirect to={APPEARANCE_SETTINGS_HREF} />,
"/settings/general/appearance": () => <SettingsRedirect to={APPEARANCE_SETTINGS_HREF} />,
+322
View File
@@ -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.
@@ -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`.
@@ -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`.
@@ -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.
@@ -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.
@@ -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.
+18 -2
View File
@@ -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",
+17
View File
@@ -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
@@ -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)
@@ -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:<encoded plugin ID>:<encoded item ID>`.
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)