diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 700a00224a..fbf93da9c7 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -61,6 +61,15 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS LspServerConfigs: req.LspServerConfigs, SavedLayouts: req.SavedLayouts, SidebarViews: req.SidebarViews, + SidebarActiveViewID: req.SidebarActiveViewID, + SidebarDraft: req.SidebarDraft.ServiceValue(), + SidebarTaskPrefs: req.SidebarTaskPrefs, + TaskCreateLastUsed: req.TaskCreateLastUsed, + JiraSavedViews: req.JiraSavedViews.ServiceValue(), + JiraTaskPresets: req.JiraTaskPresets.ServiceValue(), + GitHubSavedPresets: req.GitHubSavedPresets.ServiceValue(), + GitHubDefaultQueryPresets: req.GitHubDefaultQueryPresets.ServiceValue(), + GitLabSavedPresets: req.GitLabSavedPresets.ServiceValue(), DefaultUtilityAgentID: req.DefaultUtilityAgentID, DefaultUtilityModel: req.DefaultUtilityModel, KeyboardShortcuts: req.KeyboardShortcuts, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index f0cac76e25..baaa3ad816 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -1,6 +1,7 @@ package dto import ( + "encoding/json" "time" "github.com/kandev/kandev/internal/user/models" @@ -32,6 +33,15 @@ type UserSettingsDTO struct { LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` SidebarViews []models.SidebarView `json:"sidebar_views"` + SidebarActiveViewID string `json:"sidebar_active_view_id"` + SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views,omitempty"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets,omitempty"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets,omitempty"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets,omitempty"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets,omitempty"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -77,6 +87,15 @@ type UpdateUserSettingsRequest struct { LspServerConfigs *map[string]map[string]interface{} `json:"lsp_server_configs,omitempty"` SavedLayouts *[]models.SavedLayout `json:"saved_layouts,omitempty"` SidebarViews *[]models.SidebarView `json:"sidebar_views,omitempty"` + SidebarActiveViewID *string `json:"sidebar_active_view_id,omitempty"` + SidebarDraft NullableSidebarDraft `json:"sidebar_draft,omitempty"` + SidebarTaskPrefs *models.SidebarTaskPrefs `json:"sidebar_task_prefs,omitempty"` + TaskCreateLastUsed *models.TaskCreateLastUsed `json:"task_create_last_used,omitempty"` + JiraSavedViews NullableRawMessage `json:"jira_saved_views,omitempty"` + JiraTaskPresets NullableRawMessage `json:"jira_task_presets,omitempty"` + GitHubSavedPresets NullableRawMessage `json:"github_saved_presets,omitempty"` + GitHubDefaultQueryPresets NullableRawMessage `json:"github_default_query_presets,omitempty"` + GitLabSavedPresets NullableRawMessage `json:"gitlab_saved_presets,omitempty"` DefaultUtilityAgentID *string `json:"default_utility_agent_id,omitempty"` DefaultUtilityModel *string `json:"default_utility_model,omitempty"` KeyboardShortcuts *map[string]interface{} `json:"keyboard_shortcuts,omitempty"` @@ -88,6 +107,67 @@ type UpdateUserSettingsRequest struct { VoiceMode *models.VoiceModeSettings `json:"voice_mode,omitempty"` } +// NullableSidebarDraft preserves the JSON PATCH distinction between an omitted +// sidebar_draft field and an explicit null value. Prefer JSON decoding or +// NewNullableSidebarDraft; the zero value intentionally means "field omitted". +type NullableSidebarDraft struct { + Set bool + Value *models.SidebarViewDraft +} + +func NewNullableSidebarDraft(value *models.SidebarViewDraft) NullableSidebarDraft { + return NullableSidebarDraft{Set: true, Value: value} +} + +func (n *NullableSidebarDraft) UnmarshalJSON(data []byte) error { + n.Set = true + if string(data) == "null" { + n.Value = nil + return nil + } + var value models.SidebarViewDraft + if err := json.Unmarshal(data, &value); err != nil { + return err + } + n.Value = &value + return nil +} + +func (n NullableSidebarDraft) ServiceValue() **models.SidebarViewDraft { + if !n.Set { + return nil + } + return &n.Value +} + +// NullableRawMessage preserves PATCH semantics for raw JSON preference blobs: +// omitted means "leave unchanged"; explicit null means "clear". +type NullableRawMessage struct { + Set bool + Value *json.RawMessage +} + +func (n *NullableRawMessage) UnmarshalJSON(data []byte) error { + n.Set = true + if string(data) == "null" { + n.Value = nil + return nil + } + var value json.RawMessage + if err := json.Unmarshal(data, &value); err != nil { + return err + } + n.Value = &value + return nil +} + +func (n NullableRawMessage) ServiceValue() **json.RawMessage { + if !n.Set { + return nil + } + return &n.Value +} + func FromUser(user *models.User) UserDTO { return UserDTO{ ID: user.ID, @@ -117,6 +197,15 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { LspServerConfigs: settings.LspServerConfigs, SavedLayouts: settings.SavedLayouts, SidebarViews: settings.SidebarViews, + SidebarActiveViewID: settings.SidebarActiveViewID, + SidebarDraft: settings.SidebarDraft, + SidebarTaskPrefs: settings.SidebarTaskPrefs, + TaskCreateLastUsed: settings.TaskCreateLastUsed, + JiraSavedViews: settings.JiraSavedViews, + JiraTaskPresets: settings.JiraTaskPresets, + GitHubSavedPresets: settings.GitHubSavedPresets, + GitHubDefaultQueryPresets: settings.GitHubDefaultQueryPresets, + GitLabSavedPresets: settings.GitLabSavedPresets, DefaultUtilityAgentID: settings.DefaultUtilityAgentID, DefaultUtilityModel: settings.DefaultUtilityModel, KeyboardShortcuts: settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go new file mode 100644 index 0000000000..672172a182 --- /dev/null +++ b/apps/backend/internal/user/dto/dto_test.go @@ -0,0 +1,81 @@ +package dto + +import ( + "encoding/json" + "testing" +) + +func TestNullableSidebarDraft(t *testing.T) { + t.Run("omitted field is not set", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.SidebarDraft.Set { + t.Fatal("expected omitted sidebar_draft to remain unset") + } + if req.SidebarDraft.ServiceValue() != nil { + t.Fatal("expected omitted sidebar_draft to map to nil service value") + } + }) + + t.Run("null field is set to nil draft", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"sidebar_draft":null}`), &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + serviceValue := req.SidebarDraft.ServiceValue() + if !req.SidebarDraft.Set || serviceValue == nil || *serviceValue != nil { + t.Fatalf("expected explicit null to map to set nil draft, got set=%v value=%v", req.SidebarDraft.Set, serviceValue) + } + }) + + t.Run("object field is set to draft", func(t *testing.T) { + var req UpdateUserSettingsRequest + raw := []byte(`{"sidebar_draft":{"base_view_id":"view-1","filters":[],"sort":{"key":"state","direction":"asc"},"group":"state"}}`) + if err := json.Unmarshal(raw, &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + serviceValue := req.SidebarDraft.ServiceValue() + if !req.SidebarDraft.Set || serviceValue == nil || *serviceValue == nil || (*serviceValue).BaseViewID != "view-1" { + t.Fatalf("expected object to map to draft, got set=%v value=%v", req.SidebarDraft.Set, serviceValue) + } + }) +} + +func TestNullableRawMessage(t *testing.T) { + t.Run("omitted field is not set", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if req.JiraSavedViews.Set { + t.Fatal("expected omitted jira_saved_views to remain unset") + } + if req.JiraSavedViews.ServiceValue() != nil { + t.Fatal("expected omitted jira_saved_views to map to nil service value") + } + }) + + t.Run("null field is set to nil raw message", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"jira_saved_views":null}`), &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + serviceValue := req.JiraSavedViews.ServiceValue() + if !req.JiraSavedViews.Set || serviceValue == nil || *serviceValue != nil { + t.Fatalf("expected explicit null to map to set nil raw message, got set=%v value=%v", req.JiraSavedViews.Set, serviceValue) + } + }) + + t.Run("json field is set to raw message", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"jira_saved_views":[{"id":"view-1"}]}`), &req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + serviceValue := req.JiraSavedViews.ServiceValue() + if !req.JiraSavedViews.Set || serviceValue == nil || *serviceValue == nil || string(**serviceValue) != `[{"id":"view-1"}]` { + t.Fatalf("expected JSON value to map to raw message, got set=%v value=%v", req.JiraSavedViews.Set, serviceValue) + } + }) +} diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 3e29cb1613..e8623b3e43 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -31,6 +31,15 @@ type UserSettings struct { LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []SavedLayout `json:"saved_layouts"` SidebarViews []SidebarView `json:"sidebar_views"` + SidebarActiveViewID string `json:"sidebar_active_view_id"` + SidebarDraft *SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` // Default inference agent for utility agents DefaultUtilityModel string `json:"default_utility_model"` // Default model for utility agents KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` // User-configured keyboard shortcut overrides @@ -105,3 +114,24 @@ type SidebarViewSort struct { Key string `json:"key"` Direction string `json:"direction"` } + +type SidebarViewDraft struct { + BaseViewID string `json:"base_view_id"` + Filters []SidebarViewClause `json:"filters"` + Sort SidebarViewSort `json:"sort"` + Group string `json:"group"` +} + +type SidebarTaskPrefs struct { + PinnedTaskIDs []string `json:"pinned_task_ids"` + OrderedTaskIDs []string `json:"ordered_task_ids"` + SubtaskOrderByParentID map[string][]string `json:"subtask_order_by_parent_id"` +} + +type TaskCreateLastUsed struct { + // Empty fields are treated as "no change" when used as an update patch. + RepositoryID string `json:"repository_id"` + Branch string `json:"branch"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 59b83e0ce4..0508ffe893 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -2,6 +2,7 @@ package service import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -51,6 +52,15 @@ type UpdateUserSettingsRequest struct { LspServerConfigs *map[string]map[string]interface{} SavedLayouts *[]models.SavedLayout SidebarViews *[]models.SidebarView + SidebarActiveViewID *string + SidebarDraft **models.SidebarViewDraft + SidebarTaskPrefs *models.SidebarTaskPrefs + TaskCreateLastUsed *models.TaskCreateLastUsed + JiraSavedViews **json.RawMessage + JiraTaskPresets **json.RawMessage + GitHubSavedPresets **json.RawMessage + GitHubDefaultQueryPresets **json.RawMessage + GitLabSavedPresets **json.RawMessage DefaultUtilityAgentID *string DefaultUtilityModel *string KeyboardShortcuts *map[string]interface{} @@ -124,6 +134,12 @@ func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSetting if err := applySidebarViews(settings, req); err != nil { return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } + if err := applySidebarViewState(settings, req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } + if err := applyUserPreferenceBlobs(settings, req); err != nil { + return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) + } if err := applyVoiceMode(settings, req.VoiceMode); err != nil { return nil, fmt.Errorf("%w: %s", ErrValidation, err.Error()) } @@ -370,6 +386,105 @@ func applySidebarViews(settings *models.UserSettings, req *UpdateUserSettingsReq return nil } +func applySidebarViewState(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarActiveViewID != nil { + activeViewID := strings.TrimSpace(*req.SidebarActiveViewID) + if activeViewID == "" { + return errors.New("sidebar_active_view_id must not be empty") + } + if !sidebarViewIDExists(settings.SidebarViews, activeViewID) { + return fmt.Errorf("sidebar_active_view_id %q does not match any saved view", activeViewID) + } + settings.SidebarActiveViewID = activeViewID + } + if req.SidebarDraft != nil { + settings.SidebarDraft = *req.SidebarDraft + } + return nil +} + +func sidebarViewIDExists(views []models.SidebarView, id string) bool { + for _, view := range views { + if view.ID == id { + return true + } + } + return false +} + +const maxUserPreferenceBlobBytes = 64 * 1024 + +func applyUserPreferenceBlobs(settings *models.UserSettings, req *UpdateUserSettingsRequest) error { + if req.SidebarTaskPrefs != nil { + settings.SidebarTaskPrefs = *req.SidebarTaskPrefs + } + if req.TaskCreateLastUsed != nil { + mergeTaskCreateLastUsed(&settings.TaskCreateLastUsed, *req.TaskCreateLastUsed) + } + if err := applyUserPreferenceBlob("jira_saved_views", req.JiraSavedViews, &settings.JiraSavedViews); err != nil { + return err + } + if err := applyUserPreferenceBlob("jira_task_presets", req.JiraTaskPresets, &settings.JiraTaskPresets); err != nil { + return err + } + if err := applyUserPreferenceBlob("github_saved_presets", req.GitHubSavedPresets, &settings.GitHubSavedPresets); err != nil { + return err + } + if err := applyUserPreferenceBlob("github_default_query_presets", req.GitHubDefaultQueryPresets, &settings.GitHubDefaultQueryPresets); err != nil { + return err + } + if err := applyUserPreferenceBlob("gitlab_saved_presets", req.GitLabSavedPresets, &settings.GitLabSavedPresets); err != nil { + return err + } + return nil +} + +func applyUserPreferenceBlob(field string, value **json.RawMessage, target *json.RawMessage) error { + if value == nil { + return nil + } + if *value == nil { + *target = nil + return nil + } + if err := validateUserPreferenceBlob(field, **value); err != nil { + return err + } + *target = **value + return nil +} + +func validateUserPreferenceBlob(field string, value json.RawMessage) error { + if len(value) > maxUserPreferenceBlobBytes { + return fmt.Errorf("%s: max %d bytes allowed", field, maxUserPreferenceBlobBytes) + } + var decoded interface{} + if err := json.Unmarshal(value, &decoded); err != nil { + return fmt.Errorf("%s: must be valid JSON", field) + } + switch decoded.(type) { + case nil, []interface{}, map[string]interface{}: + return nil + default: + return fmt.Errorf("%s: must be a JSON object, array, or null", field) + } +} + +func mergeTaskCreateLastUsed(current *models.TaskCreateLastUsed, patch models.TaskCreateLastUsed) { + if patch.RepositoryID != "" { + current.RepositoryID = patch.RepositoryID + } + if patch.Branch != "" { + current.Branch = patch.Branch + } + if patch.AgentProfileID != "" { + current.AgentProfileID = patch.AgentProfileID + } + if patch.ExecutorProfileID != "" { + current.ExecutorProfileID = patch.ExecutorProfileID + } +} + func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models.UserSettings) { if s.eventBus == nil || settings == nil { return @@ -393,6 +508,15 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "lsp_server_configs": settings.LspServerConfigs, "saved_layouts": settings.SavedLayouts, "sidebar_views": settings.SidebarViews, + "sidebar_active_view_id": settings.SidebarActiveViewID, + "sidebar_draft": settings.SidebarDraft, + "sidebar_task_prefs": settings.SidebarTaskPrefs, + "task_create_last_used": settings.TaskCreateLastUsed, + "jira_saved_views": settings.JiraSavedViews, + "jira_task_presets": settings.JiraTaskPresets, + "github_saved_presets": settings.GitHubSavedPresets, + "github_default_query_presets": settings.GitHubDefaultQueryPresets, + "gitlab_saved_presets": settings.GitLabSavedPresets, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": settings.KeyboardShortcuts, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 4a460d00ed..7615eada81 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -11,6 +11,14 @@ import ( func ptr[T any](v T) *T { return &v } +func rawPatch(v json.RawMessage) **json.RawMessage { + return ptr(ptr(v)) +} + +func rawClear() **json.RawMessage { + return ptr((*json.RawMessage)(nil)) +} + func makeLayouts(n int) []models.SavedLayout { layouts := make([]models.SavedLayout, n) for i := range layouts { @@ -442,6 +450,161 @@ func TestApplySidebarViews(t *testing.T) { } } +func TestApplySidebarViewState(t *testing.T) { + t.Run("nil fields leave active view and draft unchanged", func(t *testing.T) { + settings := &models.UserSettings{ + SidebarActiveViewID: "view-existing", + SidebarDraft: &models.SidebarViewDraft{ + BaseViewID: "view-existing", + Filters: []models.SidebarViewClause{}, + Sort: models.SidebarViewSort{Key: "state", Direction: "asc"}, + Group: "state", + }, + } + if err := applySidebarViewState(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.SidebarActiveViewID != "view-existing" { + t.Fatalf("expected active view unchanged, got %q", settings.SidebarActiveViewID) + } + if settings.SidebarDraft == nil || settings.SidebarDraft.BaseViewID != "view-existing" { + t.Fatalf("expected draft unchanged, got %+v", settings.SidebarDraft) + } + }) + + t.Run("applies active view and draft", func(t *testing.T) { + draft := &models.SidebarViewDraft{ + BaseViewID: "view-1", + Filters: []models.SidebarViewClause{{ + ID: "clause-1", + Dimension: "titleMatch", + Op: "matches", + Value: json.RawMessage(`"bug"`), + }}, + Sort: models.SidebarViewSort{Key: "updatedAt", Direction: "desc"}, + Group: "workflow", + } + settings := &models.UserSettings{SidebarViews: []models.SidebarView{{ID: "view-1", Name: "View 1"}}} + req := &UpdateUserSettingsRequest{ + SidebarActiveViewID: ptr("view-1"), + SidebarDraft: &draft, + } + if err := applySidebarViewState(settings, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.SidebarActiveViewID != "view-1" { + t.Fatalf("expected active view view-1, got %q", settings.SidebarActiveViewID) + } + if settings.SidebarDraft == nil || settings.SidebarDraft.Group != "workflow" { + t.Fatalf("expected draft to be applied, got %+v", settings.SidebarDraft) + } + }) + + t.Run("clears draft when null is provided", func(t *testing.T) { + settings := &models.UserSettings{ + SidebarDraft: &models.SidebarViewDraft{BaseViewID: "view-1"}, + } + req := &UpdateUserSettingsRequest{SidebarDraft: ptr((*models.SidebarViewDraft)(nil))} + if err := applySidebarViewState(settings, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.SidebarDraft != nil { + t.Fatalf("expected draft cleared, got %+v", settings.SidebarDraft) + } + }) + + t.Run("rejects whitespace-only active view id", func(t *testing.T) { + req := &UpdateUserSettingsRequest{SidebarActiveViewID: ptr(" ")} + if err := applySidebarViewState(&models.UserSettings{}, req); err == nil { + t.Fatal("expected validation error, got nil") + } + }) + + t.Run("rejects active view id missing from saved views", func(t *testing.T) { + settings := &models.UserSettings{SidebarViews: []models.SidebarView{{ID: "view-1", Name: "View 1"}}} + req := &UpdateUserSettingsRequest{SidebarActiveViewID: ptr("missing")} + if err := applySidebarViewState(settings, req); err == nil { + t.Fatal("expected validation error, got nil") + } + if settings.SidebarActiveViewID != "" { + t.Fatalf("expected active view unchanged, got %q", settings.SidebarActiveViewID) + } + }) +} + +func TestApplyUserPreferenceBlobs(t *testing.T) { + settings := &models.UserSettings{ + TaskCreateLastUsed: models.TaskCreateLastUsed{ + RepositoryID: "repo-1", + Branch: "main", + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + }, + } + patch := models.TaskCreateLastUsed{Branch: "feature"} + + if err := applyUserPreferenceBlobs(settings, &UpdateUserSettingsRequest{ + TaskCreateLastUsed: &patch, + GitHubSavedPresets: rawPatch(json.RawMessage(`[{"id":"p1"}]`)), + }); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if settings.TaskCreateLastUsed.RepositoryID != "repo-1" { + t.Fatalf("expected repository id to be preserved, got %q", settings.TaskCreateLastUsed.RepositoryID) + } + if settings.TaskCreateLastUsed.Branch != "feature" { + t.Fatalf("expected branch to update, got %q", settings.TaskCreateLastUsed.Branch) + } + if string(settings.GitHubSavedPresets) != `[{"id":"p1"}]` { + t.Fatalf("expected GitHub presets to apply, got %s", string(settings.GitHubSavedPresets)) + } +} + +func TestApplyUserPreferenceBlobsValidation(t *testing.T) { + t.Run("accepts arrays objects and null", func(t *testing.T) { + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{ + JiraSavedViews: rawPatch(json.RawMessage(`[]`)), + GitHubDefaultQueryPresets: rawPatch(json.RawMessage(`{"pr":[],"issue":[]}`)), + GitLabSavedPresets: rawClear(), + } + if err := applyUserPreferenceBlobs(settings, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("rejects scalar blobs", func(t *testing.T) { + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{GitHubSavedPresets: rawPatch(json.RawMessage(`"bad"`))} + err := applyUserPreferenceBlobs(settings, req) + if err == nil || !strings.Contains(err.Error(), "github_saved_presets") { + t.Fatalf("expected github_saved_presets validation error, got %v", err) + } + }) + + t.Run("rejects oversized blobs", func(t *testing.T) { + settings := &models.UserSettings{} + raw := json.RawMessage(`["` + strings.Repeat("x", maxUserPreferenceBlobBytes) + `"]`) + req := &UpdateUserSettingsRequest{JiraSavedViews: rawPatch(raw)} + err := applyUserPreferenceBlobs(settings, req) + if err == nil || !strings.Contains(err.Error(), "max") { + t.Fatalf("expected size validation error, got %v", err) + } + }) + + t.Run("explicit null clears blob", func(t *testing.T) { + settings := &models.UserSettings{JiraSavedViews: json.RawMessage(`[{"id":"view"}]`)} + req := &UpdateUserSettingsRequest{JiraSavedViews: rawClear()} + if err := applyUserPreferenceBlobs(settings, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.JiraSavedViews != nil { + t.Fatalf("expected explicit null to clear blob, got %s", string(settings.JiraSavedViews)) + } + }) +} + func TestApplyVoiceMode(t *testing.T) { t.Run("nil value leaves settings unchanged", func(t *testing.T) { settings := &models.UserSettings{ diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index daf7815358..b930a5e700 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -133,6 +133,7 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod if sidebarViews == nil { sidebarViews = []models.SidebarView{} } + sidebarTaskPrefs := normalizeSidebarTaskPrefs(settings.SidebarTaskPrefs) keyboardShortcuts := settings.KeyboardShortcuts if keyboardShortcuts == nil { keyboardShortcuts = map[string]interface{}{} @@ -155,6 +156,15 @@ func (r *sqliteRepository) UpsertUserSettings(ctx context.Context, settings *mod "lsp_server_configs": lspServerConfigs, "saved_layouts": savedLayouts, "sidebar_views": sidebarViews, + "sidebar_active_view_id": settings.SidebarActiveViewID, + "sidebar_draft": settings.SidebarDraft, + "sidebar_task_prefs": sidebarTaskPrefs, + "task_create_last_used": settings.TaskCreateLastUsed, + "jira_saved_views": settings.JiraSavedViews, + "jira_task_presets": settings.JiraTaskPresets, + "github_saved_presets": settings.GitHubSavedPresets, + "github_default_query_presets": settings.GitHubDefaultQueryPresets, + "gitlab_saved_presets": settings.GitLabSavedPresets, "default_utility_agent_id": settings.DefaultUtilityAgentID, "default_utility_model": settings.DefaultUtilityModel, "keyboard_shortcuts": keyboardShortcuts, @@ -262,6 +272,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.TerminalLinkBehavior = "new_tab" settings.ChangesPanelLayout = "tree" settings.SidebarViews = []models.SidebarView{} + settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}) settings.VoiceMode = defaultVoiceModeSettings() return settings, nil } @@ -283,6 +294,15 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin LspServerConfigs map[string]map[string]interface{} `json:"lsp_server_configs"` SavedLayouts []models.SavedLayout `json:"saved_layouts"` SidebarViews []models.SidebarView `json:"sidebar_views"` + SidebarActiveViewID string `json:"sidebar_active_view_id"` + SidebarDraft *models.SidebarViewDraft `json:"sidebar_draft"` + SidebarTaskPrefs models.SidebarTaskPrefs `json:"sidebar_task_prefs"` + TaskCreateLastUsed models.TaskCreateLastUsed `json:"task_create_last_used"` + JiraSavedViews json.RawMessage `json:"jira_saved_views"` + JiraTaskPresets json.RawMessage `json:"jira_task_presets"` + GitHubSavedPresets json.RawMessage `json:"github_saved_presets"` + GitHubDefaultQueryPresets json.RawMessage `json:"github_default_query_presets"` + GitLabSavedPresets json.RawMessage `json:"gitlab_saved_presets"` DefaultUtilityAgentID string `json:"default_utility_agent_id"` DefaultUtilityModel string `json:"default_utility_model"` KeyboardShortcuts map[string]interface{} `json:"keyboard_shortcuts"` @@ -338,6 +358,15 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin if settings.SidebarViews == nil { settings.SidebarViews = []models.SidebarView{} } + settings.SidebarActiveViewID = payload.SidebarActiveViewID + settings.SidebarDraft = payload.SidebarDraft + settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(payload.SidebarTaskPrefs) + settings.TaskCreateLastUsed = payload.TaskCreateLastUsed + settings.JiraSavedViews = payload.JiraSavedViews + settings.JiraTaskPresets = payload.JiraTaskPresets + settings.GitHubSavedPresets = payload.GitHubSavedPresets + settings.GitHubDefaultQueryPresets = payload.GitHubDefaultQueryPresets + settings.GitLabSavedPresets = payload.GitLabSavedPresets settings.DefaultUtilityAgentID = payload.DefaultUtilityAgentID settings.DefaultUtilityModel = payload.DefaultUtilityModel settings.KeyboardShortcuts = payload.KeyboardShortcuts @@ -360,3 +389,16 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin } return settings, nil } + +func normalizeSidebarTaskPrefs(prefs models.SidebarTaskPrefs) models.SidebarTaskPrefs { + if prefs.PinnedTaskIDs == nil { + prefs.PinnedTaskIDs = []string{} + } + if prefs.OrderedTaskIDs == nil { + prefs.OrderedTaskIDs = []string{} + } + if prefs.SubtaskOrderByParentID == nil { + prefs.SubtaskOrderByParentID = map[string][]string{} + } + return prefs +} diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 2a762818d1..23078bcd2e 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -2,6 +2,7 @@ package store import ( "context" + "encoding/json" "testing" "time" @@ -100,3 +101,72 @@ func TestSQLiteRepositorySystemMetricsDisplayRoundTrip(t *testing.T) { t.Fatal("expected system metrics display preference to round-trip") } } + +func TestSQLiteRepositorySidebarViewStateRoundTrip(t *testing.T) { + conn, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + conn.SetMaxOpenConns(1) + t.Cleanup(func() { _ = conn.Close() }) + repo, err := newSQLiteRepositoryWithDB(conn, conn) + if err != nil { + t.Fatalf("new repo: %v", err) + } + + ctx := context.Background() + settings, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get defaults: %v", err) + } + settings.SidebarActiveViewID = "view-1" + settings.SidebarTaskPrefs = models.SidebarTaskPrefs{ + PinnedTaskIDs: []string{"task-1"}, + OrderedTaskIDs: []string{"task-2", "task-1"}, + SubtaskOrderByParentID: map[string][]string{"task-1": {"sub-1"}}, + } + settings.TaskCreateLastUsed = models.TaskCreateLastUsed{ + RepositoryID: "repo-1", + Branch: "main", + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + } + settings.JiraSavedViews = json.RawMessage(`[{"id":"view-1"}]`) + settings.GitLabSavedPresets = json.RawMessage(`[{"id":"preset-1"}]`) + settings.SidebarDraft = &models.SidebarViewDraft{ + BaseViewID: "view-1", + Filters: []models.SidebarViewClause{{ + ID: "clause-1", + Dimension: "titleMatch", + Op: "matches", + Value: json.RawMessage(`"bug"`), + }}, + Sort: models.SidebarViewSort{Key: "updatedAt", Direction: "desc"}, + Group: "workflow", + } + if err := repo.UpsertUserSettings(ctx, settings); err != nil { + t.Fatalf("upsert settings: %v", err) + } + got, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get settings: %v", err) + } + if got.SidebarActiveViewID != "view-1" { + t.Fatalf("expected active view to round-trip, got %q", got.SidebarActiveViewID) + } + if got.SidebarDraft == nil || got.SidebarDraft.Group != "workflow" { + t.Fatalf("expected sidebar draft to round-trip, got %+v", got.SidebarDraft) + } + if got.SidebarTaskPrefs.PinnedTaskIDs[0] != "task-1" { + t.Fatalf("expected sidebar task prefs to round-trip, got %+v", got.SidebarTaskPrefs) + } + if got.TaskCreateLastUsed.Branch != "main" { + t.Fatalf("expected task-create prefs to round-trip, got %+v", got.TaskCreateLastUsed) + } + if string(got.JiraSavedViews) != `[{"id":"view-1"}]` { + t.Fatalf("expected Jira saved views to round-trip, got %s", string(got.JiraSavedViews)) + } + if string(got.GitLabSavedPresets) != `[{"id":"preset-1"}]` { + t.Fatalf("expected GitLab presets to round-trip, got %s", string(got.GitLabSavedPresets)) + } +} diff --git a/apps/web/components/github/my-github/use-default-query-presets.test.ts b/apps/web/components/github/my-github/use-default-query-presets.test.ts new file mode 100644 index 0000000000..14aad6b4b5 --- /dev/null +++ b/apps/web/components/github/my-github/use-default-query-presets.test.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { + __resetSnapshotForTests, + useDefaultQueryPresets, + type StoredQueryPreset, +} from "./use-default-query-presets"; + +const STORAGE_KEY = "kandev:github-default-queries:v1"; +const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); + +function makeLocalStorageMock() { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + }; +} + +const localStorageMock = makeLocalStorageMock(); +vi.stubGlobal("localStorage", localStorageMock); + +const preset: StoredQueryPreset = { + value: "mine", + label: "Mine", + filter: "author:@me is:open", + group: "created", +}; + +describe("useDefaultQueryPresets", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorageMock.clear(); + __resetSnapshotForTests(); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { github_default_query_presets: null }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + }); + + it("retries local defaults after a failed backend sync and clears the marker", async () => { + const local = { pr: [preset], issue: [] }; + localStorageMock.setItem(STORAGE_KEY, JSON.stringify(local)); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + + renderHook(() => useDefaultQueryPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + github_default_query_presets: local, + }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("retries null local defaults after a failed reset sync", async () => { + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { github_default_query_presets: { pr: [preset], issue: [] } }, + } as Awaited>); + + renderHook(() => useDefaultQueryPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + github_default_query_presets: null, + }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("does not overwrite cross-tab local changes with a stale hydration response", async () => { + const initial = { pr: [preset], issue: [] }; + const crossTab = { pr: [], issue: [preset] }; + const server = { + pr: [{ ...preset, value: "server", label: "Server" }], + issue: [], + }; + let resolveFetch: (value: Awaited>) => void = () => {}; + vi.mocked(fetchUserSettings).mockReturnValue( + new Promise((resolve) => { + resolveFetch = resolve; + }), + ); + localStorageMock.setItem(STORAGE_KEY, JSON.stringify(initial)); + + renderHook(() => useDefaultQueryPresets()); + + await waitFor(() => expect(fetchUserSettings).toHaveBeenCalled()); + localStorageMock.setItem(STORAGE_KEY, JSON.stringify(crossTab)); + const event = new Event("storage"); + Object.defineProperty(event, "key", { value: STORAGE_KEY }); + window.dispatchEvent(event); + resolveFetch({ + settings: { github_default_query_presets: server }, + } as Awaited>); + await Promise.resolve(); + await Promise.resolve(); + + expect(localStorageMock.getItem(STORAGE_KEY)).toBe(JSON.stringify(crossTab)); + expect(updateUserSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/github/my-github/use-default-query-presets.ts b/apps/web/components/github/my-github/use-default-query-presets.ts index d24bf77ea6..5bba572344 100644 --- a/apps/web/components/github/my-github/use-default-query-presets.ts +++ b/apps/web/components/github/my-github/use-default-query-presets.ts @@ -1,13 +1,18 @@ "use client"; -import { useCallback, useMemo, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useSyncExternalStore } from "react"; import { PR_PRESETS as BUILTIN_PR_PRESETS, ISSUE_PRESETS as BUILTIN_ISSUE_PRESETS, type PresetOption, } from "./search-bar"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; const STORAGE_KEY = "kandev:github-default-queries:v1"; +const MIGRATED_KEY = "kandev:github-default-queries:migrated-to-backend:v1"; +const SYNC_FAILED_KEY = "kandev:github-default-queries:sync-failed:v1"; export type StoredQueryPreset = { value: string; @@ -67,10 +72,54 @@ function publish(next: StoredDefaults | null) { listeners.forEach((fn) => fn()); } +function readServerDefaults(value: unknown): StoredDefaults | null | undefined { + if (value === null) return null; + if ( + typeof value !== "object" || + !Array.isArray((value as StoredDefaults).pr) || + !Array.isArray((value as StoredDefaults).issue) + ) { + return undefined; + } + return value as StoredDefaults; +} + +const syncServer = createQueuedUserSettingsSync( + SYNC_FAILED_KEY, + (defaults) => ({ + github_default_query_presets: defaults, + }), +); + +function snapshotKey(value: StoredDefaults | null): string { + return JSON.stringify(value); +} + +function hasMigratedToBackend(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(MIGRATED_KEY) === "1"; +} + +function markMigratedToBackend(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATED_KEY, "1"); + } catch { + /* ignore storage failures */ + } +} + function subscribe(cb: () => void) { listeners.add(cb); + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY) return; + snapshot = readStorage(); + listeners.forEach((fn) => fn()); + }; + window.addEventListener("storage", onStorage); return () => { listeners.delete(cb); + window.removeEventListener("storage", onStorage); }; } @@ -83,18 +132,54 @@ function getServerSnapshot(): StoredDefaults | null { return null; } +export function __resetSnapshotForTests() { + snapshot = undefined; + listeners.forEach((fn) => fn()); +} + export function useDefaultQueryPresets() { const stored = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + useEffect(() => { + let cancelled = false; + const initialKey = snapshotKey(getSnapshot()); + fetchUserSettings({ cache: "no-store" }) + .then((response) => { + const serverDefaults = readServerDefaults(response.settings.github_default_query_presets); + if (cancelled || serverDefaults === undefined) return; + const local = getSnapshot(); + if (snapshotKey(local) !== initialKey) return; + if (hasUserSettingsSyncFailure(SYNC_FAILED_KEY)) { + void syncServer(local); + return; + } + if (serverDefaults === null && local !== null && !hasMigratedToBackend()) { + void syncServer(local); + markMigratedToBackend(); + return; + } + publish(serverDefaults); + markMigratedToBackend(); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + const prPresets = useMemo(() => stored?.pr ?? toStored(BUILTIN_PR_PRESETS), [stored]); const issuePresets = useMemo(() => stored?.issue ?? toStored(BUILTIN_ISSUE_PRESETS), [stored]); const save = useCallback((defaults: StoredDefaults) => { publish(defaults); + void syncServer(defaults); + markMigratedToBackend(); }, []); const reset = useCallback(() => { publish(null); + void syncServer(null); + markMigratedToBackend(); }, []); const isCustomized = stored !== null; diff --git a/apps/web/components/github/my-github/use-saved-presets.test.ts b/apps/web/components/github/my-github/use-saved-presets.test.ts index 833917c4a4..3e10462a3b 100644 --- a/apps/web/components/github/my-github/use-saved-presets.test.ts +++ b/apps/web/components/github/my-github/use-saved-presets.test.ts @@ -1,7 +1,20 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { readStorage, type SavedPreset } from "./use-saved-presets"; +import { renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { + __resetSnapshotForTests, + readStorage, + useSavedPresets, + type SavedPreset, +} from "./use-saved-presets"; const STORAGE_KEY = "kandev:github-presets:v1"; +const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); // Provide a simple in-memory localStorage mock so the tests are not sensitive // to how the test runner exposes window.localStorage (e.g. Node's @@ -40,6 +53,9 @@ const valid: SavedPreset = { describe("readStorage", () => { beforeEach(() => { localStorageMock.clear(); + __resetSnapshotForTests(); + vi.mocked(fetchUserSettings).mockReset(); + vi.mocked(updateUserSettings).mockReset(); }); it("returns empty array when no value is stored", () => { @@ -89,3 +105,48 @@ describe("readStorage", () => { expect(readStorage()).toEqual([issue]); }); }); + +describe("useSavedPresets", () => { + beforeEach(() => { + localStorageMock.clear(); + __resetSnapshotForTests(); + vi.mocked(fetchUserSettings).mockReset(); + vi.mocked(updateUserSettings).mockReset(); + }); + + it("retries local presets after a failed backend sync and clears the marker", async () => { + set(JSON.stringify([valid])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { github_saved_presets: [] }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + + renderHook(() => useSavedPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ github_saved_presets: [valid] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("retries empty local presets after a failed backend sync", async () => { + set(JSON.stringify([])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { github_saved_presets: [valid] }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + + renderHook(() => useSavedPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ github_saved_presets: [] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); +}); diff --git a/apps/web/components/github/my-github/use-saved-presets.ts b/apps/web/components/github/my-github/use-saved-presets.ts index b0def7ff27..8afacba6fc 100644 --- a/apps/web/components/github/my-github/use-saved-presets.ts +++ b/apps/web/components/github/my-github/use-saved-presets.ts @@ -1,8 +1,13 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; const STORAGE_KEY = "kandev:github-presets:v1"; +const MIGRATED_KEY = "kandev:github-presets:migrated-to-backend:v1"; +const SYNC_FAILED_KEY = "kandev:github-presets:sync-failed:v1"; export type SavedPreset = { id: string; @@ -69,21 +74,93 @@ function publish(next: SavedPreset[]) { for (const l of listeners) l(); } +function readServerPresets(value: unknown): SavedPreset[] | null { + if (!Array.isArray(value)) return null; + return value.filter( + (p): p is SavedPreset => + typeof p === "object" && + p !== null && + typeof (p as SavedPreset).id === "string" && + ((p as SavedPreset).kind === "pr" || (p as SavedPreset).kind === "issue") && + typeof (p as SavedPreset).label === "string", + ); +} + +const syncServer = createQueuedUserSettingsSync(SYNC_FAILED_KEY, (next) => ({ + github_saved_presets: next, +})); + +function snapshotKey(value: SavedPreset[]): string { + return JSON.stringify(value); +} + +function hasMigratedToBackend(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(MIGRATED_KEY) === "1"; +} + +function markMigratedToBackend(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATED_KEY, "1"); + } catch { + /* ignore storage failures */ + } +} + +export function __resetSnapshotForTests() { + snapshot = null; + for (const l of listeners) l(); +} + export function useSavedPresets() { const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + useEffect(() => { + let cancelled = false; + const initialKey = snapshotKey(readStorage()); + fetchUserSettings({ cache: "no-store" }) + .then((response) => { + const serverPresets = readServerPresets(response.settings.github_saved_presets); + if (cancelled || !serverPresets) return; + const local = readStorage(); + if (snapshotKey(local) !== initialKey) return; + if (hasUserSettingsSyncFailure(SYNC_FAILED_KEY)) { + void syncServer(local); + return; + } + if (serverPresets.length === 0 && local.length > 0 && !hasMigratedToBackend()) { + void syncServer(local); + markMigratedToBackend(); + return; + } + publish(serverPresets); + markMigratedToBackend(); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + const save = useCallback((input: Omit) => { const preset: SavedPreset = { ...input, id: `p_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString(), }; - publish([...(snapshot ?? readStorage()), preset]); + const next = [...readStorage(), preset]; + publish(next); + void syncServer(next); + markMigratedToBackend(); return preset; }, []); const remove = useCallback((id: string) => { - publish((snapshot ?? readStorage()).filter((p) => p.id !== id)); + const next = readStorage().filter((p) => p.id !== id); + publish(next); + void syncServer(next); + markMigratedToBackend(); }, []); return { presets, save, remove }; diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts index aa9b1b3a65..8b8a499ca2 100644 --- a/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts +++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; -import { act, renderHook } from "@testing-library/react"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; import { __resetSnapshotForTests, readStorage, @@ -8,6 +9,12 @@ import { } from "./use-saved-presets"; const STORAGE_KEY = "kandev:gitlab-presets:v1"; +const SYNC_FAILED_KEY = "kandev:gitlab-presets:sync-failed:v1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); function makeLocalStorageMock() { const store = new Map(); @@ -118,6 +125,12 @@ describe("useSavedPresets (gitlab)", () => { beforeEach(() => { localStorageMock.clear(); __resetSnapshotForTests(); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { gitlab_saved_presets: [] }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); }); it("save appends a preset and assigns id + createdAt", () => { @@ -169,4 +182,31 @@ describe("useSavedPresets (gitlab)", () => { expect(b.result.current.presets).toHaveLength(1); expect(b.result.current.presets[0].label).toBe("Shared"); }); + + it("retries local presets after a failed backend sync and clears the marker", async () => { + set(JSON.stringify([valid])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + + renderHook(() => useSavedPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ gitlab_saved_presets: [valid] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("retries empty local presets after a failed backend sync", async () => { + set(JSON.stringify([])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { gitlab_saved_presets: [valid] }, + } as Awaited>); + + renderHook(() => useSavedPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ gitlab_saved_presets: [] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); }); diff --git a/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts index 3cf6948132..62003364f9 100644 --- a/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts +++ b/apps/web/components/gitlab/my-gitlab/use-saved-presets.ts @@ -1,8 +1,13 @@ "use client"; -import { useCallback, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useSyncExternalStore } from "react"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; const STORAGE_KEY = "kandev:gitlab-presets:v1"; +const MIGRATED_KEY = "kandev:gitlab-presets:migrated-to-backend:v1"; +const SYNC_FAILED_KEY = "kandev:gitlab-presets:sync-failed:v1"; export type SavedPreset = { id: string; @@ -20,17 +25,7 @@ export function readStorage(): SavedPreset[] { if (!raw) return []; const parsed = JSON.parse(raw) as unknown; if (!Array.isArray(parsed)) return []; - return parsed.filter( - (p): p is SavedPreset => - typeof p === "object" && - p !== null && - typeof (p as SavedPreset).id === "string" && - ((p as SavedPreset).kind === "mr" || (p as SavedPreset).kind === "issue") && - typeof (p as SavedPreset).label === "string" && - typeof (p as SavedPreset).customQuery === "string" && - typeof (p as SavedPreset).projectFilter === "string" && - typeof (p as SavedPreset).createdAt === "string", - ); + return parsed.filter(isSavedPreset); } catch { return []; } @@ -71,6 +66,46 @@ function publish(next: SavedPreset[]) { for (const l of listeners) l(); } +function isSavedPreset(p: unknown): p is SavedPreset { + return ( + typeof p === "object" && + p !== null && + typeof (p as SavedPreset).id === "string" && + ((p as SavedPreset).kind === "mr" || (p as SavedPreset).kind === "issue") && + typeof (p as SavedPreset).label === "string" && + typeof (p as SavedPreset).customQuery === "string" && + typeof (p as SavedPreset).projectFilter === "string" && + typeof (p as SavedPreset).createdAt === "string" + ); +} + +function readServerPresets(value: unknown): SavedPreset[] | null { + if (!Array.isArray(value)) return null; + return value.filter(isSavedPreset); +} + +const syncServer = createQueuedUserSettingsSync(SYNC_FAILED_KEY, (next) => ({ + gitlab_saved_presets: next, +})); + +function snapshotKey(value: SavedPreset[]): string { + return JSON.stringify(value); +} + +function hasMigratedToBackend(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(MIGRATED_KEY) === "1"; +} + +function markMigratedToBackend(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATED_KEY, "1"); + } catch { + /* ignore storage failures */ + } +} + // Test-only: drop the module-level snapshot so the next read goes through // readStorage again. Used by the hook tests so each `it` starts from a known // empty state independent of test execution order. @@ -82,6 +117,33 @@ export function __resetSnapshotForTests() { export function useSavedPresets() { const presets = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); + useEffect(() => { + let cancelled = false; + const initialKey = snapshotKey(readStorage()); + fetchUserSettings({ cache: "no-store" }) + .then((response) => { + const serverPresets = readServerPresets(response.settings.gitlab_saved_presets); + if (cancelled || !serverPresets) return; + const local = readStorage(); + if (snapshotKey(local) !== initialKey) return; + if (hasUserSettingsSyncFailure(SYNC_FAILED_KEY)) { + void syncServer(local); + return; + } + if (serverPresets.length === 0 && local.length > 0 && !hasMigratedToBackend()) { + void syncServer(local); + markMigratedToBackend(); + return; + } + publish(serverPresets); + markMigratedToBackend(); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + // Always merge against the latest localStorage read instead of the in-memory // snapshot. With two tabs open, snapshot in tab A is stale the moment tab B // writes — appending to it would silently drop B's preset. readStorage is @@ -92,12 +154,18 @@ export function useSavedPresets() { id: `g_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`, createdAt: new Date().toISOString(), }; - publish([...readStorage(), preset]); + const next = [...readStorage(), preset]; + publish(next); + void syncServer(next); + markMigratedToBackend(); return preset; }, []); const remove = useCallback((id: string) => { - publish(readStorage().filter((p) => p.id !== id)); + const next = readStorage().filter((p) => p.id !== id); + publish(next); + void syncServer(next); + markMigratedToBackend(); }, []); return { presets, save, remove }; diff --git a/apps/web/components/jira/my-jira/use-saved-views.test.ts b/apps/web/components/jira/my-jira/use-saved-views.test.ts new file mode 100644 index 0000000000..62bb93cee5 --- /dev/null +++ b/apps/web/components/jira/my-jira/use-saved-views.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { useSavedViews, type SavedView } from "./use-saved-views"; + +const STORAGE_KEY = "kandev:jira:saved-views:v1"; +const SYNC_FAILED_KEY = "kandev:jira:saved-views:sync-failed:v1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); + +function makeLocalStorageMock() { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + }; +} + +const localStorageMock = makeLocalStorageMock(); +vi.stubGlobal("localStorage", localStorageMock); + +const view: SavedView = { + id: "custom:one", + name: "Mine", + filters: { + projectKeys: [], + statusCategories: [], + assignee: "me", + searchText: "", + sort: "updated", + }, +}; + +describe("useSavedViews", () => { + beforeEach(() => { + localStorageMock.clear(); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { jira_saved_views: [] }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + }); + + it("retries local views after a failed backend sync and clears the marker", async () => { + localStorageMock.setItem(STORAGE_KEY, JSON.stringify([view])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + + renderHook(() => useSavedViews()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ jira_saved_views: [view] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("retries empty local views after a failed backend sync", async () => { + localStorageMock.setItem(STORAGE_KEY, JSON.stringify([])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { jira_saved_views: [view] }, + } as Awaited>); + + renderHook(() => useSavedViews()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ jira_saved_views: [] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); +}); diff --git a/apps/web/components/jira/my-jira/use-saved-views.ts b/apps/web/components/jira/my-jira/use-saved-views.ts index c7d5402e12..ae60b5d9a7 100644 --- a/apps/web/components/jira/my-jira/use-saved-views.ts +++ b/apps/web/components/jira/my-jira/use-saved-views.ts @@ -2,6 +2,9 @@ import { useCallback, useEffect, useState } from "react"; import { DEFAULT_FILTERS, type FilterState } from "./filter-model"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; export type SavedView = { id: string; @@ -15,6 +18,8 @@ export type SavedView = { }; const STORAGE_KEY = "kandev:jira:saved-views:v1"; +const MIGRATED_KEY = "kandev:jira:saved-views:migrated-to-backend:v1"; +const SYNC_FAILED_KEY = "kandev:jira:saved-views:sync-failed:v1"; const BUILTIN_VIEWS: SavedView[] = [ { @@ -81,6 +86,46 @@ function writeStorage(views: SavedView[]): void { } } +function readServerViews(value: unknown): SavedView[] | null { + if (!Array.isArray(value)) return null; + return value.filter(isSavedView); +} + +const syncServer = createQueuedUserSettingsSync(SYNC_FAILED_KEY, (views) => ({ + jira_saved_views: views, +})); + +function snapshotKey(views: SavedView[]): string { + return JSON.stringify(views); +} + +function hasMigratedToBackend(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(MIGRATED_KEY) === "1"; +} + +function markMigratedToBackend(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATED_KEY, "1"); + } catch { + // Ignore write failures. + } +} + +function latestSavedViews(fallback: SavedView[]): SavedView[] { + if (typeof window === "undefined") return fallback; + try { + const raw = window.localStorage.getItem(STORAGE_KEY); + if (raw === null) return fallback; + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) return fallback; + return parsed.filter(isSavedView); + } catch { + return fallback; + } +} + export function useSavedViews() { const [custom, setCustom] = useState([]); @@ -88,7 +133,26 @@ export function useSavedViews() { let cancelled = false; async function init() { const loaded = readStorage(); + const initialKey = snapshotKey(loaded); if (!cancelled) setCustom(loaded); + const response = await fetchUserSettings({ cache: "no-store" }).catch(() => null); + const serverViews = readServerViews(response?.settings.jira_saved_views); + if (!cancelled && serverViews) { + const local = readStorage(); + if (snapshotKey(local) !== initialKey) return; + if (hasUserSettingsSyncFailure(SYNC_FAILED_KEY)) { + void syncServer(local); + return; + } + if (serverViews.length === 0 && local.length > 0 && !hasMigratedToBackend()) { + void syncServer(local); + markMigratedToBackend(); + return; + } + writeStorage(serverViews); + setCustom(serverViews); + markMigratedToBackend(); + } } void init(); return () => { @@ -105,8 +169,10 @@ export function useSavedViews() { customJql, }; setCustom((prev) => { - const next = [...prev, view]; + const next = [...latestSavedViews(prev), view]; writeStorage(next); + void syncServer(next); + markMigratedToBackend(); return next; }); return view; @@ -116,8 +182,10 @@ export function useSavedViews() { const remove = useCallback((id: string) => { setCustom((prev) => { - const next = prev.filter((v) => v.id !== id); + const next = latestSavedViews(prev).filter((v) => v.id !== id); writeStorage(next); + void syncServer(next); + markMigratedToBackend(); return next; }); }, []); diff --git a/apps/web/components/jira/my-jira/use-task-presets.test.ts b/apps/web/components/jira/my-jira/use-task-presets.test.ts new file mode 100644 index 0000000000..652491d9bb --- /dev/null +++ b/apps/web/components/jira/my-jira/use-task-presets.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { fetchUserSettings, updateUserSettings } from "@/lib/api/domains/settings-api"; +import { useJiraTaskPresets } from "./use-task-presets"; +import type { JiraStoredPreset } from "./presets"; + +const STORAGE_KEY = "kandev:jira:task-presets:v1"; +const SYNC_FAILED_KEY = "kandev:jira:task-presets:sync-failed:v1"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + fetchUserSettings: vi.fn(), + updateUserSettings: vi.fn(), +})); + +function makeLocalStorageMock() { + const store = new Map(); + return { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + get length() { + return store.size; + }, + key: (index: number) => Array.from(store.keys())[index] ?? null, + }; +} + +const localStorageMock = makeLocalStorageMock(); +vi.stubGlobal("localStorage", localStorageMock); + +const preset: JiraStoredPreset = { + id: "custom", + label: "Custom", + hint: "Do work", + icon: "code", + prompt_template: "Work on {{key}}", +}; + +describe("useJiraTaskPresets", () => { + beforeEach(() => { + localStorageMock.clear(); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { jira_task_presets: null }, + } as Awaited>); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + }); + + it("retries local presets after a failed backend sync and clears the marker", async () => { + localStorageMock.setItem(STORAGE_KEY, JSON.stringify([preset])); + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + + renderHook(() => useJiraTaskPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ jira_task_presets: [preset] }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); + + it("retries null local presets after a failed reset sync", async () => { + localStorageMock.setItem(SYNC_FAILED_KEY, "1"); + vi.mocked(fetchUserSettings).mockResolvedValue({ + settings: { jira_task_presets: [preset] }, + } as Awaited>); + + renderHook(() => useJiraTaskPresets()); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ jira_task_presets: null }); + expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull(); + }); + }); +}); diff --git a/apps/web/components/jira/my-jira/use-task-presets.ts b/apps/web/components/jira/my-jira/use-task-presets.ts index b1e4e896f3..6a99a6e5e2 100644 --- a/apps/web/components/jira/my-jira/use-task-presets.ts +++ b/apps/web/components/jira/my-jira/use-task-presets.ts @@ -7,8 +7,13 @@ import { type JiraStoredPreset, type JiraTaskPreset, } from "./presets"; +import { fetchUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import { hasUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; const STORAGE_KEY = "kandev:jira:task-presets:v1"; +const MIGRATED_KEY = "kandev:jira:task-presets:migrated-to-backend:v1"; +const SYNC_FAILED_KEY = "kandev:jira:task-presets:sync-failed:v1"; function isStoredPreset(v: unknown): v is JiraStoredPreset { if (!v || typeof v !== "object") return false; @@ -51,6 +56,35 @@ function writeStorage(presets: JiraStoredPreset[] | null): void { } } +function readServerPresets(value: unknown): JiraStoredPreset[] | null | undefined { + if (value === null) return null; + if (!Array.isArray(value)) return undefined; + return value.filter(isStoredPreset); +} + +const syncServer = createQueuedUserSettingsSync( + SYNC_FAILED_KEY, + (presets) => ({ jira_task_presets: presets }), +); + +function snapshotKey(presets: JiraStoredPreset[] | null): string { + return JSON.stringify(presets); +} + +function hasMigratedToBackend(): boolean { + if (typeof window === "undefined") return false; + return window.localStorage.getItem(MIGRATED_KEY) === "1"; +} + +function markMigratedToBackend(): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(MIGRATED_KEY, "1"); + } catch { + // Ignore write failures. + } +} + export function useJiraTaskPresets() { const [stored, setStored] = useState(null); const [loaded, setLoaded] = useState(false); @@ -59,10 +93,29 @@ export function useJiraTaskPresets() { let cancelled = false; async function init() { const value = readStorage(); + const initialKey = snapshotKey(value); if (!cancelled) { setStored(value); setLoaded(true); } + const response = await fetchUserSettings({ cache: "no-store" }).catch(() => null); + const serverValue = readServerPresets(response?.settings.jira_task_presets); + if (!cancelled && serverValue !== undefined) { + const local = readStorage(); + if (snapshotKey(local) !== initialKey) return; + if (hasUserSettingsSyncFailure(SYNC_FAILED_KEY)) { + void syncServer(local); + return; + } + if (serverValue === null && local !== null && !hasMigratedToBackend()) { + void syncServer(local); + markMigratedToBackend(); + return; + } + writeStorage(serverValue); + setStored(serverValue); + markMigratedToBackend(); + } } void init(); return () => { @@ -72,11 +125,15 @@ export function useJiraTaskPresets() { const save = useCallback((next: JiraStoredPreset[]) => { writeStorage(next); + void syncServer(next); + markMigratedToBackend(); setStored(next); }, []); const reset = useCallback(() => { writeStorage(null); + void syncServer(null); + markMigratedToBackend(); setStored(null); }, []); diff --git a/apps/web/components/settings/editors-settings-state.tsx b/apps/web/components/settings/editors-settings-state.tsx index a92de77506..71b84b8a55 100644 --- a/apps/web/components/settings/editors-settings-state.tsx +++ b/apps/web/components/settings/editors-settings-state.tsx @@ -13,7 +13,7 @@ import { parseTerminalLinkBehavior, parseVoiceMode, } from "@/lib/ssr/user-settings"; -import { fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; +import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import { type EditorFormState, buildConfig, @@ -215,18 +215,66 @@ function buildSettingsPayload( function mapEditorSettingsFields( s: NonNullable["settings"]>, +) { + return { + ...mapEditorBehaviorFields(s), + ...mapEditorLspFields(s), + ...mapEditorSidebarFields(s), + ...mapEditorSyncedLocalFields(s), + loaded: true as const, + }; +} + +function mapEditorBehaviorFields( + s: NonNullable["settings"]>, ) { return { chatSubmitKey: s.chat_submit_key ?? "cmd_enter", reviewAutoMarkOnScroll: s.review_auto_mark_on_scroll ?? true, showReleaseNotification: s.show_release_notification ?? true, releaseNotesLastSeenVersion: (s.release_notes_last_seen_version as string) || null, + savedLayouts: s.saved_layouts ?? [], + }; +} + +function mapEditorLspFields(s: NonNullable["settings"]>) { + return { lspAutoStartLanguages: s.lsp_auto_start_languages ?? [], lspAutoInstallLanguages: s.lsp_auto_install_languages ?? [], lspServerConfigs: s.lsp_server_configs ?? {}, - savedLayouts: s.saved_layouts ?? [], + }; +} + +function mapEditorSidebarFields( + s: NonNullable["settings"]>, +) { + return { sidebarViews: (s.sidebar_views ?? []).map(fromApiSidebarView), - loaded: true as const, + sidebarActiveViewId: s.sidebar_active_view_id || null, + sidebarDraft: s.sidebar_draft ? fromApiSidebarDraft(s.sidebar_draft) : null, + sidebarTaskPrefs: { + pinnedTaskIds: s.sidebar_task_prefs?.pinned_task_ids ?? [], + orderedTaskIds: s.sidebar_task_prefs?.ordered_task_ids ?? [], + subtaskOrderByParentId: s.sidebar_task_prefs?.subtask_order_by_parent_id ?? {}, + }, + }; +} + +function mapEditorSyncedLocalFields( + s: NonNullable["settings"]>, +) { + return { + taskCreateLastUsed: { + repositoryId: s.task_create_last_used?.repository_id || null, + branch: s.task_create_last_used?.branch || null, + agentProfileId: s.task_create_last_used?.agent_profile_id || null, + executorProfileId: s.task_create_last_used?.executor_profile_id || null, + }, + jiraSavedViews: s.jira_saved_views, + jiraTaskPresets: s.jira_task_presets, + githubSavedPresets: s.github_saved_presets, + githubDefaultQueryPresets: s.github_default_query_presets, + gitlabSavedPresets: s.gitlab_saved_presets, }; } diff --git a/apps/web/components/state-provider.test.tsx b/apps/web/components/state-provider.test.tsx index bbd5014a7e..61660b3593 100644 --- a/apps/web/components/state-provider.test.tsx +++ b/apps/web/components/state-provider.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; import { StateProvider, useAppStore } from "./state-provider"; function ShowMetricsPreference({ label }: { label: string }) { @@ -32,6 +33,11 @@ function EnableMetricsFromNestedProvider() { } describe("StateProvider", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.restoreAllMocks(); + }); + it("reuses the parent store for nested route providers", async () => { render( @@ -54,4 +60,58 @@ describe("StateProvider", () => { fireEvent.click(screen.getByRole("button", { name: "Enable metrics" })); expect(await screen.findByText("root:on")).toBeTruthy(); }); + + it("syncs task-create last-used settings to localStorage without redundant writes", async () => { + function UpdateUnrelatedSetting() { + const setUserSettings = useAppStore((state) => state.setUserSettings); + const userSettings = useAppStore((state) => state.userSettings); + return ( + + ); + } + + const setItemSpy = vi.spyOn(Storage.prototype, "setItem"); + render( + + + , + ); + + await waitFor(() => { + expect(window.localStorage.getItem(STORAGE_KEYS.LAST_REPOSITORY_ID)).toBe( + JSON.stringify("repo-1"), + ); + }); + const writesAfterInitialSync = setItemSpy.mock.calls.length; + + fireEvent.click(screen.getByRole("button", { name: "Toggle unrelated" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Toggle unrelated" })).toBeTruthy(); + }); + expect(setItemSpy).toHaveBeenCalledTimes(writesAfterInitialSync); + }); }); diff --git a/apps/web/components/state-provider.tsx b/apps/web/components/state-provider.tsx index 02201b37cb..4bff0922a8 100644 --- a/apps/web/components/state-provider.tsx +++ b/apps/web/components/state-provider.tsx @@ -6,6 +6,8 @@ import { useStore } from "zustand"; import { isDebug, registerSessionTaskResolver } from "@/lib/debug/log"; import type { AppState, StoreProviderProps } from "@/lib/state/store"; import { createAppStore } from "@/lib/state/store"; +import { removeLocalStorage, setLocalStorage } from "@/lib/local-storage"; +import { STORAGE_KEYS } from "@/lib/settings/constants"; const StoreContext = createContext | null>(null); @@ -31,6 +33,22 @@ export function StateProvider({ children, initialState }: StoreProviderProps) { } }, [store]); + useEffect(() => { + syncTaskCreateLastUsedCache(store.getState()); + return store.subscribe((state, prevState) => { + if ( + state.userSettings.loaded === prevState.userSettings.loaded && + taskCreateLastUsedEqual( + state.userSettings.taskCreateLastUsed, + prevState.userSettings.taskCreateLastUsed, + ) + ) { + return; + } + syncTaskCreateLastUsedCache(state); + }); + }, [store]); + // In debug builds, let the namespaced debug logger annotate every line that // carries a sessionId with `task_id=<...>` so console/log filters can scope to // a single task (see lib/debug/log.ts). No-op in production. @@ -44,6 +62,44 @@ export function StateProvider({ children, initialState }: StoreProviderProps) { return {children}; } +function syncTaskCreateLastUsedCache(state: AppState) { + if (!state.userSettings.loaded) return; + const lastUsed = state.userSettings.taskCreateLastUsed; + if (!lastUsed) return; + if (lastUsed.repositoryId) { + setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, lastUsed.repositoryId); + } else { + removeLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID); + } + if (lastUsed.branch) { + setLocalStorage(STORAGE_KEYS.LAST_BRANCH, lastUsed.branch); + } else { + removeLocalStorage(STORAGE_KEYS.LAST_BRANCH); + } + if (lastUsed.agentProfileId) { + setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, lastUsed.agentProfileId); + } else { + removeLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID); + } + if (lastUsed.executorProfileId) { + setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, lastUsed.executorProfileId); + } else { + removeLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID); + } +} + +function taskCreateLastUsedEqual( + a: AppState["userSettings"]["taskCreateLastUsed"], + b: AppState["userSettings"]["taskCreateLastUsed"], +) { + return ( + a?.repositoryId === b?.repositoryId && + a?.branch === b?.branch && + a?.agentProfileId === b?.agentProfileId && + a?.executorProfileId === b?.executorProfileId + ); +} + export function useAppStore(selector: (state: AppState) => T) { const store = useContext(StoreContext); if (!store) { diff --git a/apps/web/components/task-create-dialog-handlers.test.ts b/apps/web/components/task-create-dialog-handlers.test.ts index e8e719949f..1190bf8433 100644 --- a/apps/web/components/task-create-dialog-handlers.test.ts +++ b/apps/web/components/task-create-dialog-handlers.test.ts @@ -1,96 +1,47 @@ -import { describe, it, expect, vi } from "vitest"; -import { renderHook, act } from "@testing-library/react"; -import { useDialogHandlers } from "./task-create-dialog-handlers"; -import type { DialogFormState } from "@/components/task-create-dialog-types"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { resetTaskCreateLastUsedSync, syncTaskCreateLastUsed } from "./task-create-dialog-handlers"; -/** - * Minimal stand-in for `DialogFormState`. We only exercise the source-mode - * toggles here, so most fields are populated with safe defaults and the rest - * are filled in by an `as` cast. Mutating setters are wired to vi.fn() and - * the simple state flags are recomputed on each set so a test can assert the - * mutually-exclusive behavior end-to-end. - */ -type ToggleState = { - useRemote: boolean; - noRepository: boolean; -}; +const PENDING_LAST_USED_SYNC_KEY = "kandev.taskCreateLastUsed.pendingSync"; -function makeFs(initial: Partial = {}): DialogFormState { - const state: ToggleState = { - useRemote: initial.useRemote ?? false, - noRepository: initial.noRepository ?? false, - }; - const setUseRemote = vi.fn((v: boolean) => { - state.useRemote = v; +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: vi.fn(), +})); + +describe("syncTaskCreateLastUsed", () => { + beforeEach(() => { + window.localStorage.clear(); + resetTaskCreateLastUsedSync(); + vi.mocked(updateUserSettings).mockReset(); }); - const setNoRepository = vi.fn((v: boolean) => { - state.noRepository = v; - }); - const fs = { - get useRemote() { - return state.useRemote; - }, - get noRepository() { - return state.noRepository; - }, - setUseRemote, - setNoRepository, - setGitHubUrlError: vi.fn(), - setFreshBranchEnabled: vi.fn(), - setCurrentLocalBranch: vi.fn(), - setCurrentLocalBranchLoading: vi.fn(), - setExecutorId: vi.fn(), - setExecutorProfileId: vi.fn(), - setWorkspacePath: vi.fn(), - setAgentProfileId: vi.fn(), - setTaskName: vi.fn(), - setHasTitle: vi.fn(), - setSelectedWorkflowId: vi.fn(), - updateRepository: vi.fn(), - } as unknown as DialogFormState; - return fs; -} -describe("useDialogHandlers — Remote / no-repository mutual exclusion", () => { - it("toggling Remote ON clears noRepository (mutually exclusive source modes)", () => { - // Regression: handleToggleRemote used to flip useRemote without touching - // noRepository, so the user could end up with both true at once (toggle - // no-repo on, then toggle Remote on). The submit gate's mode-aware checks - // then produced confusing results. - const fs = makeFs({ useRemote: false, noRepository: true }); - const { result } = renderHook(() => useDialogHandlers(fs, [])); + it("persists failed last-used patches and retries them after an in-memory reset", async () => { + vi.mocked(updateUserSettings).mockRejectedValueOnce(new Error("network")); - act(() => { - result.current.handleToggleRemote(); + syncTaskCreateLastUsed({ branch: "feature" }); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledWith({ + task_create_last_used: { branch: "feature" }, + }); + }); + expect(JSON.parse(window.localStorage.getItem(PENDING_LAST_USED_SYNC_KEY) ?? "null")).toEqual({ + branch: "feature", }); - expect(fs.setUseRemote).toHaveBeenCalledWith(true); - expect(fs.setNoRepository).toHaveBeenCalledWith(false); - }); + resetTaskCreateLastUsedSync(); + vi.mocked(updateUserSettings).mockResolvedValueOnce({ settings: {} } as Awaited< + ReturnType + >); - it("toggling Remote OFF does NOT touch noRepository", () => { - // When the user explicitly leaves Remote mode we shouldn't force noRepo - // on or off — they may already be in some non-Remote workspace flow. - const fs = makeFs({ useRemote: true, noRepository: false }); - const { result } = renderHook(() => useDialogHandlers(fs, [])); + syncTaskCreateLastUsed({}); - act(() => { - result.current.handleToggleRemote(); + await waitFor(() => { + expect(updateUserSettings).toHaveBeenLastCalledWith({ + task_create_last_used: { branch: "feature" }, + }); + expect(window.localStorage.getItem(PENDING_LAST_USED_SYNC_KEY)).toBeNull(); }); - - expect(fs.setUseRemote).toHaveBeenCalledWith(false); - expect(fs.setNoRepository).not.toHaveBeenCalled(); - }); - - it("toggling no-repository ON clears useRemote (mirror of the Remote handler)", () => { - const fs = makeFs({ useRemote: true, noRepository: false }); - const { result } = renderHook(() => useDialogHandlers(fs, [])); - - act(() => { - result.current.handleToggleNoRepository(); - }); - - expect(fs.setNoRepository).toHaveBeenCalledWith(true); - expect(fs.setUseRemote).toHaveBeenCalledWith(false); }); }); diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index b8dfc948dc..d5307268df 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -3,8 +3,67 @@ import { useCallback } from "react"; import type { Repository } from "@/lib/types/http"; import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dialog-types"; -import { setLocalStorage } from "@/lib/local-storage"; +import { removeLocalStorage, setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; + +type TaskCreateLastUsedPatch = { + repository_id?: string; + branch?: string; + agent_profile_id?: string; + executor_profile_id?: string; +}; + +let pendingLastUsed: TaskCreateLastUsedPatch = {}; +let lastUsedSync = Promise.resolve(); +const PENDING_LAST_USED_SYNC_KEY = "kandev.taskCreateLastUsed.pendingSync"; + +export function resetTaskCreateLastUsedSync() { + pendingLastUsed = {}; +} + +function readPendingLastUsedSync(): TaskCreateLastUsedPatch { + if (typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(PENDING_LAST_USED_SYNC_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (!parsed || typeof parsed !== "object") return {}; + const record = parsed as Record; + return { + repository_id: typeof record.repository_id === "string" ? record.repository_id : undefined, + branch: typeof record.branch === "string" ? record.branch : undefined, + agent_profile_id: + typeof record.agent_profile_id === "string" ? record.agent_profile_id : undefined, + executor_profile_id: + typeof record.executor_profile_id === "string" ? record.executor_profile_id : undefined, + }; + } catch { + return {}; + } +} + +function persistPendingLastUsedSync(patch: TaskCreateLastUsedPatch) { + setLocalStorage(PENDING_LAST_USED_SYNC_KEY, patch as Record); +} + +export function syncTaskCreateLastUsed(patch: TaskCreateLastUsedPatch) { + pendingLastUsed = { ...readPendingLastUsedSync(), ...pendingLastUsed, ...patch }; + const payload = { ...pendingLastUsed }; + persistPendingLastUsedSync(payload); + lastUsedSync = lastUsedSync + .catch(() => undefined) + .then(() => + updateUserSettings({ task_create_last_used: payload }) + .then(() => { + if (JSON.stringify(pendingLastUsed) === JSON.stringify(payload)) { + pendingLastUsed = {}; + removeLocalStorage(PENDING_LAST_USED_SYNC_KEY); + } + }) + .catch(() => undefined), + ); +} /** * Centralizes form-field change handlers for the task-create dialog. @@ -42,7 +101,10 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) ? { repositoryId: value, localPath: undefined, branch: "" } : { repositoryId: undefined, localPath: value, branch: "" }; fs.updateRepository(key, patch); - if (isWorkspaceRepo) setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); + if (isWorkspaceRepo) { + setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); + syncTaskCreateLastUsed({ repository_id: value }); + } // Switching the repo invalidates whatever local-status the fresh-branch // panel had cached. clearFreshBranch(fs); @@ -54,6 +116,7 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) (key: string, value: string) => { fs.updateRepository(key, { branch: value }); setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); + syncTaskCreateLastUsed({ branch: value }); }, [fs], ); @@ -66,6 +129,7 @@ function useProfileAndNameHandlers(fs: DialogFormState) { (value: string) => { fs.setAgentProfileId(value); setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value); + syncTaskCreateLastUsed({ agent_profile_id: value }); }, [fs], ); @@ -73,6 +137,7 @@ function useProfileAndNameHandlers(fs: DialogFormState) { (value: string) => { fs.setExecutorProfileId(value); setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value); + syncTaskCreateLastUsed({ executor_profile_id: value }); }, [fs], ); diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index 0a3cf51f9a..521a623d3e 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormEvent, useCallback } from "react"; +import { FormEvent, useCallback, useEffect } from "react"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog"; @@ -42,6 +42,7 @@ import { buildDialogFooterProps, buildDialogFormBodyProps, } from "@/components/task-create-dialog-prop-builders"; +import { resetTaskCreateLastUsedSync } from "@/components/task-create-dialog-handlers"; export interface TaskCreateDialogProps { open: boolean; @@ -481,6 +482,9 @@ const VOICE_SUBMIT_EVENT = { preventDefault: () => {} } as unknown as FormEvent; export function TaskCreateDialog(props: TaskCreateDialogProps) { const setup = useTaskCreateDialogSetup(props); const { guardedHandleSubmit } = setup; + useEffect(() => { + if (!props.open) resetTaskCreateLastUsedSync(); + }, [props.open]); // Voice auto-send invokes the same submit handler as the in-form Submit // button. Every existing validation gate (missing title/repo/branch/agent, // `submitBlockedReason`, in-flight create) still applies because they live diff --git a/apps/web/hooks/use-sidebar-views-sync.test.ts b/apps/web/hooks/use-sidebar-views-sync.test.ts new file mode 100644 index 0000000000..04faa0921a --- /dev/null +++ b/apps/web/hooks/use-sidebar-views-sync.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { DEFAULT_VIEW_ID } from "@/lib/state/slices/ui/sidebar-view-builtins"; +import { useSidebarViewsSync } from "./use-sidebar-views-sync"; + +const mockToast = vi.fn(); + +type MockState = { + sidebarViews: { views: Array<{ id: string }>; syncError: string | null }; + sidebarTaskPrefs: { syncError?: string | null }; + userSettings: { loaded: boolean }; + migrateLocalViewsToBackend: () => void; + clearSidebarSyncError: () => void; + clearSidebarTaskPrefsSyncError: () => void; +}; + +let mockState: MockState; + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: MockState) => unknown) => selector(mockState), +})); + +vi.mock("@/components/toast-provider", () => ({ + useToast: () => ({ toast: mockToast }), +})); + +describe("useSidebarViewsSync", () => { + beforeEach(() => { + mockToast.mockReset(); + mockState = { + sidebarViews: { views: [], syncError: null }, + sidebarTaskPrefs: { syncError: null }, + userSettings: { loaded: true }, + migrateLocalViewsToBackend: vi.fn(), + clearSidebarSyncError: vi.fn(), + clearSidebarTaskPrefsSyncError: vi.fn(), + }; + }); + + it("toasts and clears task preference sync errors", async () => { + mockState.sidebarTaskPrefs.syncError = "backend unavailable"; + + renderHook(() => useSidebarViewsSync()); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: "Sidebar task preferences", + description: "backend unavailable", + variant: "error", + }); + expect(mockState.clearSidebarTaskPrefsSyncError).toHaveBeenCalled(); + }); + }); + + it("migrates custom local views only once before user settings load", async () => { + mockState.userSettings.loaded = false; + mockState.sidebarViews.views = [{ id: DEFAULT_VIEW_ID }, { id: "custom-view" }]; + + const { rerender } = renderHook(() => useSidebarViewsSync()); + + await waitFor(() => { + expect(mockState.migrateLocalViewsToBackend).toHaveBeenCalledTimes(1); + }); + + rerender(); + + expect(mockState.migrateLocalViewsToBackend).toHaveBeenCalledTimes(1); + }); + + it("toasts and clears sidebar view sync errors", async () => { + mockState.sidebarViews.syncError = "boom"; + + renderHook(() => useSidebarViewsSync()); + + await waitFor(() => { + expect(mockToast).toHaveBeenCalledWith({ + title: "Sidebar views", + description: "boom", + variant: "error", + }); + expect(mockState.clearSidebarSyncError).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/web/hooks/use-sidebar-views-sync.ts b/apps/web/hooks/use-sidebar-views-sync.ts index a12fb8f0c8..02cbde6482 100644 --- a/apps/web/hooks/use-sidebar-views-sync.ts +++ b/apps/web/hooks/use-sidebar-views-sync.ts @@ -12,9 +12,11 @@ import { DEFAULT_VIEW_ID } from "@/lib/state/slices/ui/sidebar-view-builtins"; export function useSidebarViewsSync() { const views = useAppStore((s) => s.sidebarViews.views); const syncError = useAppStore((s) => s.sidebarViews.syncError); + const taskPrefsSyncError = useAppStore((s) => s.sidebarTaskPrefs.syncError); const userSettingsLoaded = useAppStore((s) => s.userSettings.loaded); const migrate = useAppStore((s) => s.migrateLocalViewsToBackend); const clearError = useAppStore((s) => s.clearSidebarSyncError); + const clearTaskPrefsError = useAppStore((s) => s.clearSidebarTaskPrefsSyncError); const { toast } = useToast(); const migratedRef = useRef(false); @@ -39,4 +41,10 @@ export function useSidebarViewsSync() { toast({ title: "Sidebar views", description: syncError, variant: "error" }); clearError(); }, [syncError, toast, clearError]); + + useEffect(() => { + if (!taskPrefsSyncError) return; + toast({ title: "Sidebar task preferences", description: taskPrefsSyncError, variant: "error" }); + clearTaskPrefsError(); + }, [taskPrefsSyncError, toast, clearTaskPrefsError]); } diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index af8b191ea1..3e411fcdc6 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -57,7 +57,6 @@ function carryForwardCoreSettings(current: DisplaySettings) { showReleaseNotification: current.showReleaseNotification ?? true, releaseNotesLastSeenVersion: current.releaseNotesLastSeenVersion ?? null, savedLayouts: current.savedLayouts ?? [], - sidebarViews: current.sidebarViews ?? [], defaultUtilityAgentId: current.defaultUtilityAgentId ?? null, keyboardShortcuts: current.keyboardShortcuts ?? {}, changesPanelLayout: current.changesPanelLayout ?? "tree", @@ -66,9 +65,40 @@ function carryForwardCoreSettings(current: DisplaySettings) { }; } +function carryForwardSidebarSettings(current: DisplaySettings) { + return { + sidebarViews: current.sidebarViews ?? [], + sidebarActiveViewId: current.sidebarActiveViewId ?? null, + sidebarDraft: current.sidebarDraft ?? null, + sidebarTaskPrefs: current.sidebarTaskPrefs ?? { + pinnedTaskIds: [], + orderedTaskIds: [], + subtaskOrderByParentId: {}, + }, + }; +} + +function carryForwardSyncedLocalSettings(current: DisplaySettings) { + return { + taskCreateLastUsed: current.taskCreateLastUsed ?? { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + }, + jiraSavedViews: current.jiraSavedViews, + jiraTaskPresets: current.jiraTaskPresets, + githubSavedPresets: current.githubSavedPresets, + githubDefaultQueryPresets: current.githubDefaultQueryPresets, + gitlabSavedPresets: current.gitlabSavedPresets, + }; +} + function carryForwardSettings(current: DisplaySettings) { return { ...carryForwardCoreSettings(current), + ...carryForwardSidebarSettings(current), + ...carryForwardSyncedLocalSettings(current), ...carryForwardLspSettings(current), ...carryForwardTerminalSettings(current), }; diff --git a/apps/web/lib/api/domains/settings-api.ts b/apps/web/lib/api/domains/settings-api.ts index 07f09e3c9d..f0e311511a 100644 --- a/apps/web/lib/api/domains/settings-api.ts +++ b/apps/web/lib/api/domains/settings-api.ts @@ -16,12 +16,10 @@ import type { EditorOption, CustomPrompt, PromptsResponse, - SavedLayout, - SidebarViewApi, UserSettingsResponse, + UserSettingsUpdatePayload, DynamicModelsResponse, } from "@/lib/types/http"; -import type { VoiceModeSettings } from "@/lib/types/http-voice"; import type { SystemMetricsGlobalSettings, SystemMetricsSettingsResponse, @@ -33,33 +31,7 @@ export async function fetchUserSettings(options?: ApiRequestOptions) { } export async function updateUserSettings( - payload: { - workspace_id?: string; - workflow_filter_id?: string; - kanban_view_mode?: string; - repository_ids?: string[]; - preferred_shell?: string; - default_editor_id?: string; - enable_preview_on_click?: boolean; - chat_submit_key?: "enter" | "cmd_enter"; - review_auto_mark_on_scroll?: boolean; - show_release_notification?: boolean; - release_notes_last_seen_version?: string; - lsp_auto_start_languages?: string[]; - lsp_auto_install_languages?: string[]; - lsp_server_configs?: Record>; - saved_layouts?: SavedLayout[]; - sidebar_views?: SidebarViewApi[]; - default_utility_agent_id?: string; - default_utility_model?: string; - keyboard_shortcuts?: Record }>; - terminal_link_behavior?: "new_tab" | "browser_panel"; - terminal_font_family?: string; - terminal_font_size?: number; - changes_panel_layout?: "flat" | "tree"; - system_metrics_display?: { show_in_topbar?: boolean }; - voice_mode?: VoiceModeSettings; - }, + payload: UserSettingsUpdatePayload, options?: ApiRequestOptions, ) { return fetchJson("/api/v1/user/settings", { diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 002be5ebc6..0ff04c54e5 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -5,11 +5,12 @@ import { parseChangesPanelLayout, parseVoiceMode, } from "./user-settings"; +import { workspaceId as toWorkspaceId } from "@/lib/types/ids"; describe("buildCoreFields", () => { it("maps terminal_font_family to terminalFontFamily", () => { const settings = { - workspace_id: "", + workspace_id: toWorkspaceId(""), workflow_filter_id: "", kanban_view_mode: "", repository_ids: [], @@ -35,7 +36,7 @@ describe("buildCoreFields", () => { it("returns null when terminal_font_family is empty", () => { const settings = { - workspace_id: "", + workspace_id: toWorkspaceId(""), workflow_filter_id: "", kanban_view_mode: "", repository_ids: [], @@ -94,6 +95,42 @@ describe("mapUserSettingsResponse", () => { const result = mapUserSettingsResponse(null); expect(result.changesPanelLayout).toBe("tree"); }); + + it("maps sidebar active view and draft state", () => { + const result = mapUserSettingsResponse({ + settings: { + user_id: "default-user", + workspace_id: toWorkspaceId(""), + repository_ids: [], + sidebar_views: [ + { + id: "all", + name: "All", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "none", + collapsed_groups: [], + }, + ], + sidebar_active_view_id: "all", + sidebar_draft: { + base_view_id: "all", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }, + updated_at: "2026-01-01T00:00:00Z", + }, + }); + + expect(result.sidebarActiveViewId).toBe("all"); + expect(result.sidebarDraft).toEqual({ + baseViewId: "all", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }); + }); }); describe("parseChangesPanelLayout", () => { diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 13347cd229..9e330e13b2 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -1,7 +1,7 @@ -import { fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; -import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types"; +import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; +import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types"; import { DEFAULT_VOICE_MODE_STATE, type VoiceModeState } from "@/lib/state/slices/settings/types"; -import type { SavedLayout, UserSettingsResponse } from "@/lib/types/http"; +import type { SavedLayout, SidebarTaskPrefsApi, UserSettingsResponse } from "@/lib/types/http"; import type { VoiceModeSettings } from "@/lib/types/http-voice"; export type UserSettingsData = NonNullable; @@ -56,6 +56,23 @@ function buildSystemMetricsDisplayFields(s: UserSettingsData | undefined) { }; } +function parseSidebarTaskPrefs(value: SidebarTaskPrefsApi | undefined) { + return { + pinnedTaskIds: value?.pinned_task_ids ?? [], + orderedTaskIds: value?.ordered_task_ids ?? [], + subtaskOrderByParentId: value?.subtask_order_by_parent_id ?? {}, + }; +} + +function parseTaskCreateLastUsed(value: UserSettingsData["task_create_last_used"] | undefined) { + return { + repositoryId: value?.repository_id || null, + branch: value?.branch || null, + agentProfileId: value?.agent_profile_id || null, + executorProfileId: value?.executor_profile_id || null, + }; +} + function buildIdentityFields(s: UserSettingsData) { return { workspaceId: s.workspace_id || null, @@ -85,6 +102,17 @@ export function buildCoreFields(s: UserSettingsData) { ...buildBehaviorFields(s), savedLayouts: s.saved_layouts ?? [], sidebarViews: (s.sidebar_views ?? []).map(fromApiSidebarView) as SidebarView[], + sidebarActiveViewId: s.sidebar_active_view_id || null, + sidebarDraft: s.sidebar_draft + ? (fromApiSidebarDraft(s.sidebar_draft) as SidebarViewDraft) + : null, + sidebarTaskPrefs: parseSidebarTaskPrefs(s.sidebar_task_prefs), + taskCreateLastUsed: parseTaskCreateLastUsed(s.task_create_last_used), + jiraSavedViews: s.jira_saved_views, + jiraTaskPresets: s.jira_task_presets, + githubSavedPresets: s.github_saved_presets, + githubDefaultQueryPresets: s.github_default_query_presets, + gitlabSavedPresets: s.gitlab_saved_presets, ...buildTerminalFields(s), ...buildSystemMetricsDisplayFields(s), ...buildVoiceModeFields(s), @@ -122,6 +150,15 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) { releaseNotesLastSeenVersion: null, savedLayouts: [] as SavedLayout[], sidebarViews: [] as SidebarView[], + sidebarActiveViewId: null, + sidebarDraft: null, + sidebarTaskPrefs: parseSidebarTaskPrefs(undefined), + taskCreateLastUsed: parseTaskCreateLastUsed(undefined), + jiraSavedViews: undefined, + jiraTaskPresets: undefined, + githubSavedPresets: undefined, + githubDefaultQueryPresets: undefined, + gitlabSavedPresets: undefined, defaultUtilityAgentId: null, keyboardShortcuts: {} as Record }>, terminalLinkBehavior: "new_tab" as const, diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index 170f64aa34..b57f868f09 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -1,8 +1,9 @@ import { beforeEach, describe, expect, it } from "vitest"; import { produce } from "immer"; import type { Draft } from "immer"; -import { hydrateUI } from "./hydrator"; +import { hydrateState, hydrateUI } from "./hydrator"; import { defaultUIState } from "@/lib/state/slices/ui/ui-slice"; +import { defaultState } from "@/lib/state/default-state"; import type { AppState } from "@/lib/state/store"; function makeDraft(): AppState { @@ -11,6 +12,10 @@ function makeDraft(): AppState { return { ...defaultUIState } as unknown as AppState; } +function makeAppDraft(): AppState { + return structuredClone(defaultState) as AppState; +} + describe("hydrateUI — quick chat name overlay", () => { beforeEach(() => { window.localStorage.clear(); @@ -71,3 +76,110 @@ describe("hydrateUI — quick chat name overlay", () => { expect(result.quickChat.sessions.map((s) => s.name)).toEqual(["Renamed A", "Original B"]); }); }); + +describe("hydrateState — sidebar views from user settings", () => { + it("hydrates active view and draft from backend user settings", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.sidebarViews.activeViewId = "local"; + hydrateState(draft, { + userSettings: { + sidebarViews: [ + { + id: "server", + name: "Server", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "none", + collapsedGroups: [], + }, + ], + sidebarActiveViewId: "server", + sidebarDraft: { + baseViewId: "server", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }, + }, + } as unknown as Partial); + }); + + expect(result.sidebarViews.activeViewId).toBe("server"); + expect(result.sidebarViews.draft).toEqual({ + baseViewId: "server", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }); + }); + + it("clears stale local draft when backend draft is null", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.sidebarViews.draft = { + baseViewId: "local", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "state", + }; + hydrateState(draft, { + userSettings: { + sidebarDraft: null, + }, + } as unknown as Partial); + }); + + expect(result.sidebarViews.draft).toBeNull(); + }); + + it("hydrates sidebar task prefs from backend, including explicit clears", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.sidebarTaskPrefs = { + pinnedTaskIds: ["local-pin"], + orderedTaskIds: ["local-order"], + subtaskOrderByParentId: { parent: ["child"] }, + }; + hydrateState(draft, { + userSettings: { + sidebarTaskPrefs: { + pinnedTaskIds: [], + orderedTaskIds: [], + subtaskOrderByParentId: {}, + }, + }, + } as unknown as Partial); + }); + + expect(result.sidebarTaskPrefs).toEqual({ + pinnedTaskIds: [], + orderedTaskIds: [], + subtaskOrderByParentId: {}, + }); + }); + + it("uses backend sidebar task prefs as the authoritative hydrated value", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.sidebarTaskPrefs = { + pinnedTaskIds: ["local-pin"], + orderedTaskIds: ["local-order"], + subtaskOrderByParentId: { shared: ["local-child"], localOnly: ["child"] }, + syncError: "retry", + }; + hydrateState(draft, { + userSettings: { + sidebarTaskPrefs: { + pinnedTaskIds: ["server-pin"], + orderedTaskIds: ["server-order"], + subtaskOrderByParentId: { shared: ["server-child"], serverOnly: ["child"] }, + }, + }, + } as unknown as Partial); + }); + + expect(result.sidebarTaskPrefs).toEqual({ + pinnedTaskIds: ["server-pin"], + orderedTaskIds: ["server-order"], + subtaskOrderByParentId: { shared: ["server-child"], serverOnly: ["child"] }, + syncError: "retry", + }); + }); +}); diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index f359f47d6c..a39c10c91a 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -88,11 +88,29 @@ function bridgeSidebarViewsFromUserSettings( userSettings: Partial, ): void { const serverViews = userSettings.sidebarViews; - if (!serverViews || serverViews.length === 0) return; - const normalized = serverViews.map(migrateView); - draft.sidebarViews.views = normalized; - if (!normalized.some((v) => v.id === draft.sidebarViews.activeViewId)) { - draft.sidebarViews.activeViewId = normalized[0].id; + const normalized = serverViews?.map(migrateView) ?? []; + if (normalized.length > 0) { + draft.sidebarViews.views = normalized; + } + if ( + userSettings.sidebarActiveViewId && + draft.sidebarViews.views.some((v) => v.id === userSettings.sidebarActiveViewId) + ) { + draft.sidebarViews.activeViewId = userSettings.sidebarActiveViewId; + } else if ( + draft.sidebarViews.views.length > 0 && + !draft.sidebarViews.views.some((v) => v.id === draft.sidebarViews.activeViewId) + ) { + draft.sidebarViews.activeViewId = draft.sidebarViews.views[0].id; + } + if (userSettings.sidebarDraft !== undefined) { + draft.sidebarViews.draft = userSettings.sidebarDraft; + } + if (userSettings.sidebarTaskPrefs) { + if (draft.sidebarTaskPrefs.syncPending) return; + const nextPrefs = { ...userSettings.sidebarTaskPrefs }; + if (draft.sidebarTaskPrefs.syncError) nextPrefs.syncError = draft.sidebarTaskPrefs.syncError; + draft.sidebarTaskPrefs = nextPrefs; } } diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 6787c9aefe..cd8a0225ca 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -39,6 +39,20 @@ export const defaultSettingsState: SettingsSliceState = { lspServerConfigs: {}, savedLayouts: [], sidebarViews: [], + sidebarActiveViewId: null, + sidebarDraft: null, + sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} }, + taskCreateLastUsed: { + repositoryId: null, + branch: null, + agentProfileId: null, + executorProfileId: null, + }, + jiraSavedViews: undefined, + jiraTaskPresets: undefined, + githubSavedPresets: undefined, + githubDefaultQueryPresets: undefined, + gitlabSavedPresets: undefined, defaultUtilityAgentId: null, keyboardShortcuts: {}, terminalFontFamily: null, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index ccdbe7e6ce..1c9d49e44c 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -16,7 +16,8 @@ import type { VoiceInputEngine, WhisperWebModelSize, } from "@/lib/types/http-voice"; -import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types"; +import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types"; +import type { SidebarTaskPrefsState } from "@/lib/state/slices/ui/types"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites"; @@ -155,6 +156,15 @@ export type UserSettingsState = { lspServerConfigs: Record>; savedLayouts: SavedLayout[]; sidebarViews: SidebarView[]; + sidebarActiveViewId: string | null; + sidebarDraft: SidebarViewDraft | null; + sidebarTaskPrefs: SidebarTaskPrefsState; + taskCreateLastUsed: TaskCreateLastUsedState; + jiraSavedViews: unknown; + jiraTaskPresets: unknown; + githubSavedPresets: unknown; + githubDefaultQueryPresets: unknown; + gitlabSavedPresets: unknown; defaultUtilityAgentId: string | null; keyboardShortcuts: Record }>; terminalLinkBehavior: "new_tab" | "browser_panel"; @@ -166,6 +176,13 @@ export type UserSettingsState = { loaded: boolean; }; +export type TaskCreateLastUsedState = { + repositoryId: string | null; + branch: string | null; + agentProfileId: string | null; + executorProfileId: string | null; +}; + export type VoiceModeState = { enabled: boolean; engine: VoiceInputEngine; diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts index 63a02688dc..81102b74f7 100644 --- a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -4,48 +4,108 @@ import { setStoredPinnedTaskIds, setStoredSubtaskOrderByParentId, } from "@/lib/local-storage"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; import type { UISlice } from "./types"; type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void; -export function buildSidebarTaskPrefsActions(set: ImmerSet) { +let sidebarTaskPrefsSync = Promise.resolve(); +let sidebarTaskPrefsSyncVersion = 0; + +function syncSidebarTaskPrefs(prefs: UISlice["sidebarTaskPrefs"], set: ImmerSet) { + const syncVersion = ++sidebarTaskPrefsSyncVersion; + const payload = { + sidebar_task_prefs: { + pinned_task_ids: [...prefs.pinnedTaskIds], + ordered_task_ids: [...prefs.orderedTaskIds], + subtask_order_by_parent_id: Object.fromEntries( + Object.entries(prefs.subtaskOrderByParentId).map(([key, ids]) => [key, [...ids]]), + ), + }, + }; + sidebarTaskPrefsSync = sidebarTaskPrefsSync + .catch(() => undefined) + .then(() => + updateUserSettings(payload) + .then(() => { + set((draft) => { + if (syncVersion !== sidebarTaskPrefsSyncVersion) return; + draft.sidebarTaskPrefs.syncError = null; + draft.sidebarTaskPrefs.syncPending = false; + }); + }) + .catch((err) => { + const message = err instanceof Error ? err.message : "Failed to sync sidebar task prefs"; + set((draft) => { + if (syncVersion !== sidebarTaskPrefsSyncVersion) return; + draft.sidebarTaskPrefs.syncError = message; + draft.sidebarTaskPrefs.syncPending = false; + }); + }) + .then(() => undefined), + ); +} + +export function buildSidebarTaskPrefsActions(set: ImmerSet, get: () => UISlice) { return { - togglePinnedTask: (taskId: string) => + clearSidebarTaskPrefsSyncError: () => + set((draft) => { + draft.sidebarTaskPrefs.syncError = null; + }), + togglePinnedTask: (taskId: string) => { set((draft) => { const list = draft.sidebarTaskPrefs.pinnedTaskIds; + draft.sidebarTaskPrefs.syncPending = true; const idx = list.indexOf(taskId); if (idx === -1) list.push(taskId); else list.splice(idx, 1); setStoredPinnedTaskIds(list); - }), - setSidebarTaskOrder: (orderedTaskIds: string[]) => + }); + syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, + setSidebarTaskOrder: (orderedTaskIds: string[]) => { set((draft) => { + draft.sidebarTaskPrefs.syncPending = true; draft.sidebarTaskPrefs.orderedTaskIds = orderedTaskIds; setStoredOrderedTaskIds(orderedTaskIds); - }), - setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => + }); + syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, + setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => { set((draft) => { + draft.sidebarTaskPrefs.syncPending = true; const map = draft.sidebarTaskPrefs.subtaskOrderByParentId; if (orderedSubtaskIds.length === 0) delete map[parentTaskId]; else map[parentTaskId] = orderedSubtaskIds; setStoredSubtaskOrderByParentId(map); - }), - removeTaskFromSidebarPrefs: (taskId: string) => + }); + syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, + removeTaskFromSidebarPrefs: (taskId: string) => { + let changed = false; set((draft) => { const prefs = draft.sidebarTaskPrefs; const pinIdx = prefs.pinnedTaskIds.indexOf(taskId); if (pinIdx !== -1) { + changed = true; + prefs.syncPending = true; prefs.pinnedTaskIds.splice(pinIdx, 1); setStoredPinnedTaskIds(prefs.pinnedTaskIds); } const orderIdx = prefs.orderedTaskIds.indexOf(taskId); if (orderIdx !== -1) { + changed = true; + prefs.syncPending = true; prefs.orderedTaskIds.splice(orderIdx, 1); setStoredOrderedTaskIds(prefs.orderedTaskIds); } if (pruneSubtaskOrder(prefs.subtaskOrderByParentId, taskId)) { + changed = true; + prefs.syncPending = true; setStoredSubtaskOrderByParentId(prefs.subtaskOrderByParentId); } - }), + }); + if (changed) syncSidebarTaskPrefs(get().sidebarTaskPrefs, set); + }, }; } diff --git a/apps/web/lib/state/slices/ui/sidebar-view-actions.ts b/apps/web/lib/state/slices/ui/sidebar-view-actions.ts new file mode 100644 index 0000000000..19d2fb1267 --- /dev/null +++ b/apps/web/lib/state/slices/ui/sidebar-view-actions.ts @@ -0,0 +1,293 @@ +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { + removeStoredSidebarDraft, + setStoredSidebarActiveViewId, + setStoredSidebarDraft, + setStoredSidebarUserViews, +} from "@/lib/local-storage"; +import type { UISlice, UISliceState } from "./types"; +import type { + FilterClause, + GroupKey, + SidebarView, + SidebarViewDraft, + SortSpec, +} from "./sidebar-view-types"; +import { toApiSidebarDraft, toApiSidebarView } from "./sidebar-view-wire"; + +type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void; + +function persistUserViews(views: SidebarView[]): void { + setStoredSidebarUserViews(views); +} + +function makeId(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; +} + +function reorderViewsById( + views: SidebarView[], + activeViewId: string, + overViewId: string, +): SidebarView[] | null { + if (activeViewId === overViewId) return null; + const oldIndex = views.findIndex((v) => v.id === activeViewId); + const newIndex = views.findIndex((v) => v.id === overViewId); + if (oldIndex === -1 || newIndex === -1) return null; + const next = [...views]; + const [moved] = next.splice(oldIndex, 1); + next.splice(newIndex, 0, moved); + return next; +} + +let viewsSyncRequestId = 0; + +type SidebarSnapshot = { + views: SidebarView[]; + activeViewId: string; + draft: SidebarViewDraft | null; +}; + +function snapshotSidebar(s: UISliceState["sidebarViews"]): SidebarSnapshot { + return { + views: s.views.map(cloneView), + activeViewId: s.activeViewId, + draft: s.draft ? { ...s.draft } : null, + }; +} + +function writeCacheFromSidebar(s: SidebarSnapshot | UISliceState["sidebarViews"]) { + persistUserViews(s.views); + setStoredSidebarActiveViewId(s.activeViewId); + if (s.draft) setStoredSidebarDraft(s.draft); + else removeStoredSidebarDraft(); +} + +function toSidebarSettingsPayload(s: SidebarSnapshot | UISliceState["sidebarViews"]) { + return { + sidebar_views: s.views.map(toApiSidebarView), + sidebar_active_view_id: s.activeViewId, + sidebar_draft: s.draft ? toApiSidebarDraft(s.draft) : null, + }; +} + +function draftsEqual(a: SidebarViewDraft | null, b: SidebarViewDraft | null): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function syncSidebarViewState( + set: ImmerSet, + payload: { + sidebar_active_view_id: string; + sidebar_draft: ReturnType | null; + }, +) { + updateUserSettings(payload).catch((err) => { + const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; + set((draft) => { + draft.sidebarViews.syncError = message; + }); + }); +} + +function mutateViews( + set: ImmerSet, + get: () => UISlice, + mutate: (slice: UISliceState["sidebarViews"]) => boolean | void, +): void { + const snapshot = snapshotSidebar(get().sidebarViews); + let committed = false; + set((draft) => { + committed = mutate(draft.sidebarViews) !== false; + }); + if (!committed) return; + const after = get().sidebarViews; + const afterSnapshot = snapshotSidebar(after); + writeCacheFromSidebar(after); + const thisRequestId = ++viewsSyncRequestId; + updateUserSettings(toSidebarSettingsPayload(after)).catch((err) => { + if (thisRequestId !== viewsSyncRequestId) return; + const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; + set((draft) => { + draft.sidebarViews.views = snapshot.views; + if (draft.sidebarViews.activeViewId === afterSnapshot.activeViewId) { + draft.sidebarViews.activeViewId = snapshot.activeViewId; + } + if (draftsEqual(draft.sidebarViews.draft, afterSnapshot.draft)) { + draft.sidebarViews.draft = snapshot.draft; + } + draft.sidebarViews.syncError = message; + }); + writeCacheFromSidebar(get().sidebarViews); + }); +} + +function buildSidebarLocalActions(set: ImmerSet, get: () => UISlice) { + return { + setSidebarActiveView: (viewId: string) => { + let committed = false; + set((draft) => { + if (!draft.sidebarViews.views.some((v) => v.id === viewId)) return; + committed = true; + draft.sidebarViews.activeViewId = viewId; + draft.sidebarViews.draft = null; + setStoredSidebarActiveViewId(viewId); + removeStoredSidebarDraft(); + }); + if (!committed) return; + syncSidebarViewState(set, { sidebar_active_view_id: viewId, sidebar_draft: null }); + }, + updateSidebarDraft: ( + patch: Partial<{ filters: FilterClause[]; sort: SortSpec; group: GroupKey }>, + ) => { + let committed = false; + set((draft) => { + const active = draft.sidebarViews.views.find( + (v) => v.id === draft.sidebarViews.activeViewId, + ); + if (!active) return; + committed = true; + const current: SidebarViewDraft = draft.sidebarViews.draft ?? { + baseViewId: active.id, + filters: active.filters, + sort: active.sort, + group: active.group, + }; + const next: SidebarViewDraft = { + baseViewId: active.id, + filters: patch.filters ?? current.filters, + sort: patch.sort ?? current.sort, + group: patch.group ?? current.group, + }; + draft.sidebarViews.draft = next; + setStoredSidebarDraft(next); + }); + if (!committed) return; + const { activeViewId, draft } = get().sidebarViews; + syncSidebarViewState(set, { + sidebar_active_view_id: activeViewId, + sidebar_draft: draft ? toApiSidebarDraft(draft) : null, + }); + }, + discardSidebarDraft: () => { + set((draft) => { + draft.sidebarViews.draft = null; + removeStoredSidebarDraft(); + }); + syncSidebarViewState(set, { + sidebar_active_view_id: get().sidebarViews.activeViewId, + sidebar_draft: null, + }); + }, + clearSidebarSyncError: () => + set((draft) => { + draft.sidebarViews.syncError = null; + }), + toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => { + set((draft) => { + const view = draft.sidebarViews.views.find((v) => v.id === viewId); + if (!view) return; + const idx = view.collapsedGroups.indexOf(groupKey); + if (idx === -1) view.collapsedGroups.push(groupKey); + else view.collapsedGroups.splice(idx, 1); + }); + persistUserViews(get().sidebarViews.views); + }, + migrateLocalViewsToBackend: () => { + const sidebar = get().sidebarViews; + const thisRequestId = ++viewsSyncRequestId; + updateUserSettings(toSidebarSettingsPayload(sidebar)).catch((err) => { + if (thisRequestId !== viewsSyncRequestId) return; + const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; + set((draft) => { + draft.sidebarViews.syncError = message; + }); + }); + }, + }; +} + +function buildSidebarBackendActions(set: ImmerSet, get: () => UISlice) { + const mv = (mutate: (s: UISliceState["sidebarViews"]) => boolean | void) => + mutateViews(set, get, mutate); + return { + saveSidebarDraftAs: (name: string) => + mv((s) => { + if (!s.draft) return false; + s.views.push({ + id: makeId("view"), + name: name.trim() || "Untitled view", + filters: s.draft.filters, + sort: s.draft.sort, + group: s.draft.group, + collapsedGroups: [], + }); + s.activeViewId = s.views[s.views.length - 1].id; + s.draft = null; + }), + saveSidebarDraftOverwrite: () => + mv((s) => { + if (!s.draft) return false; + const view = s.views.find((v) => v.id === s.draft!.baseViewId); + if (!view) return false; + view.filters = s.draft.filters; + view.sort = s.draft.sort; + view.group = s.draft.group; + s.draft = null; + }), + duplicateSidebarView: (viewId: string, name: string) => + mv((s) => { + const source = s.views.find((v) => v.id === viewId); + if (!source) return false; + s.views.push({ + id: makeId("view"), + name: name.trim() || `${source.name} copy`, + filters: source.filters.map((f) => ({ ...f, id: makeId("clause") })), + sort: source.sort, + group: source.group, + collapsedGroups: [], + }); + s.activeViewId = s.views[s.views.length - 1].id; + }), + deleteSidebarView: (viewId: string) => + mv((s) => { + const remaining = s.views.filter((v) => v.id !== viewId); + if (remaining.length === 0) return false; + s.views = remaining; + if (s.activeViewId === viewId) s.activeViewId = remaining[0].id; + s.draft = null; + }), + renameSidebarView: (viewId: string, name: string) => + mv((s) => { + const view = s.views.find((v) => v.id === viewId); + if (!view) return false; + const next = name.trim(); + if (!next || next === view.name) return false; + view.name = next; + }), + reorderSidebarViews: (activeViewId: string, overViewId: string) => + mv((s) => { + const reordered = reorderViewsById(s.views, activeViewId, overViewId); + if (!reordered) return false; + s.views = reordered; + }), + }; +} + +export function buildSidebarViewActions(set: ImmerSet, get: () => UISlice) { + return { + ...buildSidebarLocalActions(set, get), + ...buildSidebarBackendActions(set, get), + }; +} + +function cloneView(v: SidebarView): SidebarView { + return { + id: v.id, + name: v.name, + filters: v.filters.map((f) => ({ ...f })), + sort: { ...v.sort }, + group: v.group, + collapsedGroups: [...v.collapsedGroups], + }; +} diff --git a/apps/web/lib/state/slices/ui/sidebar-view-wire.ts b/apps/web/lib/state/slices/ui/sidebar-view-wire.ts index eec8daeefa..1b0e9a24c8 100644 --- a/apps/web/lib/state/slices/ui/sidebar-view-wire.ts +++ b/apps/web/lib/state/slices/ui/sidebar-view-wire.ts @@ -1,16 +1,29 @@ -import type { SidebarViewApi } from "@/lib/types/http"; -import type { SidebarView } from "./sidebar-view-types"; +import type { SidebarViewApi, SidebarViewDraftApi } from "@/lib/types/http"; +import type { SidebarView, SidebarViewDraft } from "./sidebar-view-types"; + +function toApiClause(c: SidebarView["filters"][number]) { + return { + id: c.id, + dimension: c.dimension, + op: c.op, + value: c.value, + }; +} + +function fromApiClause(c: SidebarViewApi["filters"][number]): SidebarView["filters"][number] { + return { + id: c.id, + dimension: c.dimension as SidebarView["filters"][number]["dimension"], + op: c.op as SidebarView["filters"][number]["op"], + value: c.value as SidebarView["filters"][number]["value"], + }; +} export function toApiSidebarView(view: SidebarView): SidebarViewApi { return { id: view.id, name: view.name, - filters: view.filters.map((c) => ({ - id: c.id, - dimension: c.dimension, - op: c.op, - value: c.value, - })), + filters: view.filters.map(toApiClause), sort: { key: view.sort.key, direction: view.sort.direction }, group: view.group, collapsed_groups: view.collapsedGroups, @@ -21,12 +34,7 @@ export function fromApiSidebarView(api: SidebarViewApi): SidebarView { return { id: api.id, name: api.name, - filters: api.filters.map((c) => ({ - id: c.id, - dimension: c.dimension as SidebarView["filters"][number]["dimension"], - op: c.op as SidebarView["filters"][number]["op"], - value: c.value as SidebarView["filters"][number]["value"], - })), + filters: api.filters.map(fromApiClause), sort: { key: api.sort.key as SidebarView["sort"]["key"], direction: api.sort.direction as SidebarView["sort"]["direction"], @@ -35,3 +43,24 @@ export function fromApiSidebarView(api: SidebarViewApi): SidebarView { collapsedGroups: api.collapsed_groups ?? [], }; } + +export function toApiSidebarDraft(draft: SidebarViewDraft): SidebarViewDraftApi { + return { + base_view_id: draft.baseViewId, + filters: draft.filters.map(toApiClause), + sort: { key: draft.sort.key, direction: draft.sort.direction }, + group: draft.group, + }; +} + +export function fromApiSidebarDraft(api: SidebarViewDraftApi): SidebarViewDraft { + return { + baseViewId: api.base_view_id, + filters: api.filters.map(fromApiClause), + sort: { + key: api.sort.key as SidebarView["sort"]["key"], + direction: api.sort.direction as SidebarView["sort"]["direction"], + }, + group: api.group as SidebarView["group"], + }; +} diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index dd2034f993..42afca4163 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -114,6 +114,8 @@ export type SidebarTaskPrefsState = { * Independent of the global `orderedTaskIds` and the view's sort spec. */ subtaskOrderByParentId: Record; + syncError?: string | null; + syncPending?: boolean; }; /** Unified AppSidebar collapse + per-section expand state (localStorage). */ @@ -150,7 +152,7 @@ export type UISliceState = { collapsedSubtaskParents: string[]; /** Task ID currently shown in the kanban preview side-panel, or null if closed. */ kanbanPreviewedTaskId: string | null; - /** Sidebar pin + manual-order. Per-browser, persisted to localStorage. */ + /** Sidebar pin + manual-order. Synced to backend, with localStorage fallback. */ sidebarTaskPrefs: SidebarTaskPrefsState; /** Unified AppSidebar collapse + section expand state (localStorage). */ appSidebar: AppSidebarState; @@ -211,6 +213,7 @@ export type UISliceActions = { toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => void; toggleSubtaskCollapsed: (parentTaskId: string) => void; clearSidebarSyncError: () => void; + clearSidebarTaskPrefsSyncError: () => void; migrateLocalViewsToBackend: () => void; setKanbanPreviewedTaskId: (taskId: string | null) => void; togglePinnedTask: (taskId: string) => void; diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts index 0ff1c3d239..89a5eed553 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { create } from "zustand"; +import { create, type StoreApi, type UseBoundStore } from "zustand"; +import { waitFor } from "@testing-library/react"; import { immer } from "zustand/middleware/immer"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { createUISlice } from "./ui-slice"; @@ -18,10 +19,15 @@ function makeStore() { ); } +type UIStore = UseBoundStore>; + const KEY = "kandev.sidebar.collapsedSubtasks"; const TASK_A = "task-a"; const TASK_B = "task-b"; const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views"; +const SIDEBAR_ACTIVE_VIEW_KEY = "kandev.sidebar.activeViewId"; +const SIDEBAR_DRAFT_KEY = "kandev.sidebar.draft"; +const BACKEND_DOWN = "backend down"; function makeSidebarView(id: string, name: string) { return { @@ -80,6 +86,9 @@ describe("sidebar task prefs (pin + manual order)", () => { beforeEach(() => { window.localStorage.clear(); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); }); it("hydrates pinned + ordered from localStorage", () => { @@ -142,6 +151,83 @@ describe("sidebar task prefs (pin + manual order)", () => { expect(store.getState().sidebarTaskPrefs.pinnedTaskIds).toEqual(["t1"]); expect(window.localStorage.getItem(PINNED_KEY)).toBe(before); }); + + it("records sync errors and clears them after a later successful sync", async () => { + const store = makeStore(); + vi.mocked(updateUserSettings).mockRejectedValueOnce(new Error(BACKEND_DOWN)); + + store.getState().togglePinnedTask("t1"); + + await waitFor(() => { + expect(store.getState().sidebarTaskPrefs.syncError).toBe(BACKEND_DOWN); + expect(store.getState().sidebarTaskPrefs.syncPending).toBe(false); + }); + + vi.mocked(updateUserSettings).mockResolvedValueOnce({ + settings: {}, + } as Awaited>); + + store.getState().togglePinnedTask("t2"); + + await waitFor(() => { + expect(store.getState().sidebarTaskPrefs.syncError).toBeNull(); + }); + }); + + it("clears task preference sync errors on demand", async () => { + const store = makeStore(); + vi.mocked(updateUserSettings).mockRejectedValueOnce(new Error(BACKEND_DOWN)); + store.getState().togglePinnedTask("t1"); + + await waitFor(() => { + expect(store.getState().sidebarTaskPrefs.syncError).toBe(BACKEND_DOWN); + }); + + store.getState().clearSidebarTaskPrefsSyncError(); + + expect(store.getState().sidebarTaskPrefs.syncError).toBeNull(); + }); +}); + +describe("sidebar view sync rollback", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.mocked(updateUserSettings).mockResolvedValue({ + settings: {}, + } as Awaited>); + }); + + function seedViews(store: UIStore) { + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + views: [makeSidebarView("view-a", "View A"), makeSidebarView("view-b", "View B")], + activeViewId: "view-a", + draft: null, + }, + })); + } + + it("does not roll back an active view changed after a failed view mutation", async () => { + const store = makeStore(); + seedViews(store); + vi.mocked(updateUserSettings) + .mockRejectedValueOnce(new Error("rename failed")) + .mockResolvedValueOnce({ + settings: {}, + } as Awaited>); + + store.getState().renameSidebarView("view-a", "Renamed"); + store.getState().setSidebarActiveView("view-b"); + + await waitFor(() => { + expect(store.getState().sidebarViews.syncError).toBe("rename failed"); + }); + + expect(store.getState().sidebarViews.activeViewId).toBe("view-b"); + expect(store.getState().sidebarViews.views.find((v) => v.id === "view-a")?.name).toBe("View A"); + }); }); describe("setSubtaskOrder", () => { @@ -360,6 +446,8 @@ describe("reorderSidebarViews", () => { expect.objectContaining({ id: "two" }), expect.objectContaining({ id: "one" }), ], + sidebar_active_view_id: "two", + sidebar_draft: null, }); }); @@ -412,3 +500,101 @@ describe("reorderSidebarViews", () => { expect(updateUserSettings).not.toHaveBeenCalled(); }); }); + +describe("sidebar view backend state", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.mocked(updateUserSettings).mockClear(); + }); + + it("syncs active view changes to backend user settings", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + views: [makeSidebarView("all", "All"), makeSidebarView("mine", "Mine")], + activeViewId: "all", + draft: { + baseViewId: "all", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "state", + }, + }, + })); + + store.getState().setSidebarActiveView("mine"); + + expect(store.getState().sidebarViews.activeViewId).toBe("mine"); + expect(window.localStorage.getItem(SIDEBAR_ACTIVE_VIEW_KEY)).toBe(JSON.stringify("mine")); + expect(window.localStorage.getItem(SIDEBAR_DRAFT_KEY)).toBeNull(); + expect(updateUserSettings).toHaveBeenCalledWith({ + sidebar_active_view_id: "mine", + sidebar_draft: null, + }); + }); + + it("syncs filter sort and group drafts to backend user settings", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + views: [makeSidebarView("all", "All")], + activeViewId: "all", + draft: null, + }, + })); + + store.getState().updateSidebarDraft({ + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }); + + expect(updateUserSettings).toHaveBeenCalledWith({ + sidebar_active_view_id: "all", + sidebar_draft: { + base_view_id: "all", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + }, + }); + }); + + it("includes active view and draft state when syncing saved view mutations", () => { + const draft: SidebarViewDraft = { + baseViewId: "all", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "state", + }; + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + views: [makeSidebarView("all", "All"), makeSidebarView("two", "Two")], + activeViewId: "all", + draft, + }, + })); + + store.getState().reorderSidebarViews("two", "all"); + + expect(updateUserSettings).toHaveBeenCalledWith({ + sidebar_views: [ + expect.objectContaining({ id: "two" }), + expect.objectContaining({ id: "all" }), + ], + sidebar_active_view_id: "all", + sidebar_draft: { + base_view_id: "all", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "state", + }, + }); + }); +}); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 8f4c093dec..4979b9abb8 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -1,5 +1,4 @@ import type { StateCreator } from "zustand"; -import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { getStoredCollapsedSubtaskParents, getStoredOrderedTaskIds, @@ -8,12 +7,9 @@ import { getStoredSidebarDraft, getStoredSidebarUserViews, getStoredSubtaskOrderByParentId, - removeStoredSidebarDraft, setLocalStorage, setStoredCollapsedSubtaskParents, setStoredQuickChatName, - setStoredSidebarActiveViewId, - setStoredSidebarDraft, setStoredSidebarUserViews, } from "@/lib/local-storage"; import { buildDismissedAgentErrors } from "./dismissed-agent-errors-actions"; @@ -24,15 +20,9 @@ import { } from "./app-sidebar-actions"; import { APP_SIDEBAR_EXPANDED_WIDTH } from "@/components/app-sidebar/app-sidebar-constants"; import { buildSidebarTaskPrefsActions } from "./sidebar-task-prefs-actions"; +import { buildSidebarViewActions } from "./sidebar-view-actions"; import { DEFAULT_ACTIVE_VIEW_ID, DEFAULT_VIEW } from "./sidebar-view-builtins"; -import type { - FilterClause, - GroupKey, - SidebarView, - SidebarViewDraft, - SortSpec, -} from "./sidebar-view-types"; -import { toApiSidebarView } from "./sidebar-view-wire"; +import type { SidebarView, SidebarViewDraft, SortSpec } from "./sidebar-view-types"; import type { SystemHealthResponse } from "@/lib/types/health"; import type { ActiveDocument, UISlice, UISliceState } from "./types"; @@ -81,29 +71,6 @@ export function migrateView(view: SidebarView): SidebarView { }; } -function persistUserViews(views: SidebarView[]): void { - setStoredSidebarUserViews(views); -} - -function makeId(prefix: string): string { - return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; -} - -function reorderViewsById( - views: SidebarView[], - activeViewId: string, - overViewId: string, -): SidebarView[] | null { - if (activeViewId === overViewId) return null; - const oldIndex = views.findIndex((v) => v.id === activeViewId); - const newIndex = views.findIndex((v) => v.id === overViewId); - if (oldIndex === -1 || newIndex === -1) return null; - const next = [...views]; - const [moved] = next.splice(oldIndex, 1); - next.splice(newIndex, 0, moved); - return next; -} - export const defaultUIState: UISliceState = { previewPanel: { openBySessionId: {}, @@ -236,214 +203,6 @@ function buildBottomTerminalActions(set: ImmerSet) { }; } -// Tracks the most recent in-flight views PATCH. On failure, only the latest -// request is allowed to revert — earlier failed requests are ignored because -// their state is already stale (a newer action is in flight). -let viewsSyncRequestId = 0; - -type SidebarSnapshot = { - views: SidebarView[]; - activeViewId: string; - draft: SidebarViewDraft | null; -}; - -function snapshotSidebar(s: UISliceState["sidebarViews"]): SidebarSnapshot { - return { - views: s.views.map(cloneView), - activeViewId: s.activeViewId, - draft: s.draft ? { ...s.draft } : null, - }; -} - -function writeCacheFromSidebar(s: SidebarSnapshot | UISliceState["sidebarViews"]) { - persistUserViews(s.views); - setStoredSidebarActiveViewId(s.activeViewId); - if (s.draft) setStoredSidebarDraft(s.draft); - else removeStoredSidebarDraft(); -} - -function mutateViews( - set: ImmerSet, - get: () => UISlice, - mutate: (slice: UISliceState["sidebarViews"]) => boolean | void, -): void { - const snapshot = snapshotSidebar(get().sidebarViews); - let committed = false; - set((draft) => { - committed = mutate(draft.sidebarViews) !== false; - }); - if (!committed) return; - const after = get().sidebarViews; - writeCacheFromSidebar(after); - const thisRequestId = ++viewsSyncRequestId; - updateUserSettings({ sidebar_views: after.views.map(toApiSidebarView) }).catch((err) => { - if (thisRequestId !== viewsSyncRequestId) return; - const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; - set((draft) => { - draft.sidebarViews.views = snapshot.views; - draft.sidebarViews.activeViewId = snapshot.activeViewId; - draft.sidebarViews.draft = snapshot.draft; - draft.sidebarViews.syncError = message; - }); - writeCacheFromSidebar(snapshot); - }); -} - -function buildSidebarLocalActions(set: ImmerSet, get: () => UISlice) { - return { - setSidebarActiveView: (viewId: string) => - set((draft) => { - if (!draft.sidebarViews.views.some((v) => v.id === viewId)) return; - draft.sidebarViews.activeViewId = viewId; - draft.sidebarViews.draft = null; - setStoredSidebarActiveViewId(viewId); - removeStoredSidebarDraft(); - }), - updateSidebarDraft: ( - patch: Partial<{ filters: FilterClause[]; sort: SortSpec; group: GroupKey }>, - ) => - set((draft) => { - const active = draft.sidebarViews.views.find( - (v) => v.id === draft.sidebarViews.activeViewId, - ); - if (!active) return; - const current: SidebarViewDraft = draft.sidebarViews.draft ?? { - baseViewId: active.id, - filters: active.filters, - sort: active.sort, - group: active.group, - }; - const next: SidebarViewDraft = { - baseViewId: active.id, - filters: patch.filters ?? current.filters, - sort: patch.sort ?? current.sort, - group: patch.group ?? current.group, - }; - draft.sidebarViews.draft = next; - setStoredSidebarDraft(next); - }), - discardSidebarDraft: () => - set((draft) => { - draft.sidebarViews.draft = null; - removeStoredSidebarDraft(); - }), - clearSidebarSyncError: () => - set((draft) => { - draft.sidebarViews.syncError = null; - }), - // collapsedGroups is per-device visual state; update in memory and the - // localStorage cache only. Don't PATCH — we'd flood the backend on every - // expand/collapse click, and the server-side copy is stale anyway across - // devices. The next mutateViews call picks up any newer local state. - toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => { - set((draft) => { - const view = draft.sidebarViews.views.find((v) => v.id === viewId); - if (!view) return; - const idx = view.collapsedGroups.indexOf(groupKey); - if (idx === -1) view.collapsedGroups.push(groupKey); - else view.collapsedGroups.splice(idx, 1); - }); - persistUserViews(get().sidebarViews.views); - }, - migrateLocalViewsToBackend: () => { - const views = get().sidebarViews.views; - const thisRequestId = ++viewsSyncRequestId; - updateUserSettings({ sidebar_views: views.map(toApiSidebarView) }).catch((err) => { - if (thisRequestId !== viewsSyncRequestId) return; - const message = err instanceof Error ? err.message : "Failed to sync sidebar views"; - set((draft) => { - draft.sidebarViews.syncError = message; - }); - }); - }, - }; -} - -function buildSidebarBackendActions(set: ImmerSet, get: () => UISlice) { - const mv = (mutate: (s: UISliceState["sidebarViews"]) => boolean | void) => - mutateViews(set, get, mutate); - return { - saveSidebarDraftAs: (name: string) => - mv((s) => { - if (!s.draft) return false; - s.views.push({ - id: makeId("view"), - name: name.trim() || "Untitled view", - filters: s.draft.filters, - sort: s.draft.sort, - group: s.draft.group, - collapsedGroups: [], - }); - s.activeViewId = s.views[s.views.length - 1].id; - s.draft = null; - }), - saveSidebarDraftOverwrite: () => - mv((s) => { - if (!s.draft) return false; - const view = s.views.find((v) => v.id === s.draft!.baseViewId); - if (!view) return false; - view.filters = s.draft.filters; - view.sort = s.draft.sort; - view.group = s.draft.group; - s.draft = null; - }), - duplicateSidebarView: (viewId: string, name: string) => - mv((s) => { - const source = s.views.find((v) => v.id === viewId); - if (!source) return false; - s.views.push({ - id: makeId("view"), - name: name.trim() || `${source.name} copy`, - filters: source.filters.map((f) => ({ ...f, id: makeId("clause") })), - sort: source.sort, - group: source.group, - collapsedGroups: [], - }); - s.activeViewId = s.views[s.views.length - 1].id; - }), - deleteSidebarView: (viewId: string) => - mv((s) => { - const remaining = s.views.filter((v) => v.id !== viewId); - if (remaining.length === 0) return false; - s.views = remaining; - if (s.activeViewId === viewId) s.activeViewId = remaining[0].id; - s.draft = null; - }), - renameSidebarView: (viewId: string, name: string) => - mv((s) => { - const view = s.views.find((v) => v.id === viewId); - if (!view) return false; - const next = name.trim(); - if (!next || next === view.name) return false; - view.name = next; - }), - reorderSidebarViews: (activeViewId: string, overViewId: string) => - mv((s) => { - const reordered = reorderViewsById(s.views, activeViewId, overViewId); - if (!reordered) return false; - s.views = reordered; - }), - }; -} - -function buildSidebarViewActions(set: ImmerSet, get: () => UISlice) { - return { - ...buildSidebarLocalActions(set, get), - ...buildSidebarBackendActions(set, get), - }; -} - -function cloneView(v: SidebarView): SidebarView { - return { - id: v.id, - name: v.name, - filters: v.filters.map((f) => ({ ...f })), - sort: { ...v.sort }, - group: v.group, - collapsedGroups: [...v.collapsedGroups], - }; -} - function buildSystemHealthActions(set: ImmerSet) { return { setSystemHealth: (response: SystemHealthResponse) => @@ -553,7 +312,7 @@ export const createUISlice: StateCreator }>; terminal_link_behavior?: string; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts new file mode 100644 index 0000000000..34398069d1 --- /dev/null +++ b/apps/web/lib/types/http-user-settings.ts @@ -0,0 +1,121 @@ +import type { WorkspaceId } from "./ids"; +import type { VoiceModeSettings } from "./http-voice"; + +export type SavedLayout = { + id: string; + name: string; + is_default: boolean; + layout: Record; + created_at: string; +}; + +export type SidebarViewApi = { + id: string; + name: string; + filters: Array<{ id: string; dimension: string; op: string; value: unknown }>; + sort: { key: string; direction: string }; + group: string; + collapsed_groups: string[]; +}; + +export type SidebarViewDraftApi = { + base_view_id: string; + filters: Array<{ id: string; dimension: string; op: string; value: unknown }>; + sort: { key: string; direction: string }; + group: string; +}; + +export type SidebarTaskPrefsApi = { + pinned_task_ids: string[]; + ordered_task_ids: string[]; + subtask_order_by_parent_id: Record; +}; + +export type TaskCreateLastUsedApi = { + repository_id?: string; + branch?: string; + agent_profile_id?: string; + executor_profile_id?: string; +}; + +export type UserSettings = { + user_id: string; + workspace_id: WorkspaceId; + kanban_view_mode?: string; + workflow_filter_id?: string; + repository_ids: string[]; + initial_setup_complete?: boolean; + preferred_shell?: string; + default_editor_id?: string; + enable_preview_on_click?: boolean; + chat_submit_key?: "enter" | "cmd_enter"; + review_auto_mark_on_scroll?: boolean; + show_release_notification?: boolean; + release_notes_last_seen_version?: string; + lsp_auto_start_languages?: string[]; + lsp_auto_install_languages?: string[]; + lsp_server_configs?: Record>; + saved_layouts?: SavedLayout[]; + sidebar_views?: SidebarViewApi[]; + sidebar_active_view_id?: string; + sidebar_draft?: SidebarViewDraftApi | null; + sidebar_task_prefs?: SidebarTaskPrefsApi; + task_create_last_used?: TaskCreateLastUsedApi; + jira_saved_views?: unknown; + jira_task_presets?: unknown; + github_saved_presets?: unknown; + github_default_query_presets?: unknown; + gitlab_saved_presets?: unknown; + default_utility_agent_id?: string; + default_utility_model?: string; + keyboard_shortcuts?: Record }>; + terminal_link_behavior?: string; + terminal_font_family?: string; + terminal_font_size?: number; + changes_panel_layout?: "flat" | "tree"; + system_metrics_display?: { show_in_topbar?: boolean }; + voice_mode?: VoiceModeSettings; + updated_at: string; +}; + +export type UserSettingsResponse = { + settings: UserSettings; + shell_options?: Array<{ value: string; label: string }>; +}; + +export type UserSettingsUpdatePayload = { + workspace_id?: string; + workflow_filter_id?: string; + kanban_view_mode?: string; + repository_ids?: string[]; + preferred_shell?: string; + default_editor_id?: string; + enable_preview_on_click?: boolean; + chat_submit_key?: "enter" | "cmd_enter"; + review_auto_mark_on_scroll?: boolean; + show_release_notification?: boolean; + release_notes_last_seen_version?: string; + lsp_auto_start_languages?: string[]; + lsp_auto_install_languages?: string[]; + lsp_server_configs?: Record>; + saved_layouts?: SavedLayout[]; + sidebar_views?: SidebarViewApi[]; + sidebar_active_view_id?: string; + sidebar_draft?: SidebarViewDraftApi | null; + sidebar_task_prefs?: SidebarTaskPrefsApi; + task_create_last_used?: TaskCreateLastUsedApi; + jira_saved_views?: unknown[] | null; + jira_task_presets?: unknown[] | null; + github_saved_presets?: unknown[] | null; + github_default_query_presets?: object | null; + gitlab_saved_presets?: unknown[] | null; + default_utility_agent_id?: string; + default_utility_model?: string; + keyboard_shortcuts?: Record }>; + terminal_link_behavior?: "new_tab" | "browser_panel"; + terminal_font_family?: string; + terminal_font_size?: number; + changes_panel_layout?: "flat" | "tree"; + system_metrics_display?: { show_in_topbar?: boolean }; + voice_mode?: VoiceModeSettings; +}; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 4d06c4b9d8..9cb7e23b79 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -1,4 +1,5 @@ import type { ExecutorType } from "./executor"; +import type { UserSettings } from "./http-user-settings"; import type { AgentProfileId, RepositoryId, @@ -10,6 +11,16 @@ import type { import type { OnEnterActionType, StepEvents } from "./workflow-actions"; export type { ExecutorType } from "./executor"; +export type { + SavedLayout, + SidebarViewApi, + SidebarViewDraftApi, + SidebarTaskPrefsApi, + TaskCreateLastUsedApi, + UserSettings, + UserSettingsResponse, + UserSettingsUpdatePayload, +} from "./http-user-settings"; export * from "./ids"; export type { MoveToStepConfig, @@ -397,59 +408,6 @@ export type User = { updated_at: string; }; -export type SavedLayout = { - id: string; - name: string; - is_default: boolean; - layout: Record; - created_at: string; -}; - -export type SidebarViewApi = { - id: string; - name: string; - filters: Array<{ id: string; dimension: string; op: string; value: unknown }>; - sort: { key: string; direction: string }; - group: string; - collapsed_groups: string[]; -}; - -export type UserSettings = { - user_id: string; - workspace_id: WorkspaceId; - kanban_view_mode?: string; - workflow_filter_id?: string; - repository_ids: string[]; - initial_setup_complete?: boolean; - preferred_shell?: string; - default_editor_id?: string; - enable_preview_on_click?: boolean; - chat_submit_key?: "enter" | "cmd_enter"; - review_auto_mark_on_scroll?: boolean; - show_release_notification?: boolean; - release_notes_last_seen_version?: string; - lsp_auto_start_languages?: string[]; - lsp_auto_install_languages?: string[]; - lsp_server_configs?: Record>; - saved_layouts?: SavedLayout[]; - sidebar_views?: SidebarViewApi[]; - default_utility_agent_id?: string; - default_utility_model?: string; - keyboard_shortcuts?: Record }>; - terminal_link_behavior?: string; - terminal_font_family?: string; - terminal_font_size?: number; - changes_panel_layout?: "flat" | "tree"; - system_metrics_display?: { show_in_topbar?: boolean }; - voice_mode?: import("./http-voice").VoiceModeSettings; - updated_at: string; -}; - -export type UserSettingsResponse = { - settings: UserSettings; - shell_options?: Array<{ value: string; label: string }>; -}; - export type EditorOption = { id: string; type: string; diff --git a/apps/web/lib/user-settings-sync-failure.ts b/apps/web/lib/user-settings-sync-failure.ts new file mode 100644 index 0000000000..669639bf27 --- /dev/null +++ b/apps/web/lib/user-settings-sync-failure.ts @@ -0,0 +1,18 @@ +export function hasUserSettingsSyncFailure(storageKey: string): boolean { + if (typeof window === "undefined") return false; + try { + return window.localStorage.getItem(storageKey) === "1"; + } catch { + return false; + } +} + +export function setUserSettingsSyncFailure(storageKey: string, failed: boolean): void { + if (typeof window === "undefined") return; + try { + if (failed) window.localStorage.setItem(storageKey, "1"); + else window.localStorage.removeItem(storageKey); + } catch { + /* ignore storage failures */ + } +} diff --git a/apps/web/lib/user-settings-sync.test.ts b/apps/web/lib/user-settings-sync.test.ts new file mode 100644 index 0000000000..8bbe3f3bcb --- /dev/null +++ b/apps/web/lib/user-settings-sync.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { waitFor } from "@testing-library/react"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { createQueuedUserSettingsSync } from "./user-settings-sync"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ + updateUserSettings: vi.fn(), +})); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +describe("createQueuedUserSettingsSync", () => { + beforeEach(() => { + window.localStorage.clear(); + vi.mocked(updateUserSettings).mockReset(); + }); + + it("serializes backend writes so later payloads are sent after earlier ones finish", async () => { + const first = deferred>>(); + vi.mocked(updateUserSettings) + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce({ settings: {} } as Awaited>); + const sync = createQueuedUserSettingsSync("sync-key", (value) => ({ + preferred_shell: value, + })); + + void sync("bash"); + void sync("zsh"); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledTimes(1); + }); + expect(updateUserSettings).toHaveBeenCalledWith({ preferred_shell: "bash" }); + + first.resolve({ settings: {} } as Awaited>); + + await waitFor(() => { + expect(updateUserSettings).toHaveBeenCalledTimes(2); + expect(updateUserSettings).toHaveBeenLastCalledWith({ preferred_shell: "zsh" }); + }); + }); +}); diff --git a/apps/web/lib/user-settings-sync.ts b/apps/web/lib/user-settings-sync.ts new file mode 100644 index 0000000000..3ca1b57cd8 --- /dev/null +++ b/apps/web/lib/user-settings-sync.ts @@ -0,0 +1,25 @@ +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { setUserSettingsSyncFailure } from "@/lib/user-settings-sync-failure"; +import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings"; + +export function createQueuedUserSettingsSync( + syncFailedKey: string, + buildPayload: (value: T) => UserSettingsUpdatePayload, +): (value: T) => Promise { + let queue = Promise.resolve(); + return (value: T) => { + const payload = buildPayload(value); + queue = queue + .catch(() => undefined) + .then(() => + updateUserSettings(payload) + .then(() => { + setUserSettingsSyncFailure(syncFailedKey, false); + }) + .catch(() => { + setUserSettingsSyncFailure(syncFailedKey, true); + }), + ); + return queue; + }; +} diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts new file mode 100644 index 0000000000..de13d9d186 --- /dev/null +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from "vitest"; +import { createStore } from "zustand/vanilla"; +import { registerUsersHandlers } from "./users"; +import { defaultState } from "@/lib/state/default-state"; +import type { AppState } from "@/lib/state/store"; +import type { BackendMessageMap } from "@/lib/types/backend"; + +function makeStore() { + return createStore(() => structuredClone(defaultState) as AppState); +} + +function userSettingsMessage( + payload: Partial, +): BackendMessageMap["user.settings.updated"] { + return { + type: "notification", + action: "user.settings.updated", + payload: { + user_id: "default", + workspace_id: "workspace", + repository_ids: [], + ...payload, + }, + }; +} + +describe("user settings websocket handler", () => { + it("preserves local collapsed groups when syncing sidebar views", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + activeViewId: "view-1", + views: [ + { + id: "view-1", + name: "Local", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "state", + collapsedGroups: ["state:todo"], + }, + ], + }, + })); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + sidebar_views: [ + { + id: "view-1", + name: "Remote", + filters: [], + sort: { key: "updatedAt", direction: "desc" }, + group: "workflow", + collapsed_groups: [], + }, + ], + sidebar_active_view_id: "view-1", + }), + ); + + expect(store.getState().sidebarViews.views[0]).toMatchObject({ + id: "view-1", + name: "Remote", + collapsedGroups: ["state:todo"], + }); + }); + + it("applies draft clears even when the broadcast has no sidebar views", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarViews: { + ...state.sidebarViews, + draft: { + baseViewId: "view-1", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "state", + }, + }, + })); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ sidebar_views: [], sidebar_draft: null }), + ); + + expect(store.getState().sidebarViews.draft).toBeNull(); + }); +}); + +describe("user settings websocket sidebar settings", () => { + it("preserves userSettings sidebar fields when payload omits them", () => { + const store = makeStore(); + const sidebarView = { + id: "all", + name: "All", + filters: [], + sort: { key: "state" as const, direction: "asc" as const }, + group: "state" as const, + collapsedGroups: [], + }; + store.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + sidebarViews: [sidebarView], + sidebarActiveViewId: "all", + sidebarDraft: { + baseViewId: "all", + filters: [], + sort: { key: "state", direction: "asc" }, + group: "state", + }, + sidebarTaskPrefs: { + pinnedTaskIds: ["task-1"], + orderedTaskIds: ["task-2"], + subtaskOrderByParentId: { parent: ["child"] }, + }, + }, + })); + + registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); + + expect(store.getState().userSettings.sidebarActiveViewId).toBe("all"); + expect(store.getState().userSettings.sidebarDraft).toMatchObject({ baseViewId: "all" }); + expect(store.getState().userSettings.sidebarTaskPrefs).toEqual({ + pinnedTaskIds: ["task-1"], + orderedTaskIds: ["task-2"], + subtaskOrderByParentId: { parent: ["child"] }, + }); + }); + + it("preserves pending local sidebar task prefs when server broadcasts stale prefs", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarTaskPrefs: { + pinnedTaskIds: ["local"], + orderedTaskIds: ["local"], + subtaskOrderByParentId: {}, + syncPending: true, + }, + })); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + sidebar_task_prefs: { + pinned_task_ids: ["server"], + ordered_task_ids: ["server"], + subtask_order_by_parent_id: {}, + }, + }), + ); + + expect(store.getState().sidebarTaskPrefs).toMatchObject({ + pinnedTaskIds: ["local"], + orderedTaskIds: ["local"], + syncPending: true, + }); + }); + + it("applies server sidebar task prefs after a failed sync is no longer pending", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + sidebarTaskPrefs: { + pinnedTaskIds: ["local"], + orderedTaskIds: ["local"], + subtaskOrderByParentId: {}, + syncError: "Failed to sync", + syncPending: false, + }, + })); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + sidebar_task_prefs: { + pinned_task_ids: ["server"], + ordered_task_ids: ["server"], + subtask_order_by_parent_id: {}, + }, + }), + ); + + expect(store.getState().sidebarTaskPrefs).toMatchObject({ + pinnedTaskIds: ["server"], + orderedTaskIds: ["server"], + syncError: "Failed to sync", + }); + }); +}); diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index cb837fe9f0..709ed9af16 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -1,46 +1,150 @@ import type { StoreApi } from "zustand"; import type { AppState } from "@/lib/state/store"; +import type { UserSettingsUpdatedPayload } from "@/lib/types/backend"; import type { WsHandlers } from "@/lib/ws/handlers/types"; import { parseChangesPanelLayout, parseSystemMetricsDisplay, parseVoiceMode, } from "@/lib/ssr/user-settings"; +import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; +import { migrateView } from "@/lib/state/slices/ui/ui-slice"; export function registerUsersHandlers(store: StoreApi): WsHandlers { return { "user.settings.updated": (message) => { store.setState((state) => ({ ...state, - userSettings: { - ...state.userSettings, - // Preserve workspaceId, workflowId, and repositoryIds — these are navigation state - // controlled by SSR and explicit user actions, not WebSocket broadcasts. - // Overwriting them here causes redirect loops / filter resets when the broadcast - // arrives with a stale workspace/workflow/repo from a previous commit. - preferredShell: message.payload.preferred_shell || null, - defaultEditorId: message.payload.default_editor_id || null, - enablePreviewOnClick: message.payload.enable_preview_on_click ?? false, - chatSubmitKey: (message.payload.chat_submit_key as "enter" | "cmd_enter") ?? "cmd_enter", - reviewAutoMarkOnScroll: message.payload.review_auto_mark_on_scroll ?? true, - showReleaseNotification: message.payload.show_release_notification ?? true, - releaseNotesLastSeenVersion: - (message.payload.release_notes_last_seen_version as string) || null, - lspAutoStartLanguages: message.payload.lsp_auto_start_languages ?? [], - lspAutoInstallLanguages: message.payload.lsp_auto_install_languages ?? [], - savedLayouts: message.payload.saved_layouts ?? [], - defaultUtilityAgentId: message.payload.default_utility_agent_id || null, - keyboardShortcuts: message.payload.keyboard_shortcuts ?? {}, - terminalLinkBehavior: - message.payload.terminal_link_behavior === "browser_panel" - ? "browser_panel" - : "new_tab", - changesPanelLayout: parseChangesPanelLayout(message.payload.changes_panel_layout), - systemMetricsDisplay: parseSystemMetricsDisplay(message.payload.system_metrics_display), - voiceMode: parseVoiceMode(message.payload.voice_mode), - loaded: true, - }, + sidebarViews: buildSidebarViewsState(state, message.payload), + sidebarTaskPrefs: buildSidebarTaskPrefsState(state, message.payload), + userSettings: buildUserSettingsState(state, message.payload), })); }, }; } + +function buildUserSettingsState(state: AppState, payload: UserSettingsUpdatedPayload) { + return { + ...state.userSettings, + ...buildBehaviorSettings(payload), + ...buildSidebarSettings(state, payload), + ...buildLspSettings(payload), + ...buildSyncedLocalSettings(payload), + defaultUtilityAgentId: payload.default_utility_agent_id || null, + keyboardShortcuts: payload.keyboard_shortcuts ?? {}, + changesPanelLayout: parseChangesPanelLayout(payload.changes_panel_layout), + systemMetricsDisplay: parseSystemMetricsDisplay(payload.system_metrics_display), + voiceMode: parseVoiceMode(payload.voice_mode), + loaded: true, + }; +} + +function buildLspSettings(payload: UserSettingsUpdatedPayload) { + return { + lspAutoStartLanguages: payload.lsp_auto_start_languages ?? [], + lspAutoInstallLanguages: payload.lsp_auto_install_languages ?? [], + }; +} + +function buildSyncedLocalSettings(payload: UserSettingsUpdatedPayload) { + return { + savedLayouts: payload.saved_layouts ?? [], + taskCreateLastUsed: { + repositoryId: payload.task_create_last_used?.repository_id || null, + branch: payload.task_create_last_used?.branch || null, + agentProfileId: payload.task_create_last_used?.agent_profile_id || null, + executorProfileId: payload.task_create_last_used?.executor_profile_id || null, + }, + jiraSavedViews: payload.jira_saved_views, + jiraTaskPresets: payload.jira_task_presets, + githubSavedPresets: payload.github_saved_presets, + githubDefaultQueryPresets: payload.github_default_query_presets, + gitlabSavedPresets: payload.gitlab_saved_presets, + }; +} + +function buildBehaviorSettings(payload: UserSettingsUpdatedPayload) { + return { + preferredShell: payload.preferred_shell || null, + defaultEditorId: payload.default_editor_id || null, + enablePreviewOnClick: payload.enable_preview_on_click ?? false, + chatSubmitKey: (payload.chat_submit_key as "enter" | "cmd_enter") ?? "cmd_enter", + reviewAutoMarkOnScroll: payload.review_auto_mark_on_scroll ?? true, + showReleaseNotification: payload.show_release_notification ?? true, + releaseNotesLastSeenVersion: (payload.release_notes_last_seen_version as string) || null, + terminalLinkBehavior: + payload.terminal_link_behavior === "browser_panel" + ? ("browser_panel" as const) + : ("new_tab" as const), + }; +} + +function buildSidebarSettings(state: AppState, payload: UserSettingsUpdatedPayload) { + const sidebarViews = + payload.sidebar_views === undefined + ? state.userSettings.sidebarViews + : (payload.sidebar_views ?? []).map(fromApiSidebarView).map(migrateView); + return { + sidebarViews, + sidebarActiveViewId: + payload.sidebar_active_view_id === undefined + ? state.userSettings.sidebarActiveViewId + : payload.sidebar_active_view_id || null, + sidebarDraft: parseSidebarDraftForSettings(state, payload), + sidebarTaskPrefs: + payload.sidebar_task_prefs === undefined + ? state.userSettings.sidebarTaskPrefs + : { + pinnedTaskIds: payload.sidebar_task_prefs.pinned_task_ids ?? [], + orderedTaskIds: payload.sidebar_task_prefs.ordered_task_ids ?? [], + subtaskOrderByParentId: payload.sidebar_task_prefs.subtask_order_by_parent_id ?? {}, + }, + }; +} + +function parseSidebarDraftForSettings(state: AppState, payload: UserSettingsUpdatedPayload) { + if (payload.sidebar_draft === undefined) return state.userSettings.sidebarDraft; + if (payload.sidebar_draft === null) return null; + return fromApiSidebarDraft(payload.sidebar_draft); +} + +function buildSidebarTaskPrefsState(state: AppState, payload: UserSettingsUpdatedPayload) { + if (!payload.sidebar_task_prefs) return state.sidebarTaskPrefs; + if (state.sidebarTaskPrefs.syncPending) return state.sidebarTaskPrefs; + return { + pinnedTaskIds: payload.sidebar_task_prefs.pinned_task_ids ?? [], + orderedTaskIds: payload.sidebar_task_prefs.ordered_task_ids ?? [], + subtaskOrderByParentId: payload.sidebar_task_prefs.subtask_order_by_parent_id ?? {}, + syncError: state.sidebarTaskPrefs.syncError, + }; +} + +function buildSidebarViewsState(state: AppState, payload: UserSettingsUpdatedPayload) { + const views = (payload.sidebar_views ?? []).map(fromApiSidebarView).map(migrateView); + const draft = parseSidebarDraftForViews(state, payload); + if (views.length === 0) return { ...state.sidebarViews, draft }; + const collapsedById = new Map( + state.sidebarViews.views.map((view) => [view.id, view.collapsedGroups]), + ); + const mergedViews = views.map((view) => ({ + ...view, + collapsedGroups: collapsedById.get(view.id) ?? view.collapsedGroups, + })); + const activeViewId = + payload.sidebar_active_view_id && + mergedViews.some((v) => v.id === payload.sidebar_active_view_id) + ? payload.sidebar_active_view_id + : state.sidebarViews.activeViewId; + return { + ...state.sidebarViews, + views: mergedViews, + activeViewId: mergedViews.some((v) => v.id === activeViewId) ? activeViewId : mergedViews[0].id, + draft, + }; +} + +function parseSidebarDraftForViews(state: AppState, payload: UserSettingsUpdatedPayload) { + if (payload.sidebar_draft === undefined) return state.sidebarViews.draft; + if (payload.sidebar_draft === null) return null; + return fromApiSidebarDraft(payload.sidebar_draft); +}