feat: sync task preferences across devices (#1440)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>) => 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<ReturnType<typeof fetchUserSettings>>);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(localStorageMock.getItem(STORAGE_KEY)).toBe(JSON.stringify(crossTab));
|
||||
expect(updateUserSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<StoredDefaults | null>(
|
||||
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;
|
||||
|
||||
@@ -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<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
|
||||
renderHook(() => useSavedPresets());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({ github_saved_presets: [] });
|
||||
expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SavedPreset[]>(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<SavedPreset, "id" | "createdAt">) => {
|
||||
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 };
|
||||
|
||||
@@ -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<string, string>();
|
||||
@@ -118,6 +125,12 @@ describe("useSavedPresets (gitlab)", () => {
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
__resetSnapshotForTests();
|
||||
vi.mocked(fetchUserSettings).mockResolvedValue({
|
||||
settings: { gitlab_saved_presets: [] },
|
||||
} as Awaited<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
renderHook(() => useSavedPresets());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({ gitlab_saved_presets: [] });
|
||||
expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<SavedPreset[]>(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 };
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
renderHook(() => useSavedViews());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({ jira_saved_views: [] });
|
||||
expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<SavedView[]>(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<SavedView[]>([]);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
vi.mocked(updateUserSettings).mockResolvedValue({
|
||||
settings: {},
|
||||
} as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof fetchUserSettings>>);
|
||||
|
||||
renderHook(() => useJiraTaskPresets());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserSettings).toHaveBeenCalledWith({ jira_task_presets: null });
|
||||
expect(localStorageMock.getItem(SYNC_FAILED_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<JiraStoredPreset[] | null>(
|
||||
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<JiraStoredPreset[] | null>(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);
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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<NonNullable<UpdateUserSettingsResponse>["settings"]>,
|
||||
) {
|
||||
return {
|
||||
...mapEditorBehaviorFields(s),
|
||||
...mapEditorLspFields(s),
|
||||
...mapEditorSidebarFields(s),
|
||||
...mapEditorSyncedLocalFields(s),
|
||||
loaded: true as const,
|
||||
};
|
||||
}
|
||||
|
||||
function mapEditorBehaviorFields(
|
||||
s: NonNullable<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["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<NonNullable<UpdateUserSettingsResponse>["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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
<StateProvider>
|
||||
@@ -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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setUserSettings({
|
||||
...userSettings,
|
||||
showReleaseNotification: !userSettings.showReleaseNotification,
|
||||
})
|
||||
}
|
||||
>
|
||||
Toggle unrelated
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
userSettings: {
|
||||
...defaultSettingsState.userSettings,
|
||||
loaded: true,
|
||||
taskCreateLastUsed: {
|
||||
repositoryId: "repo-1",
|
||||
branch: "main",
|
||||
agentProfileId: "agent-1",
|
||||
executorProfileId: "exec-1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<UpdateUnrelatedSetting />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<StoreApi<AppState> | 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 <StoreContext.Provider value={store}>{children}</StoreContext.Provider>;
|
||||
}
|
||||
|
||||
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<T>(selector: (state: AppState) => T) {
|
||||
const store = useContext(StoreContext);
|
||||
if (!store) {
|
||||
|
||||
@@ -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<ToggleState> = {}): 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<typeof updateUserSettings>
|
||||
>);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, string>);
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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<string, Record<string, unknown>>;
|
||||
saved_layouts?: SavedLayout[];
|
||||
sidebar_views?: SidebarViewApi[];
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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<UserSettingsResponse>("/api/v1/user/settings", {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<UserSettingsResponse["settings"]>;
|
||||
@@ -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<string, { key: string; modifiers?: Record<string, boolean> }>,
|
||||
terminalLinkBehavior: "new_tab" as const,
|
||||
|
||||
@@ -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<AppState>) => {
|
||||
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<AppState>);
|
||||
});
|
||||
|
||||
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<AppState>) => {
|
||||
draft.sidebarViews.draft = {
|
||||
baseViewId: "local",
|
||||
filters: [],
|
||||
sort: { key: "state", direction: "asc" },
|
||||
group: "state",
|
||||
};
|
||||
hydrateState(draft, {
|
||||
userSettings: {
|
||||
sidebarDraft: null,
|
||||
},
|
||||
} as unknown as Partial<AppState>);
|
||||
});
|
||||
|
||||
expect(result.sidebarViews.draft).toBeNull();
|
||||
});
|
||||
|
||||
it("hydrates sidebar task prefs from backend, including explicit clears", () => {
|
||||
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
||||
draft.sidebarTaskPrefs = {
|
||||
pinnedTaskIds: ["local-pin"],
|
||||
orderedTaskIds: ["local-order"],
|
||||
subtaskOrderByParentId: { parent: ["child"] },
|
||||
};
|
||||
hydrateState(draft, {
|
||||
userSettings: {
|
||||
sidebarTaskPrefs: {
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: [],
|
||||
subtaskOrderByParentId: {},
|
||||
},
|
||||
},
|
||||
} as unknown as Partial<AppState>);
|
||||
});
|
||||
|
||||
expect(result.sidebarTaskPrefs).toEqual({
|
||||
pinnedTaskIds: [],
|
||||
orderedTaskIds: [],
|
||||
subtaskOrderByParentId: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses backend sidebar task prefs as the authoritative hydrated value", () => {
|
||||
const result = produce(makeAppDraft(), (draft: Draft<AppState>) => {
|
||||
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<AppState>);
|
||||
});
|
||||
|
||||
expect(result.sidebarTaskPrefs).toEqual({
|
||||
pinnedTaskIds: ["server-pin"],
|
||||
orderedTaskIds: ["server-order"],
|
||||
subtaskOrderByParentId: { shared: ["server-child"], serverOnly: ["child"] },
|
||||
syncError: "retry",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -88,11 +88,29 @@ function bridgeSidebarViewsFromUserSettings(
|
||||
userSettings: Partial<AppState["userSettings"]>,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, Record<string, unknown>>;
|
||||
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<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<typeof toApiSidebarDraft> | 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],
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -114,6 +114,8 @@ export type SidebarTaskPrefsState = {
|
||||
* Independent of the global `orderedTaskIds` and the view's sort spec.
|
||||
*/
|
||||
subtaskOrderByParentId: Record<string, string[]>;
|
||||
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;
|
||||
|
||||
@@ -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<StoreApi<UISlice>>;
|
||||
|
||||
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<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof updateUserSettings>>);
|
||||
|
||||
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<ReturnType<typeof updateUserSettings>>);
|
||||
});
|
||||
|
||||
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<ReturnType<typeof updateUserSettings>>);
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<UISlice, [["zustand/immer", never]], []
|
||||
...buildBottomTerminalActions(set),
|
||||
...buildConfigChatActions(set),
|
||||
...buildSidebarViewActions(set, get),
|
||||
...buildSidebarTaskPrefsActions(set),
|
||||
...buildSidebarTaskPrefsActions(set, get),
|
||||
...buildCollapsedSubtaskActions(set, get),
|
||||
...buildSystemHealthActions(set),
|
||||
...buildDismissedAgentErrors(set),
|
||||
|
||||
@@ -517,6 +517,7 @@ export type AppState = {
|
||||
toggleSidebarGroupCollapsed: UIA["toggleSidebarGroupCollapsed"];
|
||||
toggleSubtaskCollapsed: UIA["toggleSubtaskCollapsed"];
|
||||
clearSidebarSyncError: UIA["clearSidebarSyncError"];
|
||||
clearSidebarTaskPrefsSyncError: UIA["clearSidebarTaskPrefsSyncError"];
|
||||
migrateLocalViewsToBackend: UIA["migrateLocalViewsToBackend"];
|
||||
setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"];
|
||||
togglePinnedTask: UIA["togglePinnedTask"];
|
||||
|
||||
@@ -82,6 +82,10 @@ export type { OfficeEventType, OfficeEventPayload } from "./office-events";
|
||||
import type {
|
||||
AvailableAgent,
|
||||
SavedLayout,
|
||||
SidebarViewApi,
|
||||
SidebarViewDraftApi,
|
||||
SidebarTaskPrefsApi,
|
||||
TaskCreateLastUsedApi,
|
||||
StepEvents,
|
||||
TaskState,
|
||||
ToolStatus,
|
||||
@@ -391,6 +395,16 @@ export type UserSettingsUpdatedPayload = {
|
||||
lsp_auto_start_languages?: string[];
|
||||
lsp_auto_install_languages?: string[];
|
||||
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;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
terminal_link_behavior?: string;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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<string, string[]>;
|
||||
};
|
||||
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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<string, Record<string, unknown>>;
|
||||
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<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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;
|
||||
};
|
||||
+11
-53
@@ -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<string, unknown>;
|
||||
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<string, Record<string, unknown>>;
|
||||
saved_layouts?: SavedLayout[];
|
||||
sidebar_views?: SidebarViewApi[];
|
||||
default_utility_agent_id?: string;
|
||||
default_utility_model?: string;
|
||||
keyboard_shortcuts?: Record<string, { key: string; modifiers?: Record<string, boolean> }>;
|
||||
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;
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<Awaited<ReturnType<typeof updateUserSettings>>>();
|
||||
vi.mocked(updateUserSettings)
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockResolvedValueOnce({ settings: {} } as Awaited<ReturnType<typeof updateUserSettings>>);
|
||||
const sync = createQueuedUserSettingsSync<string>("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<ReturnType<typeof updateUserSettings>>);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateUserSettings).toHaveBeenCalledTimes(2);
|
||||
expect(updateUserSettings).toHaveBeenLastCalledWith({ preferred_shell: "zsh" });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T>(
|
||||
syncFailedKey: string,
|
||||
buildPayload: (value: T) => UserSettingsUpdatePayload,
|
||||
): (value: T) => Promise<void> {
|
||||
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;
|
||||
};
|
||||
}
|
||||
@@ -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<AppState>(() => structuredClone(defaultState) as AppState);
|
||||
}
|
||||
|
||||
function userSettingsMessage(
|
||||
payload: Partial<BackendMessageMap["user.settings.updated"]["payload"]>,
|
||||
): 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<AppState>): 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user