feat: customize sidebar navigation (#3823)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 ©
|
||||
}
|
||||
|
||||
func withoutSidebarWorkspaceState(settings *models.UserSettings) *models.UserSettings {
|
||||
copy := *settings
|
||||
copy.SidebarViewsByWorkspace = nil
|
||||
copy.SidebarLayoutsByWorkspace = nil
|
||||
return ©
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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ĩĝàţĩōń ēńţŕĩēś"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "没有可见的导航项"
|
||||
}
|
||||
|
||||
@@ -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": "沒有可見的導覽項目"
|
||||
}
|
||||
|
||||
@@ -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": "沒有可見的導覽項目"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user