feat: sync task preferences across devices (#1440)

This commit is contained in:
Carlos Florêncio
2026-06-19 12:46:46 +01:00
committed by GitHub
parent a404e9818b
commit 2a890c7975
49 changed files with 3170 additions and 507 deletions
@@ -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,
+89
View File
@@ -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,
};
}
+62 -2
View File
@@ -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);
});
});
+56
View File
@@ -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],
);
+5 -1
View File
@@ -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();
});
});
});
+8
View File
@@ -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]);
}
+31 -1
View File
@@ -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),
};
+2 -30
View File
@@ -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", {
+39 -2
View File
@@ -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", () => {
+40 -3
View File
@@ -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,
+113 -1
View File
@@ -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",
});
});
});
+23 -5
View File
@@ -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,
+18 -1
View File
@@ -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"],
};
}
+4 -1
View File
@@ -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;
+187 -1
View File
@@ -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",
},
});
});
});
+3 -244
View File
@@ -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),
+1
View File
@@ -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"];
+14
View File
@@ -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;
+121
View File
@@ -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
View File
@@ -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 */
}
}
+48
View File
@@ -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" });
});
});
});
+25
View File
@@ -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;
};
}
+194
View File
@@ -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",
});
});
});
+132 -28
View File
@@ -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);
}