diff --git a/apps/backend/internal/backendapp/app_status_bar_visibility_test.go b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go new file mode 100644 index 0000000000..b2e75f6503 --- /dev/null +++ b/apps/backend/internal/backendapp/app_status_bar_visibility_test.go @@ -0,0 +1,24 @@ +package backendapp + +import ( + "encoding/json" + "testing" + + userdto "github.com/kandev/kandev/internal/user/dto" +) + +func TestMapUserSettingsStateIncludesAppStatusBarVisibility(t *testing.T) { + var response userdto.UserSettingsResponse + if err := json.Unmarshal([]byte(`{"settings":{"app_status_bar_enabled":false,"revision":42}}`), &response); err != nil { + t.Fatalf("decode user settings response: %v", err) + } + state := mapUserSettingsState(response, "workspace-1") + + got, ok := state["appStatusBarEnabled"].(bool) + if !ok || got { + t.Fatalf("appStatusBarEnabled = %#v, want false", state["appStatusBarEnabled"]) + } + if revision, ok := state["revision"].(int64); !ok || revision != 42 { + t.Fatalf("revision = %#v, want int64(42)", state["revision"]) + } +} diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index cff106d0aa..da9931cd95 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -442,6 +442,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str effectiveWorkspaceID = workspaceID } return map[string]any{ + "revision": settings.Revision, "workspaceId": effectiveWorkspaceID, "kanbanViewMode": nullString(settings.KanbanViewMode), "startupPage": usermodels.NormalizeStartupPage(settings.StartupPage), @@ -488,6 +489,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar, "simplified": settings.SystemMetricsDisplay.Simplified, }, + "appStatusBarEnabled": settings.AppStatusBarEnabled, "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), "voiceMode": mapVoiceMode(settings.VoiceMode), "hiddenWorkflowStepIds": stringSliceMap(settings.KanbanHiddenStepIDs), diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index a20fa3b3e7..4e5d9cc270 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -385,11 +385,6 @@ type FeaturesConfig struct { // HTTP/WS route registration, and frontend nav/route visibility. Office bool `mapstructure:"office" json:"office"` - // AppStatusBar gates the global status bar on tablet/desktop and the - // corresponding Status drawer on phones. The snake_case mapstructure key - // keeps the config and KANDEV_FEATURES_APP_STATUS_BAR environment name aligned. - AppStatusBar bool `mapstructure:"app_status_bar" json:"appStatusBar"` - // Auth is the on/off switch for opt-in authentication and per-user // workspaces. When on, every visitor must sign in (the first becomes the // admin via a setup wizard) and workspaces are private per user; the diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 87454f505a..0798eeef4d 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -205,7 +205,6 @@ func TestFeatures_ProductionDefaults(t *testing.T) { // Force a clean env so KANDEV_FEATURES_* and profile-selector vars from the // host shell cannot change the production-profile defaults under test. t.Setenv("KANDEV_FEATURES_OFFICE", "") - unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") unsetEnv(t, "KANDEV_FEATURES_AUTH") unsetEnv(t, "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF") t.Setenv("KANDEV_DEBUG_DEV_MODE", "") @@ -220,9 +219,6 @@ func TestFeatures_ProductionDefaults(t *testing.T) { if cfg.Features.Office { t.Errorf("Features.Office = true, want false (production default must be off)") } - if cfg.Features.AppStatusBar { - t.Error("Features.AppStatusBar = true, want false (status surface must remain opt-in by default)") - } if cfg.Features.Auth { t.Error("Features.Auth = true, want false (authentication must remain opt-in by default)") } @@ -260,15 +256,23 @@ func TestFeatures_ClaudeBackgroundPromptHandoffEnabledByEnv(t *testing.T) { } } -func TestFeatures_AppStatusBarDisabledByEnv(t *testing.T) { - t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "false") +func TestFeaturesConfigIgnoresRetiredAppStatusBarEnv(t *testing.T) { + t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "true") cfg, err := LoadWithPath(t.TempDir()) if err != nil { t.Fatalf("LoadWithPath: %v", err) } - if cfg.Features.AppStatusBar { - t.Error("Features.AppStatusBar = true, want false (KANDEV_FEATURES_APP_STATUS_BAR=false must hide it)") + raw, err := json.Marshal(cfg.Features) + if err != nil { + t.Fatalf("json.Marshal: %v", err) + } + var response map[string]bool + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("json.Unmarshal: %v", err) + } + if _, ok := response["appStatusBar"]; ok { + t.Fatal("retired KANDEV_FEATURES_APP_STATUS_BAR still affects the feature response") } } @@ -498,4 +502,7 @@ func TestFeaturesConfig_JSONShape(t *testing.T) { t.Errorf("FeaturesConfig JSON missing true field %q: %#v", jsonName, decoded) } } + if _, ok := decoded["appStatusBar"]; ok { + t.Fatal("retired appStatusBar remains in FeaturesConfig JSON") + } } diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index 0019ab7b16..f3fabd0d7c 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -44,13 +44,6 @@ features: dev: "true" e2e: "true" - # App status bar. Off until a user opts in; on only in e2e so browser tests - # continuously exercise the global surface without changing user defaults. - KANDEV_FEATURES_APP_STATUS_BAR: - prod: "false" - dev: "false" - e2e: "true" - # Opt-in authentication + per-user workspaces. This flag IS the on/off # switch: when on, every visitor must sign in (the first becomes admin via # a setup wizard) and workspaces are private per user. Off everywhere by diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 18df55a27d..bf7cf9313d 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -39,9 +39,6 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" { t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false") } - if v := os.Getenv("KANDEV_FEATURES_APP_STATUS_BAR"); v != "false" { - t.Errorf("KANDEV_FEATURES_APP_STATUS_BAR = %q after prod ApplyProfile; want %q", v, "false") - } if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" { t.Errorf("KANDEV_FEATURES_AUTH = %q after prod ApplyProfile; want %q", v, "false") } @@ -57,8 +54,7 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { } } -// TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile: -// active development tools turn on while the user-facing status bar stays opt-in. +// TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile. func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) { clearProfileSelectors(t) clearProfilesYAMLVars(t) @@ -74,9 +70,6 @@ func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "true" { t.Errorf("KANDEV_FEATURES_OFFICE = %q in dev; want %q", v, "true") } - if v := os.Getenv("KANDEV_FEATURES_APP_STATUS_BAR"); v != "false" { - t.Errorf("KANDEV_FEATURES_APP_STATUS_BAR = %q in dev; want %q", v, "false") - } // Auth is off by default even in dev — it locks the instance behind a // login, so it must be an explicit opt-in. if v := os.Getenv("KANDEV_FEATURES_AUTH"); v != "false" { @@ -217,8 +210,8 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) { if _, ok := defaults["office"]; !ok { t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "office", defaults) } - if _, ok := defaults["app_status_bar"]; !ok { - t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "app_status_bar", defaults) + if _, ok := defaults["app_status_bar"]; ok { + t.Errorf("FeatureFlagDefaults includes retired %q key", "app_status_bar") } if _, ok := defaults["claude_background_prompt_handoff"]; !ok { t.Errorf( @@ -271,7 +264,6 @@ func TestProfilesYAML_ContainsRequiredSections(t *testing.T) { "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", - "KANDEV_FEATURES_APP_STATUS_BAR:", "KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF:", "KANDEV_WEB_TITLE_PREFIX:", } { @@ -279,6 +271,9 @@ func TestProfilesYAML_ContainsRequiredSections(t *testing.T) { t.Errorf("embedded profiles.yaml missing section/key %q; embed broken or file truncated", section) } } + if strings.Contains(yaml, "KANDEV_FEATURES_APP_STATUS_BAR:") { + t.Error("embedded profiles.yaml still declares retired KANDEV_FEATURES_APP_STATUS_BAR") + } } // clearProfileSelectors unsets the env vars DetectEnvironment diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go index b830b6f3e8..2ea04b641b 100644 --- a/apps/backend/internal/runtimeflags/config_test.go +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -59,14 +59,14 @@ func TestOptionsFromConfigParsesUppercaseTruthyEnv(t *testing.T) { } } -func TestOptionsFromConfigParsesUppercaseTruthyEnvForAppStatusBar(t *testing.T) { - preserveEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") - t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "TRUE") +func TestOptionsFromConfigIgnoresRetiredAppStatusBarEnv(t *testing.T) { + preserveEnv(t, retiredAppStatusBarEnvVar) + t.Setenv(retiredAppStatusBarEnvVar, "TRUE") opts := OptionsFromConfig(&config.Config{}) - if !opts.EnvValues["KANDEV_FEATURES_APP_STATUS_BAR"] { - t.Fatal("KANDEV_FEATURES_APP_STATUS_BAR TRUE parsed false, want true") + if _, ok := opts.EnvValues[retiredAppStatusBarEnvVar]; ok { + t.Fatal("retired KANDEV_FEATURES_APP_STATUS_BAR remains active") } } @@ -81,25 +81,15 @@ func TestOptionsFromConfigParsesClaudeBackgroundPromptHandoffEnv(t *testing.T) { } } -func TestApplyStatesToConfigSetsAppStatusBar(t *testing.T) { - cfg := &config.Config{Features: config.FeaturesConfig{AppStatusBar: true}} +func TestApplyStatesToConfigIgnoresRetiredAppStatusBar(t *testing.T) { + cfg := &config.Config{} ApplyStatesToConfig(cfg, []RuntimeFlagState{{ - Key: "features.appStatusBar", - EffectiveValue: false, + Key: retiredAppStatusBarKey, + EffectiveValue: true, }}) - if cfg.Features.AppStatusBar { - t.Fatal("ApplyStatesToConfig did not set Features.AppStatusBar = false") - } -} - -func TestValuesFromConfigIncludesAppStatusBar(t *testing.T) { - cfg := &config.Config{Features: config.FeaturesConfig{AppStatusBar: true}} - - values := ValuesFromConfig(cfg) - - if !values["features.appStatusBar"] { - t.Fatal("ValuesFromConfig did not surface features.appStatusBar = true") + if _, ok := ValuesFromConfig(cfg)[retiredAppStatusBarKey]; ok { + t.Fatal("ValuesFromConfig surfaced retired features.appStatusBar") } } diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 671614d152..0b93433890 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -16,12 +16,18 @@ type runtimeFlagIdentity struct { envVar string } +const ( + retiredAppStatusBarKey = "features.appStatusBar" + retiredAppStatusBarEnvVar = "KANDEV_FEATURES_APP_STATUS_BAR" +) + // retiredRuntimeFlagIdentities is append-only. When a flag graduates, remove // its active registration and move its identity here. Persisted overrides for // unknown keys are intentionally retained, so neither the key nor environment // variable may ever be reused for a different flag. var retiredRuntimeFlagIdentities = []runtimeFlagIdentity{ {key: "features.plugins", envVar: "KANDEV_FEATURES_PLUGINS"}, + {key: retiredAppStatusBarKey, envVar: retiredAppStatusBarEnvVar}, } var registrations = []runtimeFlagRegistration{ @@ -42,23 +48,6 @@ var registrations = []runtimeFlagRegistration{ read: func(cfg *config.Config) bool { return cfg.Features.Office }, apply: func(cfg *config.Config, value bool) { cfg.Features.Office = value }, }, - { - definition: RuntimeFlagDefinition{ - Key: "features.appStatusBar", - EnvVar: "KANDEV_FEATURES_APP_STATUS_BAR", - Kind: KindFeature, - Label: "App status bar", - Description: "Adds the global connection, optional host metrics, and plugin status surface.", - Stability: StabilityStable, - RiskLevel: RiskLow, - RiskDescription: "Changing this adds or removes the desktop and tablet status bar and the phone Status drawer entry " + - "after restart. It does not stop connections, metrics collection requested by other clients, or plugins.", - RestartRequired: true, - Mutable: true, - }, - read: func(cfg *config.Config) bool { return cfg.Features.AppStatusBar }, - apply: func(cfg *config.Config, value bool) { cfg.Features.AppStatusBar = value }, - }, { definition: RuntimeFlagDefinition{ Key: "features.auth", diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 0bb4349e9f..97fcdfb365 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -59,17 +59,24 @@ func TestRetiredRuntimeFlagIdentitiesIncludePlugins(t *testing.T) { t.Fatal("graduated plugins flag identity is missing from the retired identity set") } -func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) { - def, ok := DefinitionByKey("features.appStatusBar") - if !ok { - t.Fatal("features.appStatusBar definition missing") +func TestDefinitionsExcludeAppStatusBar(t *testing.T) { + if _, ok := DefinitionByKey(retiredAppStatusBarKey); ok { + t.Fatal("features.appStatusBar definition present; visibility is a user setting") } - if def.EnvVar != "KANDEV_FEATURES_APP_STATUS_BAR" { - t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_APP_STATUS_BAR", def.EnvVar) + for _, def := range Definitions() { + if def.EnvVar == retiredAppStatusBarEnvVar { + t.Fatalf("definition %q still binds KANDEV_FEATURES_APP_STATUS_BAR", def.Key) + } } - if !def.RestartRequired { - t.Fatal("RestartRequired = false, want true") +} + +func TestRetiredRuntimeFlagIdentitiesIncludeAppStatusBar(t *testing.T) { + for _, identity := range retiredRuntimeFlagIdentities { + if identity.key == retiredAppStatusBarKey && identity.envVar == retiredAppStatusBarEnvVar { + return + } } + t.Fatal("graduated App status bar identity is missing from the retired identity set") } func TestDefinitionsIncludeClaudeBackgroundPromptHandoffMetadata(t *testing.T) { diff --git a/apps/backend/internal/runtimeflags/service_test.go b/apps/backend/internal/runtimeflags/service_test.go index 61abe45090..1eb99e3513 100644 --- a/apps/backend/internal/runtimeflags/service_test.go +++ b/apps/backend/internal/runtimeflags/service_test.go @@ -2,8 +2,11 @@ package runtimeflags import ( "context" + "encoding/json" "errors" "testing" + + "github.com/kandev/kandev/internal/common/config" ) type memoryStore struct { @@ -102,6 +105,38 @@ func TestServiceExplicitImpliedEnvLocksDebugMode(t *testing.T) { } } +func TestServiceIgnoresRetiredAppStatusBarOverrideAndEnv(t *testing.T) { + svc := NewService(&memoryStore{values: map[string]bool{retiredAppStatusBarKey: false}}, Options{ + DefaultValues: map[string]bool{retiredAppStatusBarKey: true}, + RuntimeValues: map[string]bool{retiredAppStatusBarKey: true}, + EnvValues: map[string]bool{retiredAppStatusBarEnvVar: true}, + IsExplicitEnv: func(name string) bool { return name == retiredAppStatusBarEnvVar }, + }) + states, err := svc.ListStates(context.Background()) + if err != nil { + t.Fatalf("ListStates: %v", err) + } + for _, state := range states { + if state.Key == retiredAppStatusBarKey { + t.Fatal("retired features.appStatusBar remains in active runtime states") + } + } + + cfg := &config.Config{} + ApplyStatesToConfig(cfg, states) + raw, err := json.Marshal(cfg.Features) + if err != nil { + t.Fatalf("marshal feature response: %v", err) + } + var response map[string]bool + if err := json.Unmarshal(raw, &response); err != nil { + t.Fatalf("decode feature response: %v", err) + } + if _, ok := response["appStatusBar"]; ok { + t.Fatal("retired appStatusBar remains in the serialized feature response") + } +} + func TestServiceNilStoreFailsFast(t *testing.T) { svc := NewService(nil, Options{}) if _, err := svc.ListStates(context.Background()); !errors.Is(err, ErrStoreUnset) { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 1876a372de..c55ae9b0bd 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -94,6 +94,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS TerminalFontSize: req.TerminalFontSize, ChangesPanelLayout: req.ChangesPanelLayout, SystemMetricsDisplay: systemMetricsDisplayPatch(req.SystemMetricsDisplay), + AppStatusBarEnabled: req.AppStatusBarEnabled, AppStatusBarOrder: req.AppStatusBarOrder, VoiceMode: req.VoiceMode, KanbanHiddenStepIDs: req.KanbanHiddenStepIDs, diff --git a/apps/backend/internal/user/dto/app_status_bar_visibility_test.go b/apps/backend/internal/user/dto/app_status_bar_visibility_test.go new file mode 100644 index 0000000000..c9cbd06dd9 --- /dev/null +++ b/apps/backend/internal/user/dto/app_status_bar_visibility_test.go @@ -0,0 +1,52 @@ +package dto + +import ( + "encoding/json" + "reflect" + "testing" + + "github.com/kandev/kandev/internal/user/models" +) + +func TestAppStatusBarVisibilityDTOResponseAndPatchSemantics(t *testing.T) { + t.Run("response preserves false", func(t *testing.T) { + var settings models.UserSettings + if err := json.Unmarshal([]byte(`{"app_status_bar_enabled":false}`), &settings); err != nil { + t.Fatalf("decode settings: %v", err) + } + encoded, err := json.Marshal(FromUserSettings(&settings)) + if err != nil { + t.Fatalf("encode response: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if got, ok := payload["app_status_bar_enabled"].(bool); !ok || got { + t.Fatalf("app_status_bar_enabled = %#v, want false", payload["app_status_bar_enabled"]) + } + }) + + t.Run("patch distinguishes omission from false", func(t *testing.T) { + var omitted UpdateUserSettingsRequest + var disabled UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &omitted); err != nil { + t.Fatalf("decode omitted patch: %v", err) + } + if err := json.Unmarshal([]byte(`{"app_status_bar_enabled":false}`), &disabled); err != nil { + t.Fatalf("decode explicit patch: %v", err) + } + + omittedField := reflect.ValueOf(omitted).FieldByName("AppStatusBarEnabled") + disabledField := reflect.ValueOf(disabled).FieldByName("AppStatusBarEnabled") + if !omittedField.IsValid() || !disabledField.IsValid() { + t.Fatal("AppStatusBarEnabled patch field is missing") + } + if !omittedField.IsNil() { + t.Fatalf("omitted AppStatusBarEnabled = %#v, want nil", omittedField.Interface()) + } + if disabledField.IsNil() || disabledField.Elem().Bool() { + t.Fatalf("explicit AppStatusBarEnabled = %#v, want pointer to false", disabledField.Interface()) + } + }) +} diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 9521691fcd..0dfd1937a0 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -66,9 +66,11 @@ type UserSettingsDTO struct { TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode models.VoiceModeSettings `json:"voice_mode"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + Revision int64 `json:"revision"` UpdatedAt string `json:"updated_at"` } @@ -138,6 +140,7 @@ type UpdateUserSettingsRequest struct { TerminalFontSize *int `json:"terminal_font_size,omitempty"` ChangesPanelLayout *string `json:"changes_panel_layout,omitempty"` SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch `json:"system_metrics_display,omitempty"` + AppStatusBarEnabled *bool `json:"app_status_bar_enabled,omitempty"` AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` VoiceMode *models.VoiceModeSettings `json:"voice_mode,omitempty"` KanbanHiddenStepIDs *map[string][]string `json:"kanban_hidden_step_ids,omitempty"` @@ -271,9 +274,11 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { TerminalFontSize: settings.TerminalFontSize, ChangesPanelLayout: settings.ChangesPanelLayout, SystemMetricsDisplay: settings.SystemMetricsDisplay, + AppStatusBarEnabled: settings.AppStatusBarEnabled, AppStatusBarOrder: settings.AppStatusBarOrder, VoiceMode: settings.VoiceMode, KanbanHiddenStepIDs: settings.KanbanHiddenStepIDs, + Revision: settings.Revision, UpdatedAt: settings.UpdatedAt.Format(time.RFC3339), } } diff --git a/apps/backend/internal/user/dto/dto_test.go b/apps/backend/internal/user/dto/dto_test.go index 09390c44f4..7c71368fdc 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -8,6 +8,13 @@ import ( "github.com/kandev/kandev/internal/user/models" ) +func TestFromUserSettingsIncludesAtomicRevision(t *testing.T) { + got := FromUserSettings(&models.UserSettings{Revision: 42}).Revision + if got != 42 { + t.Fatalf("Revision = %d, want 42", got) + } +} + func TestUpdateUserSettingsRequestExposesAzureDevOpsBrowsePreferences(t *testing.T) { field, ok := reflect.TypeFor[UpdateUserSettingsRequest]().FieldByName("AzureDevOpsBrowsePreferences") if !ok || field.Tag.Get("json") != "azure_devops_browse_preferences,omitempty" { diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 779f4c784f..be048e9875 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -115,9 +115,11 @@ type UserSettings struct { TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree" SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled bool `json:"app_status_bar_enabled"` AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode VoiceModeSettings `json:"voice_mode"` KanbanHiddenStepIDs map[string][]string `json:"kanban_hidden_step_ids"` + Revision int64 `json:"revision"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/user/service/app_status_bar_identity_test.go b/apps/backend/internal/user/service/app_status_bar_identity_test.go new file mode 100644 index 0000000000..0e9e1e9c69 --- /dev/null +++ b/apps/backend/internal/user/service/app_status_bar_identity_test.go @@ -0,0 +1,99 @@ +package service + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/auth/authn" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/models" + "go.uber.org/zap" +) + +func TestAppStatusBarPreferenceUsesAuthenticatedUser(t *testing.T) { + const userID = "member-1" + repo := &recordingUserRepository{ + getSettings: &models.UserSettings{UserID: userID, AppStatusBarEnabled: true}, + preservingSettings: &models.UserSettings{ + UserID: userID, + AppStatusBarEnabled: false, + }, + } + eventBus := &recordingEventBus{} + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + ctx := authn.WithIdentity(context.Background(), authn.Identity{ + UserID: userID, + Role: authn.RoleMember, + }) + svc := NewService(repo, eventBus, log) + + settings, err := svc.GetUserSettings(ctx) + if err != nil { + t.Fatalf("GetUserSettings: %v", err) + } + if repo.getSettingsUserID != userID || settings.UserID != userID { + t.Fatalf("GetUserSettings user = %q, settings user = %q, want %q", repo.getSettingsUserID, settings.UserID, userID) + } + + disabled := false + if _, err := svc.UpdateUserSettings(ctx, &UpdateUserSettingsRequest{AppStatusBarEnabled: &disabled}); err != nil { + t.Fatalf("UpdateUserSettings: %v", err) + } + if repo.getSettingsUserID != userID { + t.Fatalf("UpdateUserSettings read user = %q, want %q", repo.getSettingsUserID, userID) + } + if repo.preservingInput == nil || repo.preservingInput.UserID != userID { + t.Fatalf("persisted settings user = %+v, want %q", repo.preservingInput, userID) + } + if len(eventBus.publishedEvents) != 1 { + t.Fatalf("published events = %d, want 1", len(eventBus.publishedEvents)) + } + data, ok := eventBus.publishedEvents[0].Data.(map[string]interface{}) + if !ok || data["user_id"] != userID { + t.Fatalf("published user = %#v, want %q", data["user_id"], userID) + } +} + +func TestSettingsUserIDFallsBackOnlyForUnscopedCallers(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + svc := NewService(&recordingUserRepository{}, nil, log) + tests := []struct { + name string + ctx context.Context + want string + }{ + {name: "internal", ctx: context.Background(), want: "default-user"}, + { + name: "synthetic", + ctx: authn.WithIdentity(context.Background(), authn.Identity{ + UserID: "ignored-synthetic-user", + Role: authn.RoleAdmin, + Synthetic: true, + }), + want: "default-user", + }, + { + name: "first real user", + ctx: authn.WithIdentity(context.Background(), authn.Identity{UserID: "member-1"}), + want: "member-1", + }, + { + name: "second real user", + ctx: authn.WithIdentity(context.Background(), authn.Identity{UserID: "member-2"}), + want: "member-2", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := svc.settingsUserID(tt.ctx); got != tt.want { + t.Fatalf("settingsUserID = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/apps/backend/internal/user/service/app_status_bar_visibility_test.go b/apps/backend/internal/user/service/app_status_bar_visibility_test.go new file mode 100644 index 0000000000..4d62bb37b1 --- /dev/null +++ b/apps/backend/internal/user/service/app_status_bar_visibility_test.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "encoding/json" + "testing" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/user/models" + "go.uber.org/zap" +) + +func TestApplyBasicSettingsAppStatusBarVisibility(t *testing.T) { + t.Run("omission preserves enabled", func(t *testing.T) { + settings := decodeStatusBarSettings(t, true) + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if got := statusBarEnabledFromSettings(t, settings); !got { + t.Fatal("AppStatusBarEnabled = false, want true") + } + }) + + t.Run("explicit false is applied", func(t *testing.T) { + settings := decodeStatusBarSettings(t, true) + disabled := false + req := UpdateUserSettingsRequest{AppStatusBarEnabled: &disabled} + if err := applyBasicSettings(settings, &req); err != nil { + t.Fatalf("apply basic settings: %v", err) + } + if got := statusBarEnabledFromSettings(t, settings); got { + t.Fatal("AppStatusBarEnabled = true, want false") + } + }) +} + +func TestPublishUserSettingsEventIncludesAppStatusBarVisibility(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("logger.NewFromZap: %v", err) + } + eventBus := &recordingEventBus{} + svc := NewService(&recordingUserRepository{}, eventBus, log) + svc.publishUserSettingsEvent(context.Background(), decodeStatusBarSettings(t, false)) + + eventData, ok := eventBus.publishedEvents[0].Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected event data map, got %T", eventBus.publishedEvents[0].Data) + } + if got, ok := eventData["app_status_bar_enabled"].(bool); !ok || got { + t.Fatalf("app_status_bar_enabled = %#v, want false", eventData["app_status_bar_enabled"]) + } +} + +func decodeStatusBarSettings(t *testing.T, enabled bool) *models.UserSettings { + t.Helper() + var settings models.UserSettings + raw := `{"app_status_bar_enabled":false}` + if enabled { + raw = `{"app_status_bar_enabled":true}` + } + if err := json.Unmarshal([]byte(raw), &settings); err != nil { + t.Fatalf("decode settings: %v", err) + } + return &settings +} + +func statusBarEnabledFromSettings(t *testing.T, settings *models.UserSettings) bool { + t.Helper() + encoded, err := json.Marshal(settings) + if err != nil { + t.Fatalf("encode settings: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode settings payload: %v", err) + } + got, ok := payload["app_status_bar_enabled"].(bool) + if !ok { + t.Fatalf("app_status_bar_enabled = %#v, want bool", payload["app_status_bar_enabled"]) + } + return got +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 2a2f97625c..e30f678fe1 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" @@ -85,6 +86,7 @@ type UpdateUserSettingsRequest struct { TerminalFontSize *int ChangesPanelLayout *string SystemMetricsDisplay *SystemMetricsDisplaySettingsPatch + AppStatusBarEnabled *bool AppStatusBarOrder *models.AppStatusBarOrder VoiceMode *models.VoiceModeSettings KanbanHiddenStepIDs *map[string][]string @@ -104,8 +106,16 @@ func NewService(repo store.Repository, eventBus bus.EventBus, log *logger.Logger } } +func (s *Service) settingsUserID(ctx context.Context) string { + identity, ok := authn.IdentityFromContext(ctx) + if !ok || identity.Synthetic || identity.UserID == "" { + return s.defaultUser + } + return identity.UserID +} + func (s *Service) GetCurrentUser(ctx context.Context) (*models.User, error) { - user, err := s.repo.GetUser(ctx, s.defaultUser) + user, err := s.repo.GetUser(ctx, s.settingsUserID(ctx)) if err != nil { return nil, ErrUserNotFound } @@ -113,7 +123,7 @@ func (s *Service) GetCurrentUser(ctx context.Context) (*models.User, error) { } func (s *Service) GetUserSettings(ctx context.Context) (*models.UserSettings, error) { - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return nil, err } @@ -121,7 +131,7 @@ func (s *Service) GetUserSettings(ctx context.Context) (*models.UserSettings, er } func (s *Service) PreferredShell(ctx context.Context) (string, error) { - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return "", err } @@ -130,7 +140,7 @@ func (s *Service) PreferredShell(ctx context.Context) (string, error) { // GetDefaultUtilitySettings returns the user's default utility agent/model settings. func (s *Service) GetDefaultUtilitySettings(ctx context.Context) (agentID, model string, err error) { - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return "", "", err } @@ -139,7 +149,7 @@ func (s *Service) GetDefaultUtilitySettings(ctx context.Context) (agentID, model // GetDefaultUtilityAgentProfileID returns the profile used by new built-in utility actions. func (s *Service) GetDefaultUtilityAgentProfileID(ctx context.Context) (string, error) { - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return "", err } @@ -147,7 +157,7 @@ func (s *Service) GetDefaultUtilityAgentProfileID(ctx context.Context) (string, } func (s *Service) UpdateUserSettings(ctx context.Context, req *UpdateUserSettingsRequest) (*models.UserSettings, error) { - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return nil, err } @@ -201,7 +211,7 @@ func (s *Service) RecordTaskCreateLastUsed(ctx context.Context, patch models.Tas } func (s *Service) updateTaskCreateLastUsed(ctx context.Context, patch models.TaskCreateLastUsed) (*models.UserSettings, error) { - return s.repo.UpdateTaskCreateLastUsed(ctx, s.defaultUser, patch) + return s.repo.UpdateTaskCreateLastUsed(ctx, s.settingsUserID(ctx), patch) } func taskCreateLastUsedPatchEmpty(patch models.TaskCreateLastUsed) bool { @@ -231,6 +241,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe return err } applySystemMetricsDisplay(settings, req.SystemMetricsDisplay) + if req.AppStatusBarEnabled != nil { + settings.AppStatusBarEnabled = *req.AppStatusBarEnabled + } if req.AppStatusBarOrder != nil { settings.AppStatusBarOrder = *req.AppStatusBarOrder } @@ -806,9 +819,11 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "terminal_font_size": settings.TerminalFontSize, "changes_panel_layout": settings.ChangesPanelLayout, "system_metrics_display": settings.SystemMetricsDisplay, + "app_status_bar_enabled": settings.AppStatusBarEnabled, "app_status_bar_order": settings.AppStatusBarOrder, "voice_mode": settings.VoiceMode, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, + "revision": settings.Revision, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } if err := s.eventBus.Publish(ctx, events.UserSettingsUpdated, bus.NewEvent(events.UserSettingsUpdated, "user-service", data)); err != nil { @@ -867,7 +882,7 @@ func (s *Service) ClearDefaultEditorID(ctx context.Context, editorID string) err if editorID == "" { return nil } - settings, err := s.repo.GetUserSettings(ctx, s.defaultUser) + settings, err := s.repo.GetUserSettings(ctx, s.settingsUserID(ctx)) if err != nil { return err } diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index dd942c4b2a..86b2c0cbd8 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -1467,6 +1467,7 @@ func TestRecordTaskCreateLastUsed(t *testing.T) { } updatedSettings := &models.UserSettings{ UserID: store.DefaultUserID, + Revision: 42, UpdatedAt: time.Unix(123, 0).UTC(), TaskCreateLastUsed: models.TaskCreateLastUsed{ RepositoryID: "repo-1", @@ -1506,6 +1507,9 @@ func TestRecordTaskCreateLastUsed(t *testing.T) { if !reflect.DeepEqual(data["task_create_last_used"], updatedSettings.TaskCreateLastUsed) { t.Fatalf("expected event task-create state %+v, got %+v", updatedSettings.TaskCreateLastUsed, data["task_create_last_used"]) } + if got := data["revision"]; got != int64(42) { + t.Fatalf("revision = %#v, want 42", got) + } }) t.Run("workflow-only patch updates repo and publishes settings event", func(t *testing.T) { @@ -1640,6 +1644,7 @@ type recordingUserRepository struct { getUserCalls int getDefaultUserCalls int getUserSettingsCalls int + getSettingsUserID string upsertUserSettingsPreservingLastUsedCalls int updateCalls int updateUserID string @@ -1665,8 +1670,9 @@ func (r *recordingUserRepository) GetDefaultUser(context.Context) (*models.User, return nil, errors.New("unexpected GetDefaultUser call") } -func (r *recordingUserRepository) GetUserSettings(context.Context, string) (*models.UserSettings, error) { +func (r *recordingUserRepository) GetUserSettings(_ context.Context, userID string) (*models.UserSettings, error) { r.getUserSettingsCalls++ + r.getSettingsUserID = userID if r.getErr != nil { return nil, r.getErr } diff --git a/apps/backend/internal/user/store/app_status_bar_visibility_test.go b/apps/backend/internal/user/store/app_status_bar_visibility_test.go new file mode 100644 index 0000000000..e55980deec --- /dev/null +++ b/apps/backend/internal/user/store/app_status_bar_visibility_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "encoding/json" + "testing" +) + +func TestScanUserSettingsAppStatusBarVisibilityDefaultsAndRoundTrips(t *testing.T) { + t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "true") + tests := []struct { + name string + raw string + want bool + }{ + {name: "missing defaults to disabled", raw: `{}`, want: false}, + {name: "explicit true is preserved", raw: `{"app_status_bar_enabled":true}`, want: true}, + {name: "explicit false is preserved", raw: `{"app_status_bar_enabled":false}`, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + settings, err := scanUserSettings(settingsScanner{raw: tt.raw}, DefaultUserID) + if err != nil { + t.Fatalf("scan settings: %v", err) + } + encoded, err := marshalUserSettingsPayload(settings) + if err != nil { + t.Fatalf("marshal settings payload: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(encoded, &payload); err != nil { + t.Fatalf("decode normalized settings: %v", err) + } + got, ok := payload["app_status_bar_enabled"].(bool) + if !ok || got != tt.want { + t.Fatalf("app_status_bar_enabled = %#v, want %t", payload["app_status_bar_enabled"], tt.want) + } + }) + } +} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index c8dbb3dca1..f4f624a059 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -2,6 +2,7 @@ package store import ( "context" + "database/sql" "encoding/json" "fmt" "sort" @@ -57,6 +58,7 @@ func (r *sqliteRepository) initSchema() error { role TEXT NOT NULL DEFAULT 'admin', status TEXT NOT NULL DEFAULT 'active', settings TEXT NOT NULL DEFAULT '{}', + settings_revision BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL ); @@ -79,6 +81,7 @@ func (r *sqliteRepository) runMigrations() { // when authentication is enabled. Explicit CreateUser calls always set role. m.Apply("users.role", "ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'admin'") m.Apply("users.status", "ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'") + m.Apply("users.settings_revision", "ALTER TABLE users ADD COLUMN settings_revision BIGINT NOT NULL DEFAULT 0") // Safe pre-auth: the table only ever held the single default-user row. m.Apply("users.email_unique", "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email)") } @@ -219,13 +222,17 @@ func checkUserRowsAffected(result sqlResult, userID string) error { return nil } +type sqlResult interface { + RowsAffected() (int64, error) +} + func (r *sqliteRepository) GetUserSettings(ctx context.Context, userID string) (*models.UserSettings, error) { return r.getUserSettings(ctx, r.ro, userID) } func (r *sqliteRepository) getUserSettings(ctx context.Context, conn *sqlx.DB, userID string) (*models.UserSettings, error) { row := conn.QueryRowContext(ctx, conn.Rebind(` - SELECT settings, updated_at + SELECT settings, updated_at, settings_revision FROM users WHERE id = ? `), userID) settings, err := scanUserSettings(row, userID) @@ -270,18 +277,15 @@ func (r *sqliteRepository) UpsertUserSettingsPreservingTaskCreateLastUsed( } query := fmt.Sprintf(` UPDATE users - SET settings = %s, updated_at = ? + SET settings = %s, updated_at = ?, settings_revision = settings_revision + 1 WHERE id = ? + RETURNING settings, updated_at, settings_revision `, settingsExpr) args = append(args, settings.UpdatedAt, settings.UserID) - result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) - if err != nil { - return nil, err - } - if err := checkUserSettingsRowsAffected(result, settings.UserID); err != nil { - return nil, err - } - return r.getUserSettings(ctx, r.db, settings.UserID) + return scanUpdatedUserSettings( + r.db.QueryRowContext(ctx, r.db.Rebind(query), args...), + settings.UserID, + ) } func (r *sqliteRepository) upsertUserSettingsPreservingTaskCreateLastUsedPostgres( @@ -293,14 +297,10 @@ func (r *sqliteRepository) upsertUserSettingsPreservingTaskCreateLastUsedPostgre query, patchArgs := buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch) args := append([]any{string(settingsPayload)}, patchArgs...) args = append(args, settings.UpdatedAt, settings.UserID) - result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) - if err != nil { - return nil, err - } - if err := checkUserSettingsRowsAffected(result, settings.UserID); err != nil { - return nil, err - } - return r.getUserSettings(ctx, r.db, settings.UserID) + return scanUpdatedUserSettings( + r.db.QueryRowContext(ctx, r.db.Rebind(query), args...), + settings.UserID, + ) } func (r *sqliteRepository) UpdateTaskCreateLastUsed(ctx context.Context, userID string, patch models.TaskCreateLastUsed) (*models.UserSettings, error) { @@ -321,20 +321,17 @@ func (r *sqliteRepository) UpdateTaskCreateLastUsed(ctx context.Context, userID settingsExpr = fmt.Sprintf("json_set(%s, %s)", settingsExpr, placeholders) query := ` UPDATE users - SET settings = %s, updated_at = ? + SET settings = %s, updated_at = ?, settings_revision = settings_revision + 1 WHERE id = ? + RETURNING settings, updated_at, settings_revision ` now := time.Now().UTC() args := append([]any{}, patchArgs...) args = append(args, now, userID) - result, err := r.db.ExecContext(ctx, r.db.Rebind(fmt.Sprintf(query, settingsExpr)), args...) - if err != nil { - return nil, err - } - if err := checkUserSettingsRowsAffected(result, userID); err != nil { - return nil, err - } - return r.getUserSettings(ctx, r.db, userID) + return scanUpdatedUserSettings( + r.db.QueryRowContext(ctx, r.db.Rebind(fmt.Sprintf(query, settingsExpr)), args...), + userID, + ) } func (r *sqliteRepository) updateTaskCreateLastUsedPostgres(ctx context.Context, userID string, patch models.TaskCreateLastUsed) (*models.UserSettings, error) { @@ -344,14 +341,10 @@ func (r *sqliteRepository) updateTaskCreateLastUsedPostgres(ctx context.Context, } now := time.Now().UTC() args = append(args, now, userID) - result, err := r.db.ExecContext(ctx, r.db.Rebind(query), args...) - if err != nil { - return nil, err - } - if err := checkUserSettingsRowsAffected(result, userID); err != nil { - return nil, err - } - return r.getUserSettings(ctx, r.db, userID) + return scanUpdatedUserSettings( + r.db.QueryRowContext(ctx, r.db.Rebind(query), args...), + userID, + ) } func makeTaskCreateLastUsedJSONSetArgs(patch models.TaskCreateLastUsed) []any { @@ -416,8 +409,9 @@ func buildPostgresTaskCreateLastUsedUpdate(patch models.TaskCreateLastUsed) (str expr, args = applyPostgresTaskCreateLastUsedPatch(expr, patch, args) query := fmt.Sprintf(` UPDATE users - SET settings = %s::text, updated_at = ? + SET settings = %s::text, updated_at = ?, settings_revision = settings_revision + 1 WHERE id = ? + RETURNING settings, updated_at, settings_revision `, expr) return query, args } @@ -434,8 +428,9 @@ func buildPostgresUserSettingsPreservingTaskCreateLastUsedUpdate(patch *models.T } query := fmt.Sprintf(` UPDATE users - SET settings = %s::text, updated_at = ? + SET settings = %s::text, updated_at = ?, settings_revision = settings_revision + 1 WHERE id = ? + RETURNING settings, updated_at, settings_revision `, expr) return query, args } @@ -567,25 +562,19 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "terminal_font_size": settings.TerminalFontSize, "changes_panel_layout": settings.ChangesPanelLayout, "system_metrics_display": settings.SystemMetricsDisplay, + "app_status_bar_enabled": settings.AppStatusBarEnabled, "app_status_bar_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), "voice_mode": settings.VoiceMode, "kanban_hidden_step_ids": settings.KanbanHiddenStepIDs, }) } -func checkUserSettingsRowsAffected(result sqlResult, userID string) error { - rows, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to check rows affected: %w", err) +func scanUpdatedUserSettings(scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) { + settings, err := scanUserSettings(scanner, userID) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("user not found: %s", userID) } - if rows == 0 { - return fmt.Errorf("user not found: %s", userID) - } - return nil -} - -type sqlResult interface { - RowsAffected() (int64, error) + return settings, err } func scanUser(scanner interface{ Scan(dest ...any) error }) (*models.User, error) { @@ -678,6 +667,7 @@ func defaultUserSettings(userID string) *models.UserSettings { SidebarViews: DefaultSidebarViews(), SidebarActiveViewID: DefaultSidebarViewID, SidebarTaskPrefs: normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}), + AppStatusBarEnabled: false, AppStatusBarOrder: normalizeAppStatusBarOrder(models.AppStatusBarOrder{}), VoiceMode: defaultVoiceModeSettings(), KanbanHiddenStepIDs: map[string][]string{}, @@ -698,7 +688,7 @@ func DefaultSidebarViews() []models.SidebarView { func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID string) (*models.UserSettings, error) { settings := defaultUserSettings(userID) var settingsRaw string - if err := scanner.Scan(&settingsRaw, &settings.UpdatedAt); err != nil { + if err := scanner.Scan(&settingsRaw, &settings.UpdatedAt, &settings.Revision); err != nil { return nil, err } if settingsRaw == "" || settingsRaw == "{}" { @@ -755,6 +745,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarEnabled *bool `json:"app_status_bar_enabled"` AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode *storedVoiceMode `json:"voice_mode"` KanbanHiddenStepIDs json.RawMessage `json:"kanban_hidden_step_ids"` @@ -873,6 +864,9 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.TerminalFontSize = payload.TerminalFontSize settings.VoiceMode = mergeVoiceModeDefaults(payload.VoiceMode) settings.SystemMetricsDisplay = payload.SystemMetricsDisplay + if payload.AppStatusBarEnabled != nil { + settings.AppStatusBarEnabled = *payload.AppStatusBarEnabled + } settings.AppStatusBarOrder = normalizeAppStatusBarOrder(payload.AppStatusBarOrder) if payload.ChangesPanelLayout == "flat" { settings.ChangesPanelLayout = "flat" diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 1286646dfb..81b8138cde 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -4,7 +4,9 @@ import ( "context" "encoding/json" "reflect" + "sort" "strings" + "sync" "testing" "time" @@ -16,7 +18,8 @@ import ( ) type settingsScanner struct { - raw string + raw string + revision int64 } func upsertUserSettingsForTest(t *testing.T, repo *sqliteRepository, ctx context.Context, settings *models.UserSettings) { @@ -33,9 +36,151 @@ func upsertUserSettingsForTest(t *testing.T, repo *sqliteRepository, ctx context func (s settingsScanner) Scan(dest ...any) error { *(dest[0].(*string)) = s.raw *(dest[1].(*time.Time)) = time.Time{} + *(dest[2].(*int64)) = s.revision return nil } +func TestSQLiteRepositoryMigratesLegacySettingsRevision(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() }) + assertLegacySettingsRevisionMigration(t, conn) +} + +func TestPostgresRepositoryMigratesLegacySettingsRevision(t *testing.T) { + conn := testutil.OpenIsolatedPostgres(t, testutil.PostgresDSNFromEnv(t)) + assertLegacySettingsRevisionMigration(t, conn) +} + +func assertLegacySettingsRevisionMigration(t *testing.T, conn *sqlx.DB) { + t.Helper() + now := time.Now().UTC() + if _, err := conn.Exec(` + CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL, + settings TEXT NOT NULL DEFAULT '{}', + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL + ) + `); err != nil { + t.Fatalf("create legacy users table: %v", err) + } + if _, err := conn.Exec( + conn.Rebind(`INSERT INTO users (id, email, settings, created_at, updated_at) VALUES (?, ?, '{}', ?, ?)`), + DefaultUserID, + DefaultUserEmail, + now, + now, + ); err != nil { + t.Fatalf("insert legacy user: %v", err) + } + + 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("read migrated settings: %v", err) + } + if settings.Revision != 0 { + t.Fatalf("migrated revision = %d, want 0", settings.Revision) + } + settings.AppStatusBarEnabled = true + settings.UpdatedAt = now.Add(time.Second) + updated, err := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, settings, nil) + if err != nil { + t.Fatalf("write migrated settings: %v", err) + } + if updated.Revision != 1 { + t.Fatalf("updated revision = %d, want 1", updated.Revision) + } + + replayedRepo, err := newSQLiteRepositoryWithDB(conn, conn) + if err != nil { + t.Fatalf("reinitialize migrated repo: %v", err) + } + replayed, err := replayedRepo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("read settings after migration replay: %v", err) + } + if replayed.Revision != 1 { + t.Fatalf("revision after migration replay = %d, want 1", replayed.Revision) + } + if !replayed.AppStatusBarEnabled { + t.Fatal("status bar preference was not preserved across migration replay") + } +} + +func TestSQLiteRepositoryAssignsUniqueAtomicSettingsRevisions(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) + } + first := *settings + second := *settings + first.AppStatusBarEnabled = true + second.ChangesPanelLayout = "flat" + + start := make(chan struct{}) + revisions := make(chan int64, 2) + errors := make(chan error, 2) + var workers sync.WaitGroup + for _, candidate := range []*models.UserSettings{&first, &second} { + workers.Add(1) + go func(candidate *models.UserSettings) { + defer workers.Done() + <-start + updated, updateErr := repo.UpsertUserSettingsPreservingTaskCreateLastUsed(ctx, candidate, nil) + if updateErr != nil { + errors <- updateErr + return + } + revisions <- updated.Revision + }(candidate) + } + close(start) + workers.Wait() + close(errors) + close(revisions) + for updateErr := range errors { + t.Fatalf("concurrent update: %v", updateErr) + } + + got := make([]int64, 0, 2) + for revision := range revisions { + got = append(got, revision) + } + sort.Slice(got, func(i, j int) bool { return got[i] < got[j] }) + if !reflect.DeepEqual(got, []int64{1, 2}) { + t.Fatalf("revisions = %v, want unique commit order [1 2]", got) + } + final, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get final settings: %v", err) + } + if final.Revision != 2 { + t.Fatalf("final revision = %d, want 2", final.Revision) + } +} + func TestScanUserSettingsStartupPage(t *testing.T) { tests := []struct { name string @@ -924,6 +1069,10 @@ func TestBuildPostgresTaskCreateLastUsedUpdatePatchesNonEmptyFields(t *testing.T if !strings.Contains(query, "jsonb_set") { t.Fatalf("postgres update should use jsonb_set: %s", query) } + if !strings.Contains(query, "settings_revision = settings_revision + 1") || + !strings.Contains(query, "RETURNING settings, updated_at, settings_revision") { + t.Fatalf("postgres update should atomically return its settings revision: %s", query) + } if !strings.Contains(query, "{task_create_last_used,repository_id}") || !strings.Contains(query, "{task_create_last_used,branch}") || !strings.Contains(query, "{task_create_last_used,agent_profile_id}") || @@ -1011,6 +1160,10 @@ func TestBuildPostgresUserSettingsPreservingTaskCreateLastUsedUpdateUsesJSONB(t if !strings.Contains(query, "?::jsonb") || !strings.Contains(query, "jsonb_set") { t.Fatalf("postgres update should merge payload with jsonb_set: %s", query) } + if !strings.Contains(query, "settings_revision = settings_revision + 1") || + !strings.Contains(query, "RETURNING settings, updated_at, settings_revision") { + t.Fatalf("postgres update should atomically return its settings revision: %s", query) + } if !strings.Contains(query, "{task_create_last_used,repository_id}") || !strings.Contains(query, "{task_create_last_used,branch}") || !strings.Contains(query, "{task_create_last_used,agent_profile_id}") || @@ -1060,6 +1213,10 @@ func TestPostgresRepositoryTaskCreateLastUsedRoundTrip(t *testing.T) { got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" { t.Fatalf("postgres task-create update mismatch: %+v", got.TaskCreateLastUsed) } + if got.Revision < 2 { + t.Fatalf("postgres settings revision = %d, want at least 2", got.Revision) + } + firstRevision := got.Revision staleSettings.SidebarActiveViewID = "view-after" staleSettings.TaskCreateLastUsed = models.TaskCreateLastUsed{ @@ -1079,6 +1236,9 @@ func TestPostgresRepositoryTaskCreateLastUsedRoundTrip(t *testing.T) { got.TaskCreateLastUsed.ExecutorProfileID != "exec-before" { t.Fatalf("postgres preserving upsert should keep current task-create values: %+v", got.TaskCreateLastUsed) } + if got.Revision != firstRevision+1 { + t.Fatalf("postgres settings revision = %d, want %d", got.Revision, firstRevision+1) + } } func TestSQLiteRepositoryUpsertSettingsPreservesCurrentTaskCreateLastUsed(t *testing.T) { diff --git a/apps/web/app/actions/features.test.ts b/apps/web/app/actions/features.test.ts index cf39f5ff84..04eb12e46c 100644 --- a/apps/web/app/actions/features.test.ts +++ b/apps/web/app/actions/features.test.ts @@ -46,10 +46,7 @@ describe("getFeatureFlagsAction", () => { it("fails closed for missing and non-boolean backend values", async () => { fetchSpy.mockResolvedValueOnce(jsonResponse({ office: "true", appStatusBar: true })); - await expect(getFeatureFlagsAction()).resolves.toEqual({ - ...flagsWith(false), - appStatusBar: true, - }); + await expect(getFeatureFlagsAction()).resolves.toEqual(flagsWith(false)); }); it("falls back to all-off defaults when the backend is unavailable", async () => { diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b79127ae0a..1e7213aa64 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -132,10 +132,8 @@ --git-deletion: 244 112 103; } -@media (min-width: 640px) { - :root { - --app-status-bar-height: 1.5rem; - } +:root[data-app-status-bar-visible="true"] { + --app-status-bar-height: 1.5rem; } .dark { diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx index d9c062403e..407acc2dd4 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.test.tsx @@ -26,10 +26,10 @@ const state = { user: null as { display_name: string; email: string } | null, }, connection: { issueSeverity: "none" as "none" | "unstable" | "lost" }, + userSettings: { appStatusBarEnabled: true }, }; let officeEnabled = false; -let appStatusBarEnabled = true; const DEFAULT_PATHNAME = "/tasks/session-1"; let pathname = DEFAULT_PATHNAME; @@ -52,7 +52,7 @@ vi.mock("@/components/state-provider", () => ({ })); vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: (name: string) => (name === "office" ? officeEnabled : appStatusBarEnabled), + useFeature: () => officeEnabled, })); // The footer renders its insight buttons from the navigation manifest; the @@ -143,7 +143,7 @@ function resetFooterState() { state.appSidebar.settingsMode = false; state.auth = { mode: "disabled", user: null }; state.connection.issueSeverity = "none"; - appStatusBarEnabled = true; + state.userSettings.appStatusBarEnabled = true; window.localStorage.clear(); document.cookie = "office-active-workspace=; path=/; max-age=0"; mocks.routerPush.mockClear(); @@ -318,14 +318,14 @@ describe("AppSidebarFooter settings gear", () => { describe("AppSidebarFooter connection fallback", () => { beforeEach(() => { - appStatusBarEnabled = true; + state.userSettings.appStatusBarEnabled = true; state.connection.issueSeverity = "none"; }); afterEach(cleanup); it("shows the connection fallback immediately after the theme control during an outage", () => { - appStatusBarEnabled = false; + state.userSettings.appStatusBarEnabled = false; state.connection.issueSeverity = "unstable"; renderFooter(); diff --git a/apps/web/components/app-sidebar/app-sidebar-footer.tsx b/apps/web/components/app-sidebar/app-sidebar-footer.tsx index a5dd197ac1..16e7207d97 100644 --- a/apps/web/components/app-sidebar/app-sidebar-footer.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-footer.tsx @@ -261,7 +261,7 @@ export function AppSidebarFooter({ collapsed, onToggleSettingsMode }: AppSidebar const settingsMode = useAppStore((s) => s.appSidebar.settingsMode); const toggleSettings = useSettingsGearToggle(settingsMode, activeWorkspace, onToggleSettingsMode); const officeEnabled = useFeature("office"); - const appStatusBarEnabled = useFeature("appStatusBar"); + const appStatusBarEnabled = useAppStore((s) => s.userSettings.appStatusBarEnabled); const insightDestinations = useStaticDestinations("sidebar", "insights"); const releaseNotes = useReleaseNotes(); const improveOpen = useAppStore((s) => s.appSidebar.improveDialogOpen); diff --git a/apps/web/components/app-status-bar/app-status-bar.test.tsx b/apps/web/components/app-status-bar/app-status-bar.test.tsx index 674d2eca2a..9570a4cfdd 100644 --- a/apps/web/components/app-status-bar/app-status-bar.test.tsx +++ b/apps/web/components/app-status-bar/app-status-bar.test.tsx @@ -2,11 +2,13 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra import { TooltipProvider } from "@kandev/ui/tooltip"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { StateProvider } from "@/components/state-provider"; +import type { StoreApi } from "zustand"; +import { StateProvider, useAppStoreApi } from "@/components/state-provider"; import type { AppState } from "@/lib/state/store"; import { ApiError } from "@/lib/api/client"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; import { pluginRegistry } from "@/lib/plugins/registry"; +import { workspaceId } from "@/lib/types/ids"; import { AppStatusBar } from "./app-status-bar"; import { APP_STATUS_CONNECTION_ID, APP_STATUS_METRICS_ID } from "./app-status-bar-order"; @@ -148,6 +150,19 @@ describe("AppStatusBar drag input", () => { renderBar(); const leftId = `plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`; const rightId = `plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0`; + vi.mocked(updateUserSettings).mockResolvedValueOnce({ + settings: { + user_id: "default-user", + workspace_id: workspaceId(""), + repository_ids: [], + app_status_bar_order: { + left_item_ids: [APP_STATUS_CONNECTION_ID], + right_item_ids: [leftId, rightId], + }, + revision: 1, + updated_at: "2026-08-11T21:00:00Z", + }, + } as Awaited>); const dragged = statusItem(leftId); setRect(statusItem(APP_STATUS_CONNECTION_ID), 0, 20); setRect(dragged, 20, 40); @@ -224,6 +239,70 @@ describe("AppStatusBar drag input", () => { }); }); +describe("AppStatusBar revision ordering", () => { + it("keeps a newer settings snapshot when an older order response finishes later", async () => { + const save = deferred>>(); + vi.mocked(updateUserSettings).mockReturnValueOnce(save.promise); + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent(APP_STATUS_LEFT_SLOT, () => Left); + const storeHolder: StoreHolder = { current: null }; + renderBar(undefined, storeHolder); + const store = requireCapturedStore(storeHolder); + const leftId = `plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`; + const dragged = statusItem(leftId); + setRect(statusItem(APP_STATUS_CONNECTION_ID), 0, 20); + setRect(dragged, 20, 40); + setRect(screen.getByTestId("app-status-bar-spacer"), 40, 80); + + fireEvent.pointerDown(dragged, { + pointerId: 5, + pointerType: "mouse", + ctrlKey: true, + clientX: 30, + }); + fireEvent.pointerMove(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 5, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + fireEvent.pointerUp(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 5, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + await waitFor(() => expect(updateUserSettings).toHaveBeenCalledOnce()); + + const newerOrder = { + leftItemIds: [APP_STATUS_CONNECTION_ID, leftId], + rightItemIds: [], + }; + act(() => { + const state = store.getState(); + state.setUserSettings({ ...state.userSettings, revision: 3, appStatusBarOrder: newerOrder }); + }); + save.resolve({ + settings: { + user_id: "default-user", + workspace_id: workspaceId(""), + repository_ids: [], + app_status_bar_order: { + left_item_ids: [APP_STATUS_CONNECTION_ID], + right_item_ids: [leftId], + }, + revision: 2, + updated_at: "2026-08-11T21:00:00Z", + }, + } as Awaited>); + + await waitFor(() => expect(statusItem(leftId).dataset.statusSide).toBe("left")); + expect(store.getState().userSettings.appStatusBarOrder).toEqual(newerOrder); + expect(store.getState().userSettings.revision).toBe(3); + }); +}); + describe("AppStatusBar persistence and recovery", () => { it("restores the confirmed order and reports a rejected save", async () => { pluginRegistry @@ -281,9 +360,22 @@ describe("AppStatusBar persistence and recovery", () => { }); }); -function renderBar(initialState?: Partial) { +type StoreHolder = { current: StoreApi | null }; + +function StoreCapture({ holder }: { holder: StoreHolder }) { + holder.current = useAppStoreApi(); + return null; +} + +function requireCapturedStore(holder: StoreHolder): StoreApi { + if (!holder.current) throw new Error("App store was not captured"); + return holder.current; +} + +function renderBar(initialState?: Partial, storeHolder?: StoreHolder) { return render( + {storeHolder ? : null} ) { ); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + function statusItem(id: string): HTMLElement { const item = document.querySelector(`[data-status-item-id="${id}"]`); if (!item) throw new Error(`Missing status item ${id}`); diff --git a/apps/web/components/app-status-bar/app-status-drawer.test.tsx b/apps/web/components/app-status-bar/app-status-drawer.test.tsx index 7b70de1cd1..c24da13dfb 100644 --- a/apps/web/components/app-status-bar/app-status-drawer.test.tsx +++ b/apps/web/components/app-status-bar/app-status-drawer.test.tsx @@ -2,7 +2,6 @@ import { cleanup, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { afterEach, describe, expect, it, vi } from "vitest"; import { StateProvider } from "@/components/state-provider"; -import { defaultFeaturesState } from "@/lib/state/slices/features/features-slice"; import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; import { pluginRegistry } from "@/lib/plugins/registry"; import { useDockviewStore } from "@/lib/state/dockview-store"; @@ -64,9 +63,9 @@ function renderPhoneLspDrawer() { render( ({ breakpoint: "desktop" as "mobile" | "tablet" | "desktop", })); -const featureState = vi.hoisted(() => ({ appStatusBar: true })); +let appStatusBarEnabled = true; const restartState = vi.hoisted(() => ({ capability: { status: "resolved", capability: { supported: false, mode: "manual" } }, restart: { @@ -21,6 +22,7 @@ const restartState = vi.hoisted(() => ({ const STATUS_BAR_TEST_ID = "app-status-bar"; const STATUS_DRAWER_TEST_ID = "app-status-drawer"; const STATUS_DRAWER_TRIGGER_TEST_ID = "app-status-drawer-trigger"; +const STATUS_BAR_VISIBLE_ATTRIBUTE = "data-app-status-bar-visible"; vi.mock("@/hooks/use-responsive-breakpoint", () => ({ useResponsiveBreakpoint: () => ({ @@ -35,10 +37,6 @@ vi.mock("@/hooks/use-responsive-breakpoint", () => ({ }), })); -vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: (name: string) => (name === "appStatusBar" ? featureState.appStatusBar : true), -})); - vi.mock("@/hooks/domains/system/use-restart-capability", () => ({ useRestartCapability: () => restartState.capability, })); @@ -61,7 +59,16 @@ vi.mock("./app-status-drawer", () => ({ function renderSurface(initialState?: Partial, children = ) { return render( - + {children} , ); @@ -89,20 +96,24 @@ function ConnectionIssueControls() { ); } +function verifyDesktopStatusBarSurface() { + renderSurface(); + + expect(screen.getByTestId(STATUS_BAR_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull(); + expect(document.documentElement.getAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE)).toBe("true"); +} + describe("AppStatusSurfaceProvider", () => { beforeEach(() => { responsiveState.breakpoint = "desktop"; - featureState.appStatusBar = true; + appStatusBarEnabled = true; + document.documentElement.removeAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE); }); afterEach(cleanup); - it("mounts only desktop status bar outside phone breakpoint", () => { - renderSurface(); - - expect(screen.getByTestId(STATUS_BAR_TEST_ID)).toBeTruthy(); - expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull(); - }); + it("mounts only desktop status bar outside phone breakpoint", verifyDesktopStatusBarSurface); it("mounts only phone drawer and opens it from native trigger", () => { responsiveState.breakpoint = "mobile"; @@ -110,6 +121,7 @@ describe("AppStatusSurfaceProvider", () => { expect(screen.queryByTestId(STATUS_BAR_TEST_ID)).toBeNull(); expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("false"); + expect(document.documentElement.hasAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE)).toBe(false); fireEvent.click(screen.getByRole("button", { name: "Open status" })); expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("true"); @@ -127,18 +139,19 @@ describe("AppStatusSurfaceProvider", () => { expect(trigger.className).not.toContain("sm:hidden"); }); - it("hides both presentations when the app-status-bar feature is disabled", () => { + it("hides both presentations when the status bar preference is disabled", () => { responsiveState.breakpoint = "mobile"; - featureState.appStatusBar = false; + appStatusBarEnabled = false; renderSurface(); expect(screen.queryByTestId(STATUS_BAR_TEST_ID)).toBeNull(); expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull(); expect(screen.queryByTestId(STATUS_DRAWER_TRIGGER_TEST_ID)).toBeNull(); + expect(document.documentElement.hasAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE)).toBe(false); }); - it("keeps the runtime alert visible when the app-status-bar feature is disabled", () => { - featureState.appStatusBar = false; + it("keeps the runtime alert visible when the status bar preference is disabled", () => { + appStatusBarEnabled = false; renderSurface({ agentRuntime: { status: "unavailable", @@ -154,7 +167,7 @@ describe("AppStatusSurfaceProvider", () => { it("exposes a connection-only Status drawer for an active phone warning when disabled", () => { responsiveState.breakpoint = "mobile"; - featureState.appStatusBar = false; + appStatusBarEnabled = false; renderSurface({ connection: { status: "reconnecting", error: null, issueSeverity: "unstable" }, }); @@ -181,7 +194,7 @@ describe("AppStatusSurfaceProvider", () => { it("keeps an active connection warning reachable at the tablet breakpoint", () => { responsiveState.breakpoint = "tablet"; - featureState.appStatusBar = false; + appStatusBarEnabled = false; renderSurface({ connection: { status: "reconnecting", error: null, issueSeverity: "unstable" }, }); @@ -196,7 +209,7 @@ describe("AppStatusSurfaceProvider", () => { it("closes a connection-only drawer when the warning recovers", () => { responsiveState.breakpoint = "mobile"; - featureState.appStatusBar = false; + appStatusBarEnabled = false; renderSurface( { connection: { status: "reconnecting", error: null, issueSeverity: "unstable" } }, <> diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.tsx index 50c99321e5..0190d91d80 100644 --- a/apps/web/components/app-status-bar/app-status-surface-provider.tsx +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -4,6 +4,7 @@ import { createContext, useContext, useEffect, + useLayoutEffect, useMemo, useState, type ComponentProps, @@ -14,7 +15,6 @@ import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import { cn } from "@kandev/ui/lib/utils"; import { useAppStore } from "@/components/state-provider"; -import { useFeature } from "@/hooks/domains/features/use-feature"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { usePathname } from "@/lib/routing/client-router"; import { AppStatusBar } from "./app-status-bar"; @@ -33,6 +33,7 @@ type AppStatusDrawerContextValue = { }; const AppStatusDrawerContext = createContext(null); +const STATUS_BAR_VISIBLE_ATTRIBUTE = "data-app-status-bar-visible"; const unavailableDrawer: AppStatusDrawerContextValue = { enabled: false, issueSeverity: "none", @@ -116,11 +117,22 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const issueSeverity = useAppStore((state) => state.connection.issueSeverity); - const appStatusBarEnabled = useFeature("appStatusBar"); + const appStatusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled); const { isMobile, isTablet, isFullDesktop } = useResponsiveBreakpoint(); const connectionOnly = !appStatusBarEnabled && issueSeverity !== "none"; const useDrawerSurface = isMobile || (isTablet && connectionOnly); const drawerEnabled = useDrawerSurface && (appStatusBarEnabled || connectionOnly); + const inlineStatusBarVisible = !useDrawerSurface && appStatusBarEnabled; + + useLayoutEffect(() => { + const root = document.documentElement; + if (inlineStatusBarVisible) { + root.setAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE, "true"); + } else { + root.removeAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE); + } + return () => root.removeAttribute(STATUS_BAR_VISIBLE_ATTRIBUTE); + }, [inlineStatusBarVisible]); useEffect(() => { if (!drawerEnabled) setStatusDrawerOpen(false); @@ -151,7 +163,7 @@ export function AppStatusSurfaceProvider({ children }: { children: ReactNode })
{children} - {(useDrawerSurface ? drawerEnabled : appStatusBarEnabled) && + {(useDrawerSurface ? drawerEnabled : inlineStatusBarVisible) && (useDrawerSurface ? ( ((order) => ({ - app_status_bar_order: { - left_item_ids: order.leftItemIds, - right_item_ids: order.rightItemIds, - }, -})); +const syncAppStatusBarOrder = createQueuedUserSettingsSyncWithResponse( + (order) => ({ + app_status_bar_order: { + left_item_ids: order.leftItemIds, + right_item_ids: order.rightItemIds, + }, + }), +); export function useAppStatusBarOrder(activeItems: T[]) { const savedOrder = useAppStore((state) => state.userSettings.appStatusBarOrder); @@ -42,9 +45,9 @@ export function useAppStatusBarOrder(activeIt const revision = ++latestRevision.current; setOptimisticOrder(next); void syncAppStatusBarOrder(next) - .then(() => { + .then((response) => { const state = store.getState(); - state.setUserSettings({ ...state.userSettings, appStatusBarOrder: next }); + state.setUserSettings(mapUserSettingsResponse(response, state.userSettings)); if (latestRevision.current === revision) setOptimisticOrder(null); }) .catch(() => { diff --git a/apps/web/components/settings/app-status-bar-settings-card.test.tsx b/apps/web/components/settings/app-status-bar-settings-card.test.tsx new file mode 100644 index 0000000000..31b068373d --- /dev/null +++ b/apps/web/components/settings/app-status-bar-settings-card.test.tsx @@ -0,0 +1,29 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppStatusBarSettingsCard } from "./app-status-bar-settings-card"; + +afterEach(cleanup); + +describe("AppStatusBarSettingsCard", () => { + it("renders a controlled, dirty, touch-sized visibility row", () => { + const onChange = vi.fn(); + render(); + + const toggle = screen.getByRole("switch", { name: "Show status bar" }); + expect(toggle.getAttribute("data-state")).toBe("checked"); + expect(toggle.getAttribute("data-settings-dirty")).toBe("true"); + expect(screen.getByTestId("app-status-bar-settings-card").dataset.settingsDirty).toBe("true"); + expect(screen.getByTestId("app-status-bar-toggle-row").className).toContain("min-h-11"); + expect(toggle.className).toContain("data-[size=default]:h-11"); + expect(toggle.className).toContain("data-[size=default]:w-11"); + expect( + screen.getByText( + "Show status details along the bottom of Kandev. On phones, they appear in the Status drawer. Connection warnings remain visible when this is off.", + ), + ).toBeTruthy(); + + fireEvent.click(toggle); + expect(onChange).toHaveBeenCalledWith(false); + expect(onChange).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/components/settings/app-status-bar-settings-card.tsx b/apps/web/components/settings/app-status-bar-settings-card.tsx new file mode 100644 index 0000000000..af3a59acd1 --- /dev/null +++ b/apps/web/components/settings/app-status-bar-settings-card.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Label } from "@kandev/ui/label"; +import { Switch } from "@kandev/ui/switch"; +import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; +import { SettingsCard } from "./settings-card"; + +export function AppStatusBarSettingsCard({ + enabled, + isDirty, + onChange, +}: { + enabled: boolean; + isDirty: boolean; + onChange: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + + + {t("settings:statusBar")} + + +
+
+ +

+ {t("settings:showStatusBarDescription")} +

+
+ +
+
+
+ ); +} diff --git a/apps/web/components/settings/appearance-settings-state.ts b/apps/web/components/settings/appearance-settings-state.ts new file mode 100644 index 0000000000..3581e4861f --- /dev/null +++ b/apps/web/components/settings/appearance-settings-state.ts @@ -0,0 +1,94 @@ +import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode"; +import type { Theme } from "@/lib/settings/types"; +import type { UserSettingsState } from "@/lib/state/slices/settings/types"; +import type { UserSettingsUpdatePayload } from "@/lib/types/http"; + +export type AppearanceState = { + theme: Theme; + settingsMenuMode: SettingsMenuMode; + startupPage: UserSettingsState["startupPage"]; + changesPanelLayout: UserSettingsState["changesPanelLayout"]; + appStatusBarEnabled: boolean; + showMetrics: boolean; + simplifiedMetrics: boolean; +}; + +export function createAppearanceSavedState( + theme: Theme, + settingsMenuMode: SettingsMenuMode, + userSettings: Pick< + UserSettingsState, + "appStatusBarEnabled" | "changesPanelLayout" | "startupPage" | "systemMetricsDisplay" + >, +): AppearanceState { + return { + theme, + appStatusBarEnabled: userSettings.appStatusBarEnabled, + // Per-device, but drafted and saved with account settings under one control. + settingsMenuMode, + changesPanelLayout: userSettings.changesPanelLayout, + startupPage: userSettings.startupPage, + showMetrics: userSettings.systemMetricsDisplay.showInTopbar, + simplifiedMetrics: userSettings.systemMetricsDisplay.simplified, + }; +} + +export function buildAppearanceUserSettingsPatch( + submitted: AppearanceState, + saved: AppearanceState, +): UserSettingsUpdatePayload { + const patch: UserSettingsUpdatePayload = {}; + if (submitted.startupPage !== saved.startupPage) { + patch.startup_page = submitted.startupPage; + } + if (submitted.changesPanelLayout !== saved.changesPanelLayout) { + patch.changes_panel_layout = submitted.changesPanelLayout; + } + if (submitted.appStatusBarEnabled !== saved.appStatusBarEnabled) { + patch.app_status_bar_enabled = submitted.appStatusBarEnabled; + } + const metrics: NonNullable = {}; + if (submitted.showMetrics !== saved.showMetrics) { + metrics.show_in_topbar = submitted.showMetrics; + } + if (submitted.simplifiedMetrics !== saved.simplifiedMetrics) { + metrics.simplified = submitted.simplifiedMetrics; + } + if (Object.keys(metrics).length > 0) { + patch.system_metrics_display = metrics; + } + return patch; +} + +export function rebaseAppearanceDraft( + draft: AppearanceState, + baseline: AppearanceState, + nextSaved: AppearanceState, + preserveDraftFields: ReadonlySet = new Set(), +): AppearanceState { + const rebase = (field: Field): AppearanceState[Field] => + preserveDraftFields.has(field) || draft[field] !== baseline[field] + ? draft[field] + : nextSaved[field]; + return { + theme: rebase("theme"), + settingsMenuMode: rebase("settingsMenuMode"), + startupPage: rebase("startupPage"), + changesPanelLayout: rebase("changesPanelLayout"), + appStatusBarEnabled: rebase("appStatusBarEnabled"), + showMetrics: rebase("showMetrics"), + simplifiedMetrics: rebase("simplifiedMetrics"), + }; +} + +export function appearanceRevision(state: AppearanceState): string { + return JSON.stringify([ + state.theme, + state.settingsMenuMode, + state.startupPage, + state.changesPanelLayout, + state.appStatusBarEnabled, + state.showMetrics, + state.simplifiedMetrics, + ]); +} diff --git a/apps/web/components/settings/general-settings.test.tsx b/apps/web/components/settings/general-settings.test.tsx new file mode 100644 index 0000000000..601a308548 --- /dev/null +++ b/apps/web/components/settings/general-settings.test.tsx @@ -0,0 +1,357 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; +import { SettingsSaveProvider } from "./settings-save-provider"; +import { AppearanceSettings } from "./general-settings"; + +const apiMocks = vi.hoisted(() => ({ updateUserSettings: vi.fn() })); +const SHOW_STATUS_BAR_LABEL = "Show status bar"; +const SAVE_CHANGES_LABEL = "Save changes"; +const CHECKED_STATE = "checked"; +const DATA_STATE_ATTRIBUTE = "data-state"; +const INITIAL_REVISION = 1; +const OLDER_LIVE_REVISION = 2; +const SAVED_REVISION = 3; +const themeMocks = vi.hoisted(() => ({ + previewTheme: vi.fn(), + commitTheme: vi.fn(), + restoreTheme: vi.fn(), +})); +const storeMocks = vi.hoisted(() => ({ + state: {} as Record, + setUserSettings: vi.fn(), + previewSettingsMenuMode: vi.fn(), + commitSettingsMenuMode: vi.fn(), + restoreSettingsMenuMode: vi.fn(), +})); + +vi.mock("@/lib/api", () => ({ + updateUserSettings: (...args: unknown[]) => apiMocks.updateUserSettings(...args), +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector(storeMocks.state), + useAppStoreApi: () => ({ getState: () => storeMocks.state }), +})); + +vi.mock("@/components/theme/app-theme", () => ({ + useTheme: () => ({ + savedTheme: "system", + previewTheme: themeMocks.previewTheme, + commitTheme: themeMocks.commitTheme, + restoreTheme: themeMocks.restoreTheme, + }), +})); + +vi.mock("@/components/settings/language-settings", () => ({ LanguageSettings: () => null })); +vi.mock("@/components/settings/startup-page-settings-card", () => ({ + StartupPageSettingsCard: () => null, +})); +vi.mock("@/components/settings/system-metrics-settings-card", () => ({ + SystemMetricsSettingsCard: () => null, +})); + +function renderAppearance() { + return render( + + + , + ); +} + +function appearanceSettingsResponse(overrides: Record = {}): { + settings: Record; +} { + return { + settings: { + startup_page: "task_overview", + changes_panel_layout: "tree", + app_status_bar_enabled: true, + system_metrics_display: { show_in_topbar: false, simplified: false }, + ...overrides, + }, + }; +} + +async function verifyUntouchedExternalAppearanceValueIsNotOverwritten() { + apiMocks.updateUserSettings.mockResolvedValue( + appearanceSettingsResponse({ + app_status_bar_enabled: false, + changes_panel_layout: "flat", + }), + ); + const view = renderAppearance(); + + fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL })); + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + changesPanelLayout: "flat", + }, + }; + view.rerender( + + + , + ); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + const payload = apiMocks.updateUserSettings.mock.calls[0]?.[0]; + expect(payload).toMatchObject({ app_status_bar_enabled: false }); + expect(payload).not.toHaveProperty("changes_panel_layout"); +} + +async function verifyNewerLiveUpdateWinsOverOlderSaveResponse() { + let resolveSave: (value: ReturnType) => void = () => {}; + apiMocks.updateUserSettings.mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + revision: INITIAL_REVISION, + }, + }; + const view = renderAppearance(); + + fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + appStatusBarEnabled: true, + revision: SAVED_REVISION, + }, + }; + view.rerender( + + + , + ); + resolveSave( + appearanceSettingsResponse({ + app_status_bar_enabled: false, + revision: OLDER_LIVE_REVISION, + }), + ); + + await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce()); + expect(storeMocks.setUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ appStatusBarEnabled: true }), + ); +} + +async function verifyEditDuringSaveSurvivesItsLiveUpdate() { + let resolveSave: (value: ReturnType) => void = () => {}; + apiMocks.updateUserSettings.mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + const view = renderAppearance(); + const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }); + + fireEvent.click(toggle); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + + fireEvent.click(toggle); + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + appStatusBarEnabled: false, + }, + }; + view.rerender( + + + , + ); + + await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE)); + resolveSave(appearanceSettingsResponse({ app_status_bar_enabled: false })); + await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce()); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + expect(toggle.getAttribute("data-settings-dirty")).toBe("true"); +} + +async function verifyNewerSaveResponseWinsOverOlderLiveUpdate() { + let resolveSave: (value: ReturnType) => void = () => {}; + apiMocks.updateUserSettings.mockReturnValueOnce( + new Promise((resolve) => { + resolveSave = resolve; + }), + ); + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + revision: INITIAL_REVISION, + }, + }; + const view = renderAppearance(); + + fireEvent.click(screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL })); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + + storeMocks.state = { + ...storeMocks.state, + userSettings: { + ...(storeMocks.state.userSettings as typeof defaultSettingsState.userSettings), + appStatusBarEnabled: true, + revision: OLDER_LIVE_REVISION, + }, + }; + view.rerender( + + + , + ); + resolveSave( + appearanceSettingsResponse({ + app_status_bar_enabled: false, + revision: SAVED_REVISION, + }), + ); + + await waitFor(() => expect(storeMocks.setUserSettings).toHaveBeenCalledOnce()); + expect(storeMocks.setUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ appStatusBarEnabled: false, revision: SAVED_REVISION }), + ); +} + +beforeEach(() => { + apiMocks.updateUserSettings.mockReset(); + storeMocks.setUserSettings.mockReset(); + themeMocks.previewTheme.mockReset(); + themeMocks.commitTheme.mockReset(); + themeMocks.restoreTheme.mockReset(); + storeMocks.previewSettingsMenuMode.mockReset(); + storeMocks.commitSettingsMenuMode.mockReset(); + storeMocks.restoreSettingsMenuMode.mockReset(); + storeMocks.state = { + userSettings: { + ...defaultSettingsState.userSettings, + appStatusBarEnabled: true, + }, + settingsMenu: { savedMode: "flat" }, + setUserSettings: storeMocks.setUserSettings, + previewSettingsMenuMode: storeMocks.previewSettingsMenuMode, + commitSettingsMenuMode: storeMocks.commitSettingsMenuMode, + restoreSettingsMenuMode: storeMocks.restoreSettingsMenuMode, + }; +}); + +afterEach(cleanup); + +describe("AppearanceSettings status bar preference", () => { + it("saves local-only Appearance changes without a user-settings request", async () => { + renderAppearance(); + + fireEvent.click(screen.getByTestId("settings-menu-mode-accordion")); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + + await waitFor(() => + expect(storeMocks.commitSettingsMenuMode).toHaveBeenCalledWith("accordion"), + ); + expect(apiMocks.updateUserSettings).not.toHaveBeenCalled(); + expect(storeMocks.setUserSettings).not.toHaveBeenCalled(); + await waitFor(() => + expect(screen.getByTestId("settings-floating-save").getAttribute("data-status")).toBe( + "saved", + ), + ); + }); + + it("saves through the shared appearance action without optimistic store mutation", async () => { + apiMocks.updateUserSettings.mockResolvedValue( + appearanceSettingsResponse({ app_status_bar_enabled: false }), + ); + renderAppearance(); + + const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE); + + fireEvent.click(toggle); + + expect(apiMocks.updateUserSettings).not.toHaveBeenCalled(); + expect(storeMocks.setUserSettings).not.toHaveBeenCalled(); + expect(toggle.getAttribute("data-settings-dirty")).toBe("true"); + + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + + await waitFor(() => expect(apiMocks.updateUserSettings).toHaveBeenCalledOnce()); + expect(apiMocks.updateUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ app_status_bar_enabled: false }), + ); + expect(storeMocks.setUserSettings).toHaveBeenCalledWith( + expect.objectContaining({ appStatusBarEnabled: false }), + ); + await waitFor(() => + expect(screen.getByTestId("settings-floating-save").getAttribute("data-status")).toBe( + "saved", + ), + ); + }); + + it("restores the saved preference through Reset", async () => { + renderAppearance(); + + const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }); + fireEvent.click(toggle); + fireEvent.click(await screen.findByRole("button", { name: "Reset" })); + + await waitFor(() => expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe(CHECKED_STATE)); + expect(apiMocks.updateUserSettings).not.toHaveBeenCalled(); + expect(storeMocks.setUserSettings).not.toHaveBeenCalled(); + }); + + it("keeps the draft dirty and confirmed state unchanged after a failed save", async () => { + apiMocks.updateUserSettings.mockRejectedValueOnce(new Error("offline")); + renderAppearance(); + + const toggle = screen.getByRole("switch", { name: SHOW_STATUS_BAR_LABEL }); + fireEvent.click(toggle); + fireEvent.click(await screen.findByRole("button", { name: SAVE_CHANGES_LABEL })); + + expect(await screen.findByText("Couldn't save")).toBeTruthy(); + expect(toggle.getAttribute(DATA_STATE_ATTRIBUTE)).toBe("unchecked"); + expect(toggle.getAttribute("data-settings-dirty")).toBe("true"); + expect(storeMocks.setUserSettings).not.toHaveBeenCalled(); + expect( + (storeMocks.state.userSettings as { appStatusBarEnabled: boolean }).appStatusBarEnabled, + ).toBe(true); + }); + + it( + "does not overwrite a newer untouched Appearance value from another client", + verifyUntouchedExternalAppearanceValueIsNotOverwritten, + ); + + it( + "does not replace a newer live update when an older save response finishes", + verifyNewerLiveUpdateWinsOverOlderSaveResponse, + ); + + it( + "keeps an edit made during save when the submitted value arrives live", + verifyEditDuringSaveSurvivesItsLiveUpdate, + ); + + it( + "applies a newer save response after an older live update", + verifyNewerSaveResponseWinsOverOlderLiveUpdate, + ); +}); diff --git a/apps/web/components/settings/general-settings.tsx b/apps/web/components/settings/general-settings.tsx index 246835a7cb..076cf2c70c 100644 --- a/apps/web/components/settings/general-settings.tsx +++ b/apps/web/components/settings/general-settings.tsx @@ -1,5 +1,5 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useTheme } from "@/components/theme/app-theme"; import { @@ -40,8 +40,18 @@ import { usePlugins } from "@/hooks/domains/plugins/use-plugins"; import { StartupPageSettingsCard } from "@/components/settings/startup-page-settings-card"; import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences"; import { SleepInhibitionSettings } from "@/components/settings/sleep-inhibition-settings"; +import { AppStatusBarSettingsCard } from "@/components/settings/app-status-bar-settings-card"; import { SettingsMenuModeCard } from "@/components/settings/settings-menu-mode-card"; import type { SettingsMenuMode } from "@/lib/settings/settings-menu-mode"; +import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; +import { + appearanceRevision, + buildAppearanceUserSettingsPatch, + createAppearanceSavedState, + rebaseAppearanceDraft, + type AppearanceState, +} from "@/components/settings/appearance-settings-state"; function ThemeSettingsCard({ theme, @@ -167,27 +177,6 @@ function ChangesPanelLayoutCard({ ); } -function createAppearanceSavedState( - theme: Theme, - settingsMenuMode: SettingsMenuMode, - userSettings: Pick< - UserSettingsState, - "changesPanelLayout" | "startupPage" | "systemMetricsDisplay" - >, -) { - return { - theme, - // Per-device, so it comes from the UI slice rather than `userSettings` — - // but it is drafted, previewed and saved alongside them, because the page - // has one save contributor and one Save changes control. - settingsMenuMode, - changesPanelLayout: userSettings.changesPanelLayout, - startupPage: userSettings.startupPage, - showMetrics: userSettings.systemMetricsDisplay.showInTopbar, - simplifiedMetrics: userSettings.systemMetricsDisplay.simplified, - }; -} - function StartupPageSettingsSection({ value, isDirty, @@ -261,6 +250,30 @@ function AppearanceThemeSection({ ); } +function AppStatusBarSettingsSection({ + enabled, + isDirty, + onChange, +}: { + enabled: boolean; + isDirty: boolean; + onChange: (enabled: boolean) => void; +}) { + const { t } = useTranslation(); + return ( + <> + + } + title={t("settings:statusBar")} + description={t("settings:configureStatusBarVisibility")} + > + + + + ); +} + export function TaskActionsSettings() { const { t } = useTranslation(); return ( @@ -302,8 +315,6 @@ export function TaskActionsSettings() { ); } -type AppearanceState = ReturnType; - /** * Register the Appearance page with the shared save coordinator. * @@ -316,12 +327,14 @@ type AppearanceState = ReturnType; function useAppearanceSaveContributor({ draft, draftRef, + editedDuringSaveRef, saved, setSaved, setDraft, }: { draft: AppearanceState; draftRef: { current: AppearanceState }; + editedDuringSaveRef: { current: Set | null }; saved: AppearanceState; setSaved: (next: AppearanceState) => void; setDraft: (next: AppearanceState) => void; @@ -332,47 +345,59 @@ function useAppearanceSaveContributor({ const previewMenuMode = useAppStore((state) => state.previewSettingsMenuMode); const commitMenuMode = useAppStore((state) => state.commitSettingsMenuMode); const restoreMenuMode = useAppStore((state) => state.restoreSettingsMenuMode); - const revision = JSON.stringify(draft); + const revision = appearanceRevision(draft); useSettingsSaveContributor({ id: "general-appearance", order: 10, revision, - isDirty: revision !== JSON.stringify(saved), + isDirty: revision !== appearanceRevision(saved), save: async () => { const submitted = draft; - const current = storeApi.getState().userSettings; - await updateUserSettings({ - workspace_id: current.workspaceId || "", - repository_ids: current.repositoryIds || [], - startup_page: submitted.startupPage, - changes_panel_layout: submitted.changesPanelLayout, - system_metrics_display: { - show_in_topbar: submitted.showMetrics, - simplified: submitted.simplifiedMetrics, - }, - }); - commitTheme(submitted.theme); - commitMenuMode(submitted.settingsMenuMode); - // A draft edited while the save was in flight keeps its preview: what was - // submitted is now persisted, but the screen should still show what the - // user is currently looking at. - if (draftRef.current.theme !== submitted.theme) { - previewTheme(draftRef.current.theme); + const patch = buildAppearanceUserSettingsPatch(submitted, saved); + const settingsAtSubmit = storeApi.getState().userSettings; + const editedDuringSave = new Set(); + editedDuringSaveRef.current = editedDuringSave; + try { + const response = Object.keys(patch).length > 0 ? await updateUserSettings(patch) : null; + commitTheme(submitted.theme); + commitMenuMode(submitted.settingsMenuMode); + // A draft edited while the save was in flight keeps its preview: what was + // submitted is now persisted, but the screen should still show what the + // user is currently looking at. + if (draftRef.current.theme !== submitted.theme) { + previewTheme(draftRef.current.theme); + } + if (draftRef.current.settingsMenuMode !== submitted.settingsMenuMode) { + previewMenuMode(draftRef.current.settingsMenuMode); + } + const latestUserSettings = storeApi.getState().userSettings; + let responseIsCurrent = false; + if (response) { + const responseOrder = compareUserSettingsRevisions( + response.settings.revision, + latestUserSettings.revision, + ); + responseIsCurrent = + responseOrder === null ? latestUserSettings === settingsAtSubmit : responseOrder >= 0; + } + const nextUserSettings = + response && responseIsCurrent + ? mapUserSettingsResponse(response, latestUserSettings) + : latestUserSettings; + const confirmed = createAppearanceSavedState( + submitted.theme, + submitted.settingsMenuMode, + nextUserSettings, + ); + setSaved(confirmed); + setDraft(rebaseAppearanceDraft(draftRef.current, submitted, confirmed, editedDuringSave)); + if (response) setUserSettings(nextUserSettings); + } finally { + if (editedDuringSaveRef.current === editedDuringSave) { + editedDuringSaveRef.current = null; + } } - if (draftRef.current.settingsMenuMode !== submitted.settingsMenuMode) { - previewMenuMode(draftRef.current.settingsMenuMode); - } - setSaved(submitted); - setUserSettings({ - ...storeApi.getState().userSettings, - changesPanelLayout: submitted.changesPanelLayout, - startupPage: submitted.startupPage, - systemMetricsDisplay: { - showInTopbar: submitted.showMetrics, - simplified: submitted.simplifiedMetrics, - }, - }); }, discard: () => { setDraft(saved); @@ -382,6 +407,25 @@ function useAppearanceSaveContributor({ }); } +function useAppearanceDraftUpdater( + setDraft: (update: (current: AppearanceState) => AppearanceState) => void, + editedDuringSaveRef: { current: Set | null }, +) { + return useCallback( + (patch: Partial) => + setDraft((current) => { + const next = { ...current, ...patch }; + for (const field of Object.keys(patch) as Array) { + if (current[field] !== next[field]) { + editedDuringSaveRef.current?.add(field); + } + } + return next; + }), + [editedDuringSaveRef, setDraft], + ); +} + export function AppearanceSettings() { const { t } = useTranslation(); const userSettings = useAppStore((state) => state.userSettings); @@ -393,14 +437,39 @@ export function AppearanceSettings() { ); const [draft, setDraft] = useState(saved); const draftRef = useRef(draft); + const savedRef = useRef(saved); + const editedDuringSaveRef = useRef | null>(null); draftRef.current = draft; + savedRef.current = saved; - useAppearanceSaveContributor({ draft, draftRef, saved, setSaved, setDraft }); + useEffect(() => { + const previousSaved = savedRef.current; + const nextSaved = createAppearanceSavedState( + previousSaved.theme, + previousSaved.settingsMenuMode, + userSettings, + ); + setDraft( + rebaseAppearanceDraft( + draftRef.current, + previousSaved, + nextSaved, + editedDuringSaveRef.current ?? undefined, + ), + ); + setSaved(nextSaved); + }, [userSettings]); - const updateDraft = useCallback( - (patch: Partial) => setDraft((current) => ({ ...current, ...patch })), - [], - ); + useAppearanceSaveContributor({ + draft, + draftRef, + editedDuringSaveRef, + saved, + setSaved, + setDraft, + }); + + const updateDraft = useAppearanceDraftUpdater(setDraft, editedDuringSaveRef); return (
@@ -432,6 +501,12 @@ export function AppearanceSettings() { + updateDraft({ appStatusBarEnabled })} + /> + { "var(--app-status-bar-height)", ); }); + + it("does not double-reserve status-bar space for the content-hosted save action", async () => { + render( + + + , + ); + + expect((await screen.findByTestId(FLOATING_SAVE_TEST_ID)).className).not.toContain( + "var(--app-status-bar-height)", + ); + }); }); describe("SettingsFloatingSave", () => { diff --git a/apps/web/components/system-metrics/topbar-metrics.test.tsx b/apps/web/components/system-metrics/topbar-metrics.test.tsx index 8d584feb56..2f6c3e85b5 100644 --- a/apps/web/components/system-metrics/topbar-metrics.test.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.test.tsx @@ -7,21 +7,17 @@ import { defaultSystemState } from "@/lib/state/slices/system/system-slice"; import type { AppState } from "@/lib/state/store"; import { TopbarMetrics } from "./topbar-metrics"; -const featureState = vi.hoisted(() => ({ appStatusBar: false })); const subscribeMock = vi.hoisted(() => vi.fn()); -vi.mock("@/hooks/domains/features/use-feature", () => ({ - useFeature: () => featureState.appStatusBar, -})); - vi.mock("@/hooks/use-system-metrics-subscription", () => ({ useSystemMetricsSubscription: subscribeMock, })); -function renderMetrics() { +function renderMetrics(appStatusBarEnabled: boolean) { const initialState = { userSettings: { ...defaultSettingsState.userSettings, + appStatusBarEnabled, systemMetricsDisplay: { showInTopbar: true, simplified: false }, }, system: { @@ -50,16 +46,15 @@ function renderMetrics() { ); } -describe("TopbarMetrics feature fallback", () => { +describe("TopbarMetrics preference fallback", () => { beforeEach(() => { - featureState.appStatusBar = false; subscribeMock.mockClear(); }); afterEach(cleanup); it("keeps existing metrics visible while the app status bar is disabled", () => { - renderMetrics(); + renderMetrics(false); expect(screen.getByTestId("topbar-metrics")).toBeTruthy(); expect(screen.getByLabelText("CPU 42%")).toBeTruthy(); @@ -67,8 +62,7 @@ describe("TopbarMetrics feature fallback", () => { }); it("yields metrics ownership to the app status bar when enabled", () => { - featureState.appStatusBar = true; - renderMetrics(); + renderMetrics(true); expect(screen.queryByTestId("topbar-metrics")).toBeNull(); expect(subscribeMock).not.toHaveBeenCalled(); diff --git a/apps/web/components/system-metrics/topbar-metrics.tsx b/apps/web/components/system-metrics/topbar-metrics.tsx index 46704adf90..9f06e84757 100644 --- a/apps/web/components/system-metrics/topbar-metrics.tsx +++ b/apps/web/components/system-metrics/topbar-metrics.tsx @@ -1,7 +1,6 @@ "use client"; import { useAppStore } from "@/components/state-provider"; -import { useFeature } from "@/hooks/domains/features/use-feature"; import { StatusSurfaceMetrics } from "./status-surface-metrics"; type TopbarMetricsProps = { @@ -9,9 +8,9 @@ type TopbarMetricsProps = { size?: "sm" | "lg"; }; -/** Preserves pre-status-bar metrics until the new surface is enabled. */ +/** Preserves topbar metrics while the user-owned status surface is hidden. */ export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) { - const statusBarEnabled = useFeature("appStatusBar"); + const statusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled); const metricsEnabled = useAppStore( (state) => state.userSettings.systemMetricsDisplay.showInTopbar, ); diff --git a/apps/web/e2e/fixtures/test-base.ts b/apps/web/e2e/fixtures/test-base.ts index 8ee251379c..f8a56ee7bd 100644 --- a/apps/web/e2e/fixtures/test-base.ts +++ b/apps/web/e2e/fixtures/test-base.ts @@ -337,6 +337,9 @@ test.beforeEach(async ({ apiClient, seedData }) => { sidebar_active_view_id: DEFAULT_SIDEBAR_VIEW.id, sidebar_draft: null, saved_layouts: [], + // Status-surface specs opt in from their local beforeEach hooks; unrelated + // tests start from the portable setting's default-off state. + app_status_bar_enabled: false, lsp_auto_start_languages: [], lsp_auto_install_languages: [], lsp_server_configs: {}, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 23cd77bcfa..fde9c4e33d 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -949,6 +949,7 @@ export class ApiClient { sidebar_active_view_id?: string; sidebar_draft?: unknown; saved_layouts?: unknown[]; + app_status_bar_enabled?: boolean; lsp_auto_start_languages?: string[]; lsp_auto_install_languages?: string[]; lsp_server_configs?: Record>; diff --git a/apps/web/e2e/helpers/app-status-bar-settings.ts b/apps/web/e2e/helpers/app-status-bar-settings.ts new file mode 100644 index 0000000000..ddb7822cb8 --- /dev/null +++ b/apps/web/e2e/helpers/app-status-bar-settings.ts @@ -0,0 +1,42 @@ +import type { ApiClient } from "./api-client"; + +export type AppStatusBarOrderApi = { + left_item_ids: string[]; + right_item_ids: string[]; +}; + +export type AppStatusBarSettingsBaseline = { + enabled: boolean; + order?: AppStatusBarOrderApi; +}; + +export async function captureAppStatusBarSettings( + apiClient: ApiClient, +): Promise { + const { settings } = await apiClient.getUserSettings(); + return { + enabled: settings.app_status_bar_enabled === true, + order: settings.app_status_bar_order as AppStatusBarOrderApi | undefined, + }; +} + +export async function setAppStatusBarEnabled( + apiClient: ApiClient, + enabled: boolean, +): Promise { + const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: enabled, + }); + if (!response.ok) throw new Error(`status bar preference update failed: ${response.status}`); +} + +export async function restoreAppStatusBarSettings( + apiClient: ApiClient, + baseline: AppStatusBarSettingsBaseline | undefined, +): Promise { + if (!baseline) return; + const payload: Record = { app_status_bar_enabled: baseline.enabled }; + if (baseline.order) payload.app_status_bar_order = baseline.order; + const response = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", payload); + if (!response.ok) throw new Error(`status bar preference restore failed: ${response.status}`); +} diff --git a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts index 2c28f7d030..c57c1f9ef5 100644 --- a/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts +++ b/apps/web/e2e/tests/kanban/mobile-kanban.spec.ts @@ -5,6 +5,7 @@ import { missingGitHealth } from "./health-fixtures"; test.describe("Mobile kanban view", () => { test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, system_metrics_display: { show_in_topbar: false }, workflow_filter_id: "", kanban_view_mode: "", @@ -16,6 +17,7 @@ test.describe("Mobile kanban view", () => { apiClient, }) => { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, system_metrics_display: { show_in_topbar: true }, }); const mobile = new MobileKanbanPage(testPage); diff --git a/apps/web/e2e/tests/layout/agent-runtime-unavailable.spec.ts b/apps/web/e2e/tests/layout/agent-runtime-unavailable.spec.ts index 6335897f70..1359346efc 100644 --- a/apps/web/e2e/tests/layout/agent-runtime-unavailable.spec.ts +++ b/apps/web/e2e/tests/layout/agent-runtime-unavailable.spec.ts @@ -1,30 +1,28 @@ -import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; import { setAgentRuntimeAvailability, stubAgentRuntimeRestart, } from "../../helpers/agent-runtime-availability"; import { KanbanPage } from "../../pages/kanban-page"; - -type E2EStoreWindow = Window & { - __KANDEV_E2E_STORE__?: { - getState: () => { - features: Record; - setFeatures: (features: Record) => void; - }; - }; -}; - -async function setAppStatusBarEnabled(page: Page, enabled: boolean): Promise { - await page.evaluate((nextEnabled) => { - const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__; - if (!store) throw new Error("E2E store bridge missing"); - const state = store.getState(); - state.setFeatures({ ...state.features, appStatusBar: nextEnabled }); - }, enabled); -} +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; test.describe("Agent runtime availability", () => { + let baseline: AppStatusBarSettingsBaseline | undefined; + + test.beforeEach(async ({ apiClient }) => { + baseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); + }); + + test.afterEach(async ({ apiClient }) => { + await restoreAppStatusBarSettings(apiClient, baseline); + }); + test("retains the current board, supports restart, and clears after recovery", async ({ testPage, apiClient, @@ -39,6 +37,7 @@ test.describe("Agent runtime availability", () => { const restartRequestCount = await stubAgentRuntimeRestart(testPage); const kanban = new KanbanPage(testPage); + await setAppStatusBarEnabled(apiClient, false); await kanban.goto(); const taskCard = kanban.taskCard(task.id); await expect(taskCard).toBeVisible(); @@ -58,7 +57,6 @@ test.describe("Agent runtime availability", () => { caption: "Persistent desktop agent runtime recovery alert", }); - await setAppStatusBarEnabled(testPage, false); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); await expect(alert).toBeVisible(); diff --git a/apps/web/e2e/tests/layout/app-status-bar.spec.ts b/apps/web/e2e/tests/layout/app-status-bar.spec.ts index 6e365d1c65..1983a8eceb 100644 --- a/apps/web/e2e/tests/layout/app-status-bar.spec.ts +++ b/apps/web/e2e/tests/layout/app-status-bar.spec.ts @@ -1,5 +1,11 @@ import { expect, test } from "../../fixtures/test-base"; import type { Page } from "@playwright/test"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; const PIXEL_TOLERANCE = 1; @@ -27,6 +33,56 @@ async function expectStatusBarAfterSidebar(page: Page) { } test.describe("App status bar", () => { + let baseline: AppStatusBarSettingsBaseline | undefined; + + test.beforeEach(async ({ apiClient }) => { + baseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); + }); + + test.afterEach(async ({ apiClient }) => { + await restoreAppStatusBarSettings(apiClient, baseline); + }); + + test("persists the Appearance preference without a restart", async ({ + testPage, + apiClient, + prCapture, + }) => { + await testPage.goto("/settings/preferences/appearance"); + const toggle = testPage.getByRole("switch", { name: "Show status bar" }); + const toggleRow = testPage.getByTestId("app-status-bar-toggle-row"); + const floatingSave = testPage.getByTestId("settings-floating-save"); + const bar = testPage.getByTestId("app-status-bar"); + + await expect(toggle).toHaveAttribute("aria-checked", "true"); + expect((await toggleRow.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await expect(bar).toBeVisible(); + + await toggle.click(); + await expect(toggle).toHaveAttribute("data-settings-dirty", "true"); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(bar).toHaveCount(0); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.app_status_bar_enabled) + .toBe(false); + await prCapture.screenshot("status-bar-appearance-desktop", { + caption: "Desktop Appearance setting with the status bar disabled", + }); + + await testPage.reload(); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await expect(bar).toHaveCount(0); + + await toggle.click(); + await floatingSave.getByRole("button", { name: "Save changes" }).click(); + await expect(bar).toBeVisible(); + expect((await bar.boundingBox())?.height).toBe(24); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.app_status_bar_enabled) + .toBe(true); + }); + test("starts after sidebar and tracks its layout width", async ({ testPage }) => { await testPage.setViewportSize({ width: 1600, height: 900 }); await testPage.goto("/"); diff --git a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts index b0939d0d2e..eb20140ae9 100644 --- a/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts +++ b/apps/web/e2e/tests/layout/compact-desktop-responsive.spec.ts @@ -9,12 +9,21 @@ const COMPACT_DESKTOP_VIEWPORT = { width: 900, height: 800 }; useRegularMode(); test.describe("compact desktop responsive layout", () => { + test.afterEach(async ({ apiClient }) => { + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, + }); + }); + test("task page hands off from compact desktop to mobile below the sidebar boundary", async ({ testPage, apiClient, seedData, }) => { await testPage.setViewportSize(COMPACT_DESKTOP_VIEWPORT); + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, + }); const task = await apiClient.createTaskWithAgent( seedData.workspaceId, diff --git a/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts b/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts new file mode 100644 index 0000000000..185a5d045d --- /dev/null +++ b/apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts @@ -0,0 +1,98 @@ +import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; + +test.describe("Mobile App status bar preference", () => { + let baseline: AppStatusBarSettingsBaseline | undefined; + + test.beforeEach(async ({ apiClient }) => { + baseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); + }); + + test.afterEach(async ({ apiClient }) => { + await restoreAppStatusBarSettings(apiClient, baseline); + }); + + test("persists native Status visibility and preserves drawer geometry", async ({ + testPage, + apiClient, + prCapture, + }) => { + await testPage.goto("/settings"); + await testPage.getByTestId("settings-index").getByRole("link", { name: "Appearance" }).tap(); + + const toggle = testPage.getByRole("switch", { name: "Show status bar" }); + const toggleRow = testPage.getByTestId("app-status-bar-toggle-row"); + const floatingSave = testPage.getByTestId("settings-floating-save"); + await expect(toggle).toHaveAttribute("aria-checked", "true"); + await toggle.scrollIntoViewIfNeeded(); + expect((await toggleRow.boundingBox())?.height).toBeGreaterThanOrEqual(44); + const toggleBox = await toggle.boundingBox(); + if (!toggleBox) throw new Error("status bar preference target is not visible"); + expect(toggleBox.height).toBeGreaterThanOrEqual(44); + expect(toggleBox.width).toBeGreaterThanOrEqual(44); + const tapX = toggleBox.x + toggleBox.width / 2; + const tapY = toggleBox.y + 5; + expect( + await testPage.evaluate( + ({ x, y }) => document.elementFromPoint(x, y)?.closest("[role='switch']")?.id, + { x: tapX, y: tapY }, + ), + ).toBe("show-app-status-bar"); + await testPage.touchscreen.tap(tapX, tapY); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await expect(toggle).toHaveAttribute("data-settings-dirty", "true"); + await floatingSave.getByRole("button", { name: "Save changes" }).tap(); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.app_status_bar_enabled) + .toBe(false); + + await testPage.goto("/"); + await testPage.getByRole("button", { name: "Open menu" }).tap(); + await expect(testPage.getByTestId("mobile-home-status-button")).toHaveCount(0); + await testPage.keyboard.press("Escape"); + await testPage.goto("/stats"); + await expect(testPage.getByTestId("app-status-drawer-trigger")).toHaveCount(0); + await testPage.reload(); + await expect(testPage.getByTestId("app-status-drawer-trigger")).toHaveCount(0); + + await testPage.goto("/settings/preferences/appearance"); + await expect(toggle).toHaveAttribute("aria-checked", "false"); + await toggle.tap(); + await floatingSave.getByRole("button", { name: "Save changes" }).tap(); + await expect + .poll(async () => (await apiClient.getUserSettings()).settings.app_status_bar_enabled) + .toBe(true); + + await testPage.goto("/stats"); + const trigger = testPage.getByTestId("app-status-drawer-trigger"); + await expect(trigger).toBeVisible(); + expect((await trigger.boundingBox())?.height).toBeGreaterThanOrEqual(44); + await trigger.tap(); + + const drawer = testPage.getByTestId("app-status-drawer"); + const dialog = testPage.getByRole("dialog", { name: "Status" }); + await expect(drawer).toBeVisible(); + await expect(dialog).toHaveClass(/safe-area-inset-bottom/); + expect(await drawer.locator("[class*='overflow-y-auto']").count()).toBe(1); + const viewportHeight = await testPage.evaluate(() => window.innerHeight); + await expect + .poll(() => dialog.evaluate((element) => element.getBoundingClientRect().bottom)) + .toBeLessThanOrEqual(viewportHeight); + expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe( + await testPage.evaluate(() => document.documentElement.clientWidth), + ); + await prCapture.screenshot("status-bar-appearance-mobile", { + caption: "Mobile Status drawer after enabling the Appearance preference", + }); + + await testPage.keyboard.press("Escape"); + await expect(drawer).toBeHidden(); + await expect(trigger).toBeFocused(); + }); +}); diff --git a/apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts b/apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts index aa33ad0da9..ead31a8ac5 100644 --- a/apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts +++ b/apps/web/e2e/tests/layout/mobile-ws-connectivity-warning.spec.ts @@ -1,13 +1,31 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; test.describe("Mobile WebSocket connectivity warning", () => { - test("opens a touch-sized connection-only Status drawer while the feature is disabled", async ({ + let baseline: AppStatusBarSettingsBaseline | undefined; + + test.beforeEach(async ({ apiClient }) => { + baseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); + }); + + test.afterEach(async ({ apiClient }) => { + await restoreAppStatusBarSettings(apiClient, baseline); + }); + + test("opens a touch-sized connection-only Status drawer while the preference is disabled", async ({ testPage, + apiClient, prCapture, }) => { + await setAppStatusBarEnabled(apiClient, false); await testPage.goto("/stats"); - await setAppStatusBarEnabled(testPage, false); await setConnectionIssueSeverity(testPage, "lost"); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); @@ -44,10 +62,11 @@ test.describe("Mobile WebSocket connectivity warning", () => { test("keeps the connection-only trigger visible on a coarse-pointer tablet", async ({ testPage, + apiClient, }) => { await testPage.setViewportSize({ width: 900, height: 900 }); + await setAppStatusBarEnabled(apiClient, false); await testPage.goto("/stats"); - await setAppStatusBarEnabled(testPage, false); await setConnectionIssueSeverity(testPage, "unstable"); const trigger = testPage.getByTestId("app-status-drawer-trigger"); @@ -59,8 +78,6 @@ test.describe("Mobile WebSocket connectivity warning", () => { type E2EStore = { getState: () => { - features: Record; - setFeatures: (features: Record) => void; setConnectionIssueSeverity: (severity: "none" | "unstable" | "lost") => void; }; }; @@ -72,12 +89,3 @@ async function setConnectionIssueSeverity(page: Page, severity: "none" | "unstab store.getState().setConnectionIssueSeverity(nextSeverity); }, severity); } - -async function setAppStatusBarEnabled(page: Page, enabled: boolean) { - await page.evaluate((nextEnabled) => { - const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__; - if (!store) throw new Error("E2E store bridge missing"); - const state = store.getState(); - state.setFeatures({ ...state.features, appStatusBar: nextEnabled }); - }, enabled); -} diff --git a/apps/web/e2e/tests/layout/topbar-alignment.spec.ts b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts index f01566cbec..a235267711 100644 --- a/apps/web/e2e/tests/layout/topbar-alignment.spec.ts +++ b/apps/web/e2e/tests/layout/topbar-alignment.spec.ts @@ -25,6 +25,7 @@ async function heightOf(page: Page, testId: string): Promise { async function enableStatusMetrics(apiClient: ApiClient): Promise { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, system_metrics_display: { show_in_topbar: true }, }); } @@ -34,6 +35,7 @@ test.describe("Sidebar header / top bar alignment", () => { test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, system_metrics_display: { show_in_topbar: false }, }); }); diff --git a/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts b/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts index 0cda9d4b9f..ae843061a3 100644 --- a/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts +++ b/apps/web/e2e/tests/layout/ws-connectivity-warning.spec.ts @@ -1,7 +1,24 @@ import type { Page } from "@playwright/test"; import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; test.describe("WebSocket connectivity warning", () => { + let baseline: AppStatusBarSettingsBaseline | undefined; + + test.beforeEach(async ({ apiClient }) => { + baseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); + }); + + test.afterEach(async ({ apiClient }) => { + await restoreAppStatusBarSettings(apiClient, baseline); + }); + test("shows one bottom-bar warning while the app status bar is enabled", async ({ testPage, prCapture, @@ -30,9 +47,12 @@ test.describe("WebSocket connectivity warning", () => { await expect(warning).toHaveCount(0); }); - test("uses the sidebar fallback when the app status bar is disabled", async ({ testPage }) => { + test("uses the sidebar fallback when the app status bar is disabled", async ({ + testPage, + apiClient, + }) => { + await setAppStatusBarEnabled(apiClient, false); await testPage.goto("/"); - await setAppStatusBarEnabled(testPage, false); await setConnectionIssueSeverity(testPage, "unstable"); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); @@ -55,10 +75,11 @@ test.describe("WebSocket connectivity warning", () => { // 768px and 1024px, which no Playwright project emulates yet. test("keeps a connection-only warning reachable below the sidebar boundary", async ({ testPage, + apiClient, }) => { await testPage.setViewportSize({ width: 700, height: 900 }); + await setAppStatusBarEnabled(apiClient, false); await testPage.goto("/stats"); - await setAppStatusBarEnabled(testPage, false); await setConnectionIssueSeverity(testPage, "unstable"); await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); @@ -78,8 +99,6 @@ test.describe("WebSocket connectivity warning", () => { type E2EStore = { getState: () => { - features: Record; - setFeatures: (features: Record) => void; setConnectionIssueSeverity: (severity: "none" | "unstable" | "lost") => void; }; }; @@ -91,12 +110,3 @@ async function setConnectionIssueSeverity(page: Page, severity: "none" | "unstab store.getState().setConnectionIssueSeverity(nextSeverity); }, severity); } - -async function setAppStatusBarEnabled(page: Page, enabled: boolean) { - await page.evaluate((nextEnabled) => { - const store = (window as Window & { __KANDEV_E2E_STORE__?: E2EStore }).__KANDEV_E2E_STORE__; - if (!store) throw new Error("E2E store bridge missing"); - const state = store.getState(); - state.setFeatures({ ...state.features, appStatusBar: nextEnabled }); - }, enabled); -} diff --git a/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts b/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts index 635d3ac3d7..05f41c1a5f 100644 --- a/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts +++ b/apps/web/e2e/tests/lsp/lsp-file-intelligence.spec.ts @@ -254,7 +254,9 @@ test.describe("LSP file intelligence", () => { const initial = await apiClient.getUserSettings(); const initialLocation = initial.settings.lsp_status_location === "status_bar" ? "status_bar" : "toolbar"; + const initialStatusBarEnabled = initial.settings.app_status_bar_enabled === true; await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, lsp_status_location: "toolbar", }); @@ -349,6 +351,7 @@ test.describe("LSP file intelligence", () => { await expect(statusItem).toHaveAttribute("data-lsp-state", "disabled"); } finally { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: initialStatusBarEnabled, lsp_status_location: initialLocation, }); } diff --git a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts index 9b47e5828c..f9acaeeafe 100644 --- a/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts +++ b/apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts @@ -18,8 +18,17 @@ async function installFixture(page: Page) { } test.describe("Mobile Status drawer", () => { + test.beforeEach(async ({ apiClient }) => { + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, + }); + }); + test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined); + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, + }); }); test("keeps a single status signal compact", async ({ testPage, apiClient }) => { diff --git a/apps/web/e2e/tests/plugins/plugins.spec.ts b/apps/web/e2e/tests/plugins/plugins.spec.ts index d2906a635b..cbb1a43859 100644 --- a/apps/web/e2e/tests/plugins/plugins.spec.ts +++ b/apps/web/e2e/tests/plugins/plugins.spec.ts @@ -132,6 +132,9 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () = // so the next iteration starts from a clean slate. test.afterEach(async ({ apiClient }) => { await uninstallViaApi(apiClient); + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: false, + }); }); test("shows an install spinner while an upload install is pending", async ({ testPage }) => { @@ -167,6 +170,9 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () = backend, }) => { test.setTimeout(90_000); + await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: true, + }); const pluginsDir = path.join(backend.tmpDir, ".kandev", "plugins"); diff --git a/apps/web/e2e/tests/settings/config-chat-popover.spec.ts b/apps/web/e2e/tests/settings/config-chat-popover.spec.ts index 600d7a714b..1dac50a152 100644 --- a/apps/web/e2e/tests/settings/config-chat-popover.spec.ts +++ b/apps/web/e2e/tests/settings/config-chat-popover.spec.ts @@ -54,9 +54,18 @@ test.describe("Configuration Chat", () => { const initial = await apiClient.getUserSettings(); const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat"; const nextLayout = initialLayout === "tree" ? "flat" : "tree"; + expect(initial.settings.app_status_bar_enabled).toBe(false); try { await testPage.goto("/settings/preferences/appearance"); + await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0); + await expect + .poll(() => + testPage.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue("--app-status-bar-height"), + ), + ) + .toBe("0px"); const layout = testPage.getByTestId("changes-panel-layout-select"); await layout.click(); await testPage diff --git a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts index 54e38d2b0a..bbe41a90a7 100644 --- a/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts +++ b/apps/web/e2e/tests/settings/mobile-resource-metrics-display.spec.ts @@ -1,4 +1,10 @@ import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; import { metricsUnavailableWasRendered, observeMetricsUnavailable, @@ -11,16 +17,20 @@ type SystemMetricsDisplay = { test.describe("Mobile resource metrics display", () => { let baseline: SystemMetricsDisplay; + let statusBarBaseline: AppStatusBarSettingsBaseline; test.beforeEach(async ({ apiClient }) => { const settings = await apiClient.getUserSettings(); baseline = settings.settings.system_metrics_display as SystemMetricsDisplay; + statusBarBaseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); }); test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { system_metrics_display: baseline, }); + await restoreAppStatusBarSettings(apiClient, statusBarBaseline); }); test("renders simplified metrics in the Status drawer", async ({ testPage }) => { diff --git a/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts b/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts index 15b855c784..dfa34f761d 100644 --- a/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts +++ b/apps/web/e2e/tests/settings/resource-metrics-display.spec.ts @@ -1,4 +1,10 @@ import { expect, test } from "../../fixtures/test-base"; +import { + captureAppStatusBarSettings, + restoreAppStatusBarSettings, + setAppStatusBarEnabled, + type AppStatusBarSettingsBaseline, +} from "../../helpers/app-status-bar-settings"; import { metricsUnavailableWasRendered, observeMetricsUnavailable, @@ -11,16 +17,20 @@ type SystemMetricsDisplay = { test.describe("Resource metrics display", () => { let baseline: SystemMetricsDisplay; + let statusBarBaseline: AppStatusBarSettingsBaseline; test.beforeEach(async ({ apiClient }) => { const settings = await apiClient.getUserSettings(); baseline = settings.settings.system_metrics_display as SystemMetricsDisplay; + statusBarBaseline = await captureAppStatusBarSettings(apiClient); + await setAppStatusBarEnabled(apiClient, true); }); test.afterEach(async ({ apiClient }) => { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { system_metrics_display: baseline, }); + await restoreAppStatusBarSettings(apiClient, statusBarBaseline); }); test("renders simplified metrics in the status bar", async ({ testPage }) => { diff --git a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts index 3f7360114b..ebd5f47cfb 100644 --- a/apps/web/e2e/tests/settings/settings-manual-save.spec.ts +++ b/apps/web/e2e/tests/settings/settings-manual-save.spec.ts @@ -34,9 +34,11 @@ test.describe("Settings manual save", () => { }) => { const initial = await apiClient.getUserSettings(); const initialLayout = initial.settings.changes_panel_layout === "tree" ? "tree" : "flat"; + const initialStatusBarEnabled = initial.settings.app_status_bar_enabled === true; const nextLayout = initialLayout === "tree" ? "flat" : "tree"; try { + await apiClient.saveUserSettings({ app_status_bar_enabled: true }); await testPage.goto(APPEARANCE_PATH); await expect( testPage.getByRole("heading", { name: "Appearance", exact: true }), @@ -113,6 +115,7 @@ test.describe("Settings manual save", () => { expect((await apiClient.getUserSettings()).settings.changes_panel_layout).toBe(nextLayout); } finally { await apiClient.rawRequest("PATCH", "/api/v1/user/settings", { + app_status_bar_enabled: initialStatusBarEnabled, changes_panel_layout: initialLayout, }); } diff --git a/apps/web/eslint.i18n.options.mjs b/apps/web/eslint.i18n.options.mjs index d5a44173fa..031c751d78 100644 --- a/apps/web/eslint.i18n.options.mjs +++ b/apps/web/eslint.i18n.options.mjs @@ -225,6 +225,7 @@ export const i18nGuardFiles = [ "lib/i18n/**/*.{ts,tsx}", // Settings → Preferences — Appearance, Notifications, Terminal & Editors and // friends render straight from components/settings via the SPA route table. + "components/settings/app-status-bar-settings-card.tsx", "components/settings/general-settings.tsx", "components/settings/language-settings.tsx", "components/settings/notification-events-table.tsx", diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index bd604e28a7..92ba7714f7 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -46,6 +46,7 @@ import { function makeUnloadedSettings(): UserSettingsState { return { + revision: null, workspaceId: null, workflowId: null, kanbanViewMode: null, @@ -96,6 +97,7 @@ function makeUnloadedSettings(): UserSettingsState { terminalFontSize: null, changesPanelLayout: "tree", systemMetricsDisplay: { showInTopbar: false, simplified: false }, + appStatusBarEnabled: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, voiceMode: { enabled: true, diff --git a/apps/web/hooks/use-lsp-status-placement.ts b/apps/web/hooks/use-lsp-status-placement.ts index b43d9b1cd9..56ec2ee87f 100644 --- a/apps/web/hooks/use-lsp-status-placement.ts +++ b/apps/web/hooks/use-lsp-status-placement.ts @@ -1,7 +1,6 @@ "use client"; import { useAppStore } from "@/components/state-provider"; -import { useFeature } from "@/hooks/domains/features/use-feature"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { resolveLspStatusPlacement, @@ -10,7 +9,7 @@ import { export function useLspStatusPlacement(): EffectiveLspStatusPlacement { const preferredLocation = useAppStore((state) => state.userSettings.lspStatusLocation); - const appStatusBarEnabled = useFeature("appStatusBar"); + const appStatusBarEnabled = useAppStore((state) => state.userSettings.appStatusBarEnabled); const responsive = useResponsiveBreakpoint(); return resolveLspStatusPlacement({ preferredLocation, diff --git a/apps/web/lib/settings-discovery/catalog/preferences.ts b/apps/web/lib/settings-discovery/catalog/preferences.ts index ae2f7d0dc6..07e4ecff32 100644 --- a/apps/web/lib/settings-discovery/catalog/preferences.ts +++ b/apps/web/lib/settings-discovery/catalog/preferences.ts @@ -20,6 +20,7 @@ export const GENERAL_SETTINGS_TARGETS = { settingsMenuMode: "setting-settings-menu-mode", startupPage: "setting-startup-page", displayLanguage: "setting-display-language", + appStatusBar: "setting-status-bar", changesPanelLayout: "setting-changes-panel-layout", resourceMetrics: "setting-resource-metrics", layoutProfiles: "setting-layout-profiles", @@ -100,6 +101,17 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = targetId: GENERAL_SETTINGS_TARGETS.displayLanguage, order: 14, }, + { + id: "appearance-status-bar", + kind: "control", + labelKey: "settings:showStatusBar", + aliasesKey: "settings:discoveryAliasesStatusBar", + parentId: APPEARANCE_ID, + groupId: "preferences", + href: APPEARANCE_SETTINGS_HREF, + targetId: GENERAL_SETTINGS_TARGETS.appStatusBar, + order: 15, + }, { id: "appearance-changes-panel-layout", kind: "control", @@ -108,7 +120,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = groupId: "preferences", href: APPEARANCE_SETTINGS_HREF, targetId: GENERAL_SETTINGS_TARGETS.changesPanelLayout, - order: 15, + order: 16, }, { id: "appearance-resource-metrics", @@ -119,7 +131,7 @@ export const PREFERENCES_DISCOVERY_DEFINITIONS: SettingsDiscoveryDefinition[] = groupId: "preferences", href: APPEARANCE_SETTINGS_HREF, targetId: GENERAL_SETTINGS_TARGETS.resourceMetrics, - order: 16, + order: 17, }, { id: "preferences-layouts", diff --git a/apps/web/lib/settings/user-settings-revision.ts b/apps/web/lib/settings/user-settings-revision.ts new file mode 100644 index 0000000000..91783b25c2 --- /dev/null +++ b/apps/web/lib/settings/user-settings-revision.ts @@ -0,0 +1,17 @@ +function validRevision(value: number | null | undefined): number | null { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) return null; + return value; +} + +export function compareUserSettingsRevisions( + left: number | null | undefined, + right: number | null | undefined, +): -1 | 0 | 1 | null { + const leftValue = validRevision(left); + const rightValue = validRevision(right); + if (leftValue === null && rightValue === null) return null; + if (leftValue === null) return -1; + if (rightValue === null) return 1; + if (leftValue === rightValue) return 0; + return leftValue > rightValue ? 1 : -1; +} diff --git a/apps/web/lib/ssr/user-settings.test.ts b/apps/web/lib/ssr/user-settings.test.ts index 52666a2c86..4ae4820e6f 100644 --- a/apps/web/lib/ssr/user-settings.test.ts +++ b/apps/web/lib/ssr/user-settings.test.ts @@ -8,11 +8,46 @@ import { parseSystemMetricsDisplay, parseVoiceMode, } from "./user-settings"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; import { workspaceId as toWorkspaceId } from "@/lib/types/ids"; const UPDATED_AT = "2026-01-01T00:00:00Z"; const DEFAULT_USER_ID = "default-user"; +describe("user settings revision ordering", () => { + it("orders atomic revisions and hydrates the current revision", () => { + expect(compareUserSettingsRevisions(3, 2)).toBe(1); + expect( + mapUserSettingsResponse({ + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + revision: 42, + updated_at: UPDATED_AT, + }, + }).revision, + ).toBe(42); + }); + + it("does not assign a newer local revision to an unversioned HTTP response", () => { + const current = { ...mapUserSettingsResponse(null), revision: 3 }; + const result = mapUserSettingsResponse( + { + settings: { + user_id: DEFAULT_USER_ID, + workspace_id: toWorkspaceId(""), + repository_ids: [], + updated_at: UPDATED_AT, + }, + }, + current, + ); + + expect(result.revision).toBeNull(); + }); +}); + describe("startup page user settings", () => { it("normalizes startup page preferences", () => { expect(parseStartupPage("last_task")).toBe("last_task"); @@ -70,6 +105,33 @@ describe("agent-generated task title defaults", () => { }); }); +describe("app status bar visibility hydration", () => { + it("defaults missing values to disabled and preserves explicit values", () => { + const defaults = buildCoreFields({}) as Record; + const enabled = buildCoreFields({ + app_status_bar_enabled: true, + } as unknown as Parameters[0]) as Record; + const disabled = buildCoreFields({ + app_status_bar_enabled: false, + } as unknown as Parameters[0]) as Record; + + expect(defaults.appStatusBarEnabled).toBe(false); + expect(enabled.appStatusBarEnabled).toBe(true); + expect(disabled.appStatusBarEnabled).toBe(false); + }); + + it("preserves the current value when a partial update omits the field", () => { + const current = { + ...mapUserSettingsResponse(null), + appStatusBarEnabled: false, + } as Parameters[1]; + + expect((buildCoreFields({}, current) as Record).appStatusBarEnabled).toBe( + false, + ); + }); +}); + describe("buildCoreFields", () => { it("normalizes the simplified metrics preference and defaults old rows to detailed", () => { expect(parseSystemMetricsDisplay({ show_in_topbar: true, simplified: true } as never)).toEqual({ diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index c92eb0c5a6..f2d01ff28a 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -25,6 +25,7 @@ export type UserSettingsData = Omit, "workspace_id"> & { export function createDefaultUserSettings(): UserSettingsState { return { + revision: null, workspaceId: null, workflowId: null, kanbanViewMode: null, @@ -80,6 +81,7 @@ export function createDefaultUserSettings(): UserSettingsState { terminalFontSize: null, changesPanelLayout: "tree", systemMetricsDisplay: { showInTopbar: false, simplified: false }, + appStatusBarEnabled: false, appStatusBarOrder: { leftItemIds: [], rightItemIds: [] }, voiceMode: { ...DEFAULT_VOICE_MODE_STATE }, hiddenWorkflowStepIds: {}, @@ -274,6 +276,7 @@ export function buildCoreFields( current: UserSettingsState = createDefaultUserSettings(), ) { return { + revision: s.revision ?? current.revision, ...buildIdentityFields(s, current), ...buildBehaviorFields(s, current), savedLayouts: s.saved_layouts ?? current.savedLayouts, @@ -321,6 +324,7 @@ export function buildCoreFields( current.appStatusBarOrder, parseAppStatusBarOrder, ), + appStatusBarEnabled: s.app_status_bar_enabled ?? current.appStatusBarEnabled, hiddenWorkflowStepIds: s.kanban_hidden_step_ids ?? current.hiddenWorkflowStepIds, ...buildTerminalFields(s, current), ...buildSystemMetricsDisplayFields(s, current), @@ -370,6 +374,7 @@ export function mapUserSettingsResponse( } return { ...mapUserSettingsData(s, current), + revision: s.revision ?? null, shellOptions, }; } diff --git a/apps/web/lib/state/hydration/hydrator.test.ts b/apps/web/lib/state/hydration/hydrator.test.ts index 1bac36a27e..9c1a643069 100644 --- a/apps/web/lib/state/hydration/hydrator.test.ts +++ b/apps/web/lib/state/hydration/hydrator.test.ts @@ -373,6 +373,49 @@ describe("mergeInitialState — sidebar views from boot settings", () => { }); }); +describe("hydrateState — user settings revisions", () => { + it("applies a newer boot snapshot after an earlier websocket update", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.userSettings.loaded = true; + draft.userSettings.revision = 1; + draft.userSettings.appStatusBarEnabled = false; + hydrateState(draft, { + userSettings: { + loaded: true, + revision: 2, + appStatusBarEnabled: true, + }, + } as unknown as Partial); + }); + + expect(result.userSettings).toMatchObject({ + loaded: true, + revision: 2, + appStatusBarEnabled: true, + }); + }); + + it("keeps a newer websocket snapshot when boot hydration is older", () => { + const result = produce(makeAppDraft(), (draft: Draft) => { + draft.userSettings.loaded = true; + draft.userSettings.revision = 3; + draft.userSettings.appStatusBarEnabled = true; + hydrateState(draft, { + userSettings: { + loaded: true, + revision: 2, + appStatusBarEnabled: false, + }, + } as unknown as Partial); + }); + + expect(result.userSettings).toMatchObject({ + revision: 3, + appStatusBarEnabled: true, + }); + }); +}); + describe("hydrateState — sidebar views from user settings", () => { it("hydrates active view and draft from backend user settings", () => { const result = produce(makeAppDraft(), (draft: Draft) => { diff --git a/apps/web/lib/state/hydration/hydrator.ts b/apps/web/lib/state/hydration/hydrator.ts index c281a33ee4..45bfe5519c 100644 --- a/apps/web/lib/state/hydration/hydrator.ts +++ b/apps/web/lib/state/hydration/hydrator.ts @@ -5,6 +5,7 @@ import { applyStoredQuickChatNames, reconcileQuickTerminalTabs, } from "@/lib/state/slices/ui/quick-chat-sync"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; import { deepMerge, mergeSessionMap, mergeLoadingState } from "./merge-strategies"; /** @@ -81,12 +82,20 @@ function hydrateSettings(draft: Draft, state: HydrationState): void { mergeWithLoading(draft.notificationProviders, state.notificationProviders); if (state.settingsData) deepMerge(draft.settingsData, state.settingsData); if (state.sleepInhibition) deepMerge(draft.sleepInhibition, state.sleepInhibition); - if (state.userSettings && !draft.userSettings.loaded) { + if (state.userSettings && shouldHydrateUserSettings(draft.userSettings, state.userSettings)) { deepMerge(draft.userSettings, state.userSettings); bridgeSidebarViewsFromUserSettings(draft, state.userSettings); } } +function shouldHydrateUserSettings( + current: Draft, + incoming: Partial, +): boolean { + const order = compareUserSettingsRevisions(incoming.revision, current.revision); + return order === null ? !current.loaded : order >= 0; +} + function bridgeSidebarViewsFromUserSettings( draft: Draft, userSettings: Partial, diff --git a/apps/web/lib/state/slices/features/types.ts b/apps/web/lib/state/slices/features/types.ts index 18afdc4a5e..4cef4e825a 100644 --- a/apps/web/lib/state/slices/features/types.ts +++ b/apps/web/lib/state/slices/features/types.ts @@ -8,7 +8,6 @@ export const defaultFeatureFlags = { // New release toggles start disabled in every frontend state. The SSR layer // overwrites this with the backend's effective values after startup. office: false, - appStatusBar: false, auth: false, claudeBackgroundPromptHandoff: false, claudeMidTurnSteering: false, diff --git a/apps/web/lib/state/slices/settings/settings-slice.test.ts b/apps/web/lib/state/slices/settings/settings-slice.test.ts index 4ae0cdf5c9..20eae1689b 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.test.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.test.ts @@ -22,6 +22,31 @@ function updateJob(overrides: Record = {}) { }; } +describe("user settings snapshots", () => { + it("rejects an older server snapshot but allows an equal-revision optimistic update", () => { + const store = makeStore(); + const actions = store.getState(); + const current = { + ...actions.userSettings, + appStatusBarEnabled: true, + revision: 2, + }; + + actions.setUserSettings(current); + actions.setUserSettings({ ...current, appStatusBarEnabled: false, revision: 1 }); + expect(store.getState().userSettings).toMatchObject({ + appStatusBarEnabled: true, + revision: 2, + }); + + actions.setUserSettings({ ...current, appStatusBarEnabled: false }); + expect(store.getState().userSettings).toMatchObject({ + appStatusBarEnabled: false, + revision: 2, + }); + }); +}); + describe("settings update jobs", () => { it("rehydrates the newest retained job for each agent", () => { const store = makeStore(); diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index a8201b088a..7577dab555 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -1,5 +1,6 @@ import type { StateCreator } from "zustand"; import { createDefaultUserSettings } from "@/lib/ssr/user-settings"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; import type { SettingsSlice, SettingsSliceState } from "./types"; export const defaultSettingsState: SettingsSliceState = { @@ -243,6 +244,8 @@ function createCoreActions( }), setUserSettings: (settings) => set((draft) => { + const order = compareUserSettingsRevisions(settings.revision, draft.userSettings.revision); + if (order !== null && order < 0) return; draft.userSettings = settings; }), bumpAgentProfilesVersion: () => diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 5696bae708..9e02b9390f 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -205,6 +205,7 @@ export type SleepInhibitionStoreState = { }; export type UserSettingsState = { + revision: number | null; workspaceId: string | null; kanbanViewMode: string | null; startupPage: StartupPage; @@ -253,6 +254,7 @@ export type UserSettingsState = { terminalFontSize: number | null; changesPanelLayout: "flat" | "tree"; systemMetricsDisplay: { showInTopbar: boolean; simplified: boolean }; + appStatusBarEnabled: boolean; appStatusBarOrder: AppStatusBarOrderState; voiceMode: VoiceModeState; hiddenWorkflowStepIds: Record; diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 3d54755d08..bf7550877d 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -100,9 +100,11 @@ export type UserSettings = { terminal_font_size?: number; changes_panel_layout?: "flat" | "tree"; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; + app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; voice_mode?: VoiceModeSettings; kanban_hidden_step_ids?: Record; + revision?: number; updated_at: string; }; @@ -161,6 +163,7 @@ export type UserSettingsUpdatePayload = { terminal_font_size?: number; changes_panel_layout?: "flat" | "tree"; system_metrics_display?: { show_in_topbar?: boolean; simplified?: boolean }; + app_status_bar_enabled?: boolean; app_status_bar_order?: AppStatusBarOrderApi; voice_mode?: VoiceModeSettings; kanban_hidden_step_ids?: Record; diff --git a/apps/web/lib/user-settings-sync.test.ts b/apps/web/lib/user-settings-sync.test.ts index d119738632..63714d4cf5 100644 --- a/apps/web/lib/user-settings-sync.test.ts +++ b/apps/web/lib/user-settings-sync.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { waitFor } from "@testing-library/react"; import { ApiError } from "@/lib/api/client"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; -import { createQueuedUserSettingsSync } from "./user-settings-sync"; +import { + createQueuedUserSettingsSync, + createQueuedUserSettingsSyncWithResponse, +} from "./user-settings-sync"; vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: vi.fn(), @@ -47,6 +50,18 @@ describe("createQueuedUserSettingsSync", () => { }); }); + it("returns the authoritative response when the caller needs its revision", async () => { + const response = { settings: { revision: 7 } } as Awaited< + ReturnType + >; + vi.mocked(updateUserSettings).mockResolvedValueOnce(response); + const sync = createQueuedUserSettingsSyncWithResponse((value) => ({ + preferred_shell: value, + })); + + await expect(sync("zsh")).resolves.toBe(response); + }); + it("retries a transient settings write before processing the next payload", async () => { vi.mocked(updateUserSettings) .mockRejectedValueOnce(new Error("temporary failure")) diff --git a/apps/web/lib/user-settings-sync.ts b/apps/web/lib/user-settings-sync.ts index c686e1f962..597b4846da 100644 --- a/apps/web/lib/user-settings-sync.ts +++ b/apps/web/lib/user-settings-sync.ts @@ -5,14 +5,13 @@ import type { UserSettingsUpdatePayload } from "@/lib/types/http-user-settings"; const MAX_SYNC_ATTEMPTS = 3; const BASE_SYNC_RETRY_DELAY_MS = 100; -export async function updateUserSettingsWithRetry( +async function requestUserSettingsUpdateWithRetry( payload: UserSettingsUpdatePayload, -): Promise { +): Promise>> { let lastError: unknown; for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt += 1) { try { - await updateUserSettings(payload); - return; + return await updateUserSettings(payload); } catch (error) { if (error instanceof ApiError) throw error; lastError = error; @@ -26,13 +25,31 @@ export async function updateUserSettingsWithRetry( throw lastError; } +export async function updateUserSettingsWithRetry( + payload: UserSettingsUpdatePayload, +): Promise { + await requestUserSettingsUpdateWithRetry(payload); +} + +export function createQueuedUserSettingsSyncWithResponse( + buildPayload: (value: T) => UserSettingsUpdatePayload, +): (value: T) => ReturnType { + let queue: Promise = Promise.resolve(); + return (value: T) => { + const payload = buildPayload(value); + const response = queue + .catch(() => undefined) + .then(() => requestUserSettingsUpdateWithRetry(payload)); + queue = response; + return response; + }; +} + export function createQueuedUserSettingsSync( buildPayload: (value: T) => UserSettingsUpdatePayload, ): (value: T) => Promise { - let queue = Promise.resolve(); - return (value: T) => { - const payload = buildPayload(value); - queue = queue.catch(() => undefined).then(() => updateUserSettingsWithRetry(payload)); - return queue; + const syncWithResponse = createQueuedUserSettingsSyncWithResponse(buildPayload); + return async (value: T) => { + await syncWithResponse(value); }; } diff --git a/apps/web/lib/ws/handlers/users.test.ts b/apps/web/lib/ws/handlers/users.test.ts index 797e4f2790..28433d471e 100644 --- a/apps/web/lib/ws/handlers/users.test.ts +++ b/apps/web/lib/ws/handlers/users.test.ts @@ -44,6 +44,65 @@ describe("startup page websocket sync", () => { }); }); +describe("status bar visibility websocket sync", () => { + it("updates status bar visibility and preserves it when omitted", () => { + const store = makeStore(); + + expect( + (store.getState().userSettings as unknown as Record).appStatusBarEnabled, + ).toBe(false); + + registerUsersHandlers(store)["user.settings.updated"]?.( + userSettingsMessage({ + app_status_bar_enabled: true, + } as unknown as Partial), + ); + expect( + (store.getState().userSettings as unknown as Record).appStatusBarEnabled, + ).toBe(true); + + registerUsersHandlers(store)["user.settings.updated"]?.(userSettingsMessage({})); + expect( + (store.getState().userSettings as unknown as Record).appStatusBarEnabled, + ).toBe(true); + }); + + it("ignores older settings revisions and accepts newer ones", () => { + const store = makeStore(); + store.setState((state) => ({ + ...state, + userSettings: { + ...state.userSettings, + appStatusBarEnabled: false, + revision: 2, + }, + })); + const handler = registerUsersHandlers(store)["user.settings.updated"]; + + handler?.( + userSettingsMessage({ + app_status_bar_enabled: true, + revision: 1, + }), + ); + expect(store.getState().userSettings).toMatchObject({ + appStatusBarEnabled: false, + revision: 2, + }); + + handler?.( + userSettingsMessage({ + app_status_bar_enabled: true, + revision: 3, + }), + ); + expect(store.getState().userSettings).toMatchObject({ + appStatusBarEnabled: true, + revision: 3, + }); + }); +}); + describe("user settings websocket handler", () => { it("updates LSP status location, normalizes unknown values, and preserves omissions", () => { const store = makeStore(); diff --git a/apps/web/lib/ws/handlers/users.ts b/apps/web/lib/ws/handlers/users.ts index 5152756e3b..1caf9f2116 100644 --- a/apps/web/lib/ws/handlers/users.ts +++ b/apps/web/lib/ws/handlers/users.ts @@ -5,16 +5,24 @@ import type { WsHandlers } from "@/lib/ws/handlers/types"; import { mapUserSettingsData } from "@/lib/ssr/user-settings"; import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import { migrateSidebarViewDraft, migrateView } from "@/lib/state/slices/ui/ui-slice"; +import { compareUserSettingsRevisions } from "@/lib/settings/user-settings-revision"; export function registerUsersHandlers(store: StoreApi): WsHandlers { return { "user.settings.updated": (message) => { - store.setState((state) => ({ - ...state, - sidebarViews: buildSidebarViewsState(state, message.payload), - sidebarTaskPrefs: buildSidebarTaskPrefsState(state, message.payload), - userSettings: buildUserSettingsState(state, message.payload), - })); + store.setState((state) => { + const order = compareUserSettingsRevisions( + message.payload.revision, + state.userSettings.revision, + ); + if (order !== null && order <= 0) return state; + return { + ...state, + sidebarViews: buildSidebarViewsState(state, message.payload), + sidebarTaskPrefs: buildSidebarTaskPrefsState(state, message.payload), + userSettings: buildUserSettingsState(state, message.payload), + }; + }); }, }; } diff --git a/apps/web/src/locales/en/settings.json b/apps/web/src/locales/en/settings.json index c10845741b..758bcf68f8 100644 --- a/apps/web/src/locales/en/settings.json +++ b/apps/web/src/locales/en/settings.json @@ -83,6 +83,7 @@ "configChatAgentTitle": "Configuration Chat Agent", "configureArchiveSafeguardsAndDefaultsFor": "Configure archive safeguards and defaults for tasks created by agents", "configureBackendAndExecutionResourceSampling": "Configure backend and execution resource sampling", + "configureStatusBarVisibility": "Choose whether Kandev shows its status surface", "configureChatInputBehavior": "Configure chat input behavior", "configureSpritesApiToken": "Configure a <1>{{envVar}} environment variable in the executor profile, referencing a secret with your Sprites.dev API token.", "configureTerminalAppearanceAndBehavior": "Configure terminal appearance and behavior", @@ -192,6 +193,7 @@ "discoveryAliasesResourceMetrics": "metrics,CPU,memory,disk,status bar", "discoveryAliasesSettingsMenu": "sidebar,navigation,tree,accordion,menu", "discoveryAliasesStartupPage": "home page,start page,launch page", + "discoveryAliasesStatusBar": "bottom bar,status drawer,connection status,appearance", "discoveryAliasesSubmitShortcut": "send key,enter to send,command enter", "discoveryAliasesTerminalFont": "font family,typeface,monospace", "discoveryAliasesTerminalFontSize": "text size,terminal text", @@ -555,6 +557,8 @@ "showNewDividerInTranscripts": "Show New divider in transcripts", "showScrollToLastPrompt": "Show scroll to last prompt", "showScrollToStart": "Show scroll to start", + "showStatusBar": "Show status bar", + "showStatusBarDescription": "Show status details along the bottom of Kandev. On phones, they appear in the Status drawer. Connection warnings remain visible when this is off.", "showTheControlThatJumpsTo": "Show the control that jumps to the beginning of the transcript.", "showTheJumpControlAfterYour": "Show the jump control after your latest prompt scrolls above the transcript.", "showThePerSessionButtonThat": "Show the per-session button that temporarily turns transcript auto-scroll on or off. Hiding it does not change the default: new transcripts still auto-scroll.", @@ -601,6 +605,7 @@ "spritesDevProvidesEphemeralCloudSandboxes": "Sprites.dev provides ephemeral cloud sandboxes for running agents remotely.", "startOnYourSavedKanbanPipelineOrList": "Start on your saved Kanban, Pipeline, or List view.", "startupPage": "Startup Page", + "statusBar": "Status Bar", "submitShortcut": "Submit Shortcut", "supportedEditors": "Supported Editors", "supportsPlaceholders": "Supports placeholders: {{placeholders}}", diff --git a/apps/web/src/locales/pseudo/settings.json b/apps/web/src/locales/pseudo/settings.json index 9bc2d03acf..db6b6e8f36 100644 --- a/apps/web/src/locales/pseudo/settings.json +++ b/apps/web/src/locales/pseudo/settings.json @@ -83,6 +83,7 @@ "configChatAgentTitle": "Ćōńƒĩĝũŕàţĩōń Ćĥàţ Àĝēńţ", "configureArchiveSafeguardsAndDefaultsFor": "Ćōńƒĩĝũŕē àŕćĥĩvē śàƒēĝũàŕďś àńď ďēƒàũĺţś ƒōŕ ţàśķś ćŕēàţēď ƀŷ àĝēńţś", "configureBackendAndExecutionResourceSampling": "Ćōńƒĩĝũŕē ƀàćķēńď àńď ēxēćũţĩōń ŕēśōũŕćē śàḿƥĺĩńĝ", + "configureStatusBarVisibility": "Ćĥōōśē ŵĥēţĥēŕ Ķàńďēv śĥōŵś ĩţś śţàţũś śũŕƒàćē", "configureChatInputBehavior": "Ćōńƒĩĝũŕē ćĥàţ ĩńƥũţ ƀēĥàvĩōŕ", "configureSpritesApiToken": "Ćōńƒĩĝũŕē à <1>{{envVar}} ēńvĩŕōńḿēńţ vàŕĩàƀĺē ĩń ţĥē ēxēćũţōŕ ƥŕōƒĩĺē, ŕēƒēŕēńćĩńĝ à śēćŕēţ ŵĩţĥ ŷōũŕ Śƥŕĩţēś.ďēv ÀƤĨ ţōķēń.", "configureTerminalAppearanceAndBehavior": "Ćōńƒĩĝũŕē ţēŕḿĩńàĺ àƥƥēàŕàńćē àńď ƀēĥàvĩōŕ", @@ -192,6 +193,7 @@ "discoveryAliasesResourceMetrics": "ḿēţŕĩćś,ĆƤŨ,ḿēḿōŕŷ,ďĩśķ,śţàţũś ƀàŕ", "discoveryAliasesSettingsMenu": "śĩďēƀàŕ,ńàvĩĝàţĩōń,ţŕēē,àććōŕďĩōń,ḿēńũ", "discoveryAliasesStartupPage": "ĥōḿē ƥàĝē,śţàŕţ ƥàĝē,ĺàũńćĥ ƥàĝē", + "discoveryAliasesStatusBar": "ƀōţţōḿ ƀàŕ,śţàţũś ďŕàŵēŕ,ćōńńēćţĩōń śţàţũś,àƥƥēàŕàńćē", "discoveryAliasesSubmitShortcut": "śēńď ķēŷ,ēńţēŕ ţō śēńď,ćōḿḿàńď ēńţēŕ", "discoveryAliasesTerminalFont": "ƒōńţ ƒàḿĩĺŷ,ţŷƥēƒàćē,ḿōńōśƥàćē", "discoveryAliasesTerminalFontSize": "ţēxţ śĩźē,ţēŕḿĩńàĺ ţēxţ", @@ -555,6 +557,8 @@ "showNewDividerInTranscripts": "Śĥōŵ Ńēŵ ďĩvĩďēŕ ĩń ţŕàńśćŕĩƥţś", "showScrollToLastPrompt": "Śĥōŵ śćŕōĺĺ ţō ĺàśţ ƥŕōḿƥţ", "showScrollToStart": "Śĥōŵ śćŕōĺĺ ţō śţàŕţ", + "showStatusBar": "Śĥōŵ śţàţũś ƀàŕ", + "showStatusBarDescription": "Śĥōŵ śţàţũś ďēţàĩĺś àĺōńĝ ţĥē ƀōţţōḿ ōƒ Ķàńďēv. Ōń ƥĥōńēś, ţĥēŷ àƥƥēàŕ ĩń ţĥē Śţàţũś ďŕàŵēŕ. Ćōńńēćţĩōń ŵàŕńĩńĝś ŕēḿàĩń vĩśĩƀĺē ŵĥēń ţĥĩś ĩś ōƒƒ.", "showTheControlThatJumpsTo": "Śĥōŵ ţĥē ćōńţŕōĺ ţĥàţ ĵũḿƥś ţō ţĥē ƀēĝĩńńĩńĝ ōƒ ţĥē ţŕàńśćŕĩƥţ.", "showTheJumpControlAfterYour": "Śĥōŵ ţĥē ĵũḿƥ ćōńţŕōĺ àƒţēŕ ŷōũŕ ĺàţēśţ ƥŕōḿƥţ śćŕōĺĺś àƀōvē ţĥē ţŕàńśćŕĩƥţ.", "showThePerSessionButtonThat": "Śĥōŵ ţĥē ƥēŕ-śēśśĩōń ƀũţţōń ţĥàţ ţēḿƥōŕàŕĩĺŷ ţũŕńś ţŕàńśćŕĩƥţ àũţō-śćŕōĺĺ ōń ōŕ ōƒƒ. Ĥĩďĩńĝ ĩţ ďōēś ńōţ ćĥàńĝē ţĥē ďēƒàũĺţ: ńēŵ ţŕàńśćŕĩƥţś śţĩĺĺ àũţō-śćŕōĺĺ.", @@ -601,6 +605,7 @@ "spritesDevProvidesEphemeralCloudSandboxes": "Śƥŕĩţēś.ďēv ƥŕōvĩďēś ēƥĥēḿēŕàĺ ćĺōũď śàńďƀōxēś ƒōŕ ŕũńńĩńĝ àĝēńţś ŕēḿōţēĺŷ.", "startOnYourSavedKanbanPipelineOrList": "Śţàŕţ ōń ŷōũŕ śàvēď Ķàńƀàń, Ƥĩƥēĺĩńē, ōŕ Ĺĩśţ vĩēŵ.", "startupPage": "Śţàŕţũƥ Ƥàĝē", + "statusBar": "Śţàţũś Ɓàŕ", "submitShortcut": "Śũƀḿĩţ Śĥōŕţćũţ", "supportedEditors": "Śũƥƥōŕţēď Ēďĩţōŕś", "supportsPlaceholders": "Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś: {{placeholders}}", diff --git a/docs/decisions/0018-runtime-settings-overrides.md b/docs/decisions/0018-runtime-settings-overrides.md index 8372a72346..b7ee7cfa02 100644 --- a/docs/decisions/0018-runtime-settings-overrides.md +++ b/docs/decisions/0018-runtime-settings-overrides.md @@ -1,6 +1,6 @@ # 0018: Runtime settings overrides -**Status:** accepted +**Status:** accepted (amended 2026-08-11) **Date:** 2026-06-14 **Area:** backend, frontend, cli @@ -28,11 +28,15 @@ descriptions, stability metadata, risk descriptions, env-var names, and restart requirements. The Feature Toggles UI renders that registry state rather than hardcoding env-var behavior in React. -V1 exposed Office mode and Debug mode. The current registry also includes the -App Status Bar, opt-in Authentication, and Claude background prompt handoff -experiment. Office mode and the handoff experiment are marked experimental; -Debug mode is the user-facing toggle for debug behavior, including agent -message debug logs. Agent message logs are not a separate top-level toggle. +V1 exposed Office mode and Debug mode. Later registrations included the App +Status Bar, opt-in Authentication, and Claude background prompt handoff +experiment. The App Status Bar subsequently graduated from the registry into a +portable user Appearance preference; its key and environment variable remain +retired identities under +[ADR-2026-08-11-user-owned-status-bar-visibility](2026-08-11-user-owned-status-bar-visibility.md). +Office mode and the handoff experiment are marked experimental; Debug mode is +the user-facing toggle for debug behavior, including agent message debug logs. +Agent message logs are not a separate top-level toggle. V1 toggle changes require restart. Restart is launcher/supervisor mediated: diff --git a/docs/decisions/2026-08-11-user-owned-status-bar-visibility.md b/docs/decisions/2026-08-11-user-owned-status-bar-visibility.md new file mode 100644 index 0000000000..decd54e11f --- /dev/null +++ b/docs/decisions/2026-08-11-user-owned-status-bar-visibility.md @@ -0,0 +1,97 @@ +# ADR-2026-08-11-user-owned-status-bar-visibility: Make Status Bar Visibility a Portable User Preference + +**Status:** accepted +**Date:** 2026-08-11 +**Area:** backend, frontend, protocol + +## Context + +The App status bar shipped behind the install-wide `features.appStatusBar` +runtime flag. That flag was appropriate while the surface needed a staged +rollout, but it gives an administrator one restart-required choice for every +user and client. Whether to show ordinary status chrome is now a personal +appearance choice. Kandev already treats status item order and other portable +appearance choices as backend-owned user settings. + +The existing `system_metrics_display.show_in_topbar` field cannot own whole +surface visibility. It controls only whether optional host metrics appear and +must remain independent from connection state, plugin contributions, and LSP +placement. + +## Decision + +Retire the active runtime registration for `features.appStatusBar` and +`KANDEV_FEATURES_APP_STATUS_BAR`. Move that exact key and environment variable +to the append-only retired runtime-flag identities. Persisted overrides for the +now-unknown key remain inert, and neither identity may be reused. + +Backend-owned user settings become the sole durable owner of a top-level +`app_status_bar_enabled` boolean: + +- a missing stored value or initial compatibility payload defaults to `false`; +- an omitted PATCH field preserves the current value; +- an omitted field in a partial live update preserves the current value; +- an explicit `true` or `false` is preserved; +- boot hydration, PATCH responses, and `user.settings.updated` events carry the + effective value to every client. + +Each user row owns an atomic, monotonically increasing `settings_revision`. +Every user-settings mutation increments it in the same database statement and +returns the resulting settings snapshot and revision. Boot hydration, PATCH +responses, and events carry that numeric revision. The frontend uses one +revision comparison for all three ingestion paths: it rejects older snapshots, +applies newer snapshots, and keeps a newer live value when it overtakes an +in-flight response. Wall-clock timestamps and object identity are not ordering +signals. + +Settings > Preferences > Appearance exposes the preference as **Show status bar** +through the existing shared save coordinator. A successful save changes the +active responsive presentation without restarting Kandev. Desktop and tablet +show or hide the bottom bar; phone shows or hides ordinary Status entry points +and the general Status drawer. Appearance saves containing only local theme or +menu changes do not send an empty user-settings PATCH. + +The preference gates only ordinary status surfaces. An active WebSocket +connectivity warning still bypasses the visibility choice through its +problem-only sidebar or connection-only phone fallback. Turning the bar off +also preserves the existing top-bar metrics fallback and LSP toolbar fallback. +The independent agent-runtime availability alert is never gated by this +preference. + +No value is migrated from the former install-wide runtime flag or override into +individual users. Missing preferences remain off, preserving the former +production and development default; users explicitly opt in portably. + +## Consequences + +- The stable status surface is available to every user without a release toggle + or restart, but ordinary status chrome remains off until that user enables it. +- Each user's choice follows their backend identity and updates other connected + clients through the existing user-settings event. +- Delayed settings events cannot replace a newer confirmed settings snapshot. +- Operators lose the install-wide status-bar kill switch and its environment + override. +- Existing runtime override rows need no destructive cleanup and cannot affect + the new preference. +- One relational migration adds the per-user revision counter. No endpoint or + new WebSocket action is required because user settings already use the + existing PATCH and event delivery paths. +- Visibility, metrics inclusion, metrics style, item order, and LSP preferred + location remain separate settings with separate responsibilities. + +## Alternatives Considered + +1. **Promote the runtime flag to on while keeping it as a kill switch.** + Rejected because a restart-required install-wide control has the wrong owner + for a stable appearance choice. +2. **Store visibility in browser local storage.** Rejected because the choice is + portable and ADR 0041 makes backend user settings authoritative. +3. **Reuse `system_metrics_display.show_in_topbar`.** Rejected because hiding + optional host metrics must not also hide connection and plugin status or + change LSP placement. +4. **Copy the old runtime value into every user.** Rejected because one + deployment value cannot express per-user intent, would preserve the rollout + gate after promotion, and has no unambiguous mapping for users created later. +5. **Default the portable preference on.** Rejected because that would enable + new ordinary status chrome for users who never opted in and would not + preserve the former production and development default. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index aefa9c5053..dd6dd938f0 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -23,7 +23,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0015 | [Explicit completion signal for auto-advance](0015-explicit-completion-signal-for-auto-advance.md) | proposed | backend, frontend | 2026-06-04 | | 0016 | [Read-only absolute file paths](0016-observed-external-file-reads.md) | accepted | backend | 2026-06-14 | | 0017 | [Resource metrics sampling](0017-resource-metrics-sampling.md) | accepted | backend, frontend, protocol | 2026-06-14 | -| 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted | backend, frontend, cli | 2026-06-14 | +| 0018 | [Runtime settings overrides](0018-runtime-settings-overrides.md) | accepted (amended 2026-08-11) | backend, frontend, cli | 2026-06-14 | | 0019 | [Restart supervisor owns backend restarts](0019-restart-supervisor.md) | accepted | backend, frontend, cli | 2026-06-14 | | 0020 | [Pi project MCP config injection](0020-pi-project-mcp-config-injection.md) | accepted | backend | 2026-06-16 | | 0021 | [Go-served SPA with boot state](0021-go-served-spa-with-boot-state.md) | accepted | backend, frontend, cli | 2026-06-15 | @@ -145,3 +145,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-09-exclusive-runtime-state-ownership | [Lock Runtime State Before Backend Startup](2026-08-09-exclusive-runtime-state-ownership.md) | accepted | backend, infra | 2026-08-09 | | 2026-08-10-duration-aware-e2e-sharding | [Duration-aware E2E sharding uses rolling `main` timings](2026-08-10-duration-aware-e2e-sharding.md) | accepted | infra, workflow | 2026-08-10 | | 2026-08-10-debug-launcher-profile-selection | [Keep Debug Launches on the Production Profile](2026-08-10-debug-launcher-profile-selection.md) | accepted | backend, frontend, cli | 2026-08-10 | +| 2026-08-11-user-owned-status-bar-visibility | [Make Status Bar Visibility a Portable User Preference](2026-08-11-user-owned-status-bar-visibility.md) | accepted | backend, frontend, protocol | 2026-08-11 | diff --git a/docs/plans/app-status-bar-appearance-setting/plan.md b/docs/plans/app-status-bar-appearance-setting/plan.md new file mode 100644 index 0000000000..bbcf0cc46e --- /dev/null +++ b/docs/plans/app-status-bar-appearance-setting/plan.md @@ -0,0 +1,298 @@ +--- +spec: docs/specs/ui/app-status-bar.md +decision: docs/decisions/2026-08-11-user-owned-status-bar-visibility.md +created: 2026-08-11 +status: done +--- + +# Implementation Plan: Status Bar Appearance Setting + +## Outcome + +Graduate the stable App status bar out of runtime feature flags. Keep ordinary +status chrome off by default and give each user a portable **Show status bar** preference under +Settings > Preferences > Appearance. A successful Appearance save changes the +desktop/tablet bar or ordinary phone Status paths without restarting Kandev. + +The WebSocket connectivity warning remains visible through its problem-only +fallback when the preference is off. Existing metrics, item-order, and LSP +location preferences remain independent. + +## Fixed decisions + +- The wire field is the top-level user setting + `app_status_bar_enabled: boolean`; frontend state uses + `appStatusBarEnabled`. +- Missing stored values and initial compatibility payloads default to `false`. + PATCH and partial-live-update omission preserve the current value, while an + explicit boolean is durable. +- The former `features.appStatusBar` and + `KANDEV_FEATURES_APP_STATUS_BAR` identities are retired, never reused, and not + migrated into user settings. Unknown persisted runtime override rows remain + inert. +- The new control participates in the existing Appearance-page saved/draft + model and shared **Save changes** coordinator. It does not create an + independent save button or browser-storage fallback. +- Turning the preference off hides the ordinary desktop/tablet bar and phone + Status entry points. It preserves: + - the sidebar or connection-only drawer WebSocket warning fallback; + - the pre-status-bar top-bar metrics fallback when metrics are enabled; + - the Monaco toolbar fallback when LSP location is `status_bar`; + - the independent agent-runtime availability alert. +- `system_metrics_display.show_in_topbar`, + `system_metrics_display.simplified`, `app_status_bar_order`, and + `lsp_status_location` keep their existing meanings. +- One relational migration adds an atomic per-user settings revision. No new + endpoint, WebSocket action, plugin contract, or status-bar layout change is + introduced. + +## Backend + +### Portable settings contract + +- Add `AppStatusBarEnabled bool` to the user settings model and response DTO. +- Add `AppStatusBarEnabled *bool` to PATCH DTO, controller/service request + mapping, and basic settings application so omitted and explicit false remain + distinct. +- Set the canonical default to `false` in `defaultUserSettings`. +- Decode the stored JSON through a `*bool` field and overlay it only when + present. This preserves explicit values while treating old rows as disabled. +- Include `app_status_bar_enabled` in JSON persistence and the + `user.settings.updated` event. +- Add `appStatusBarEnabled` to the Go boot-state mapping. +- Add a `settings_revision` column that every settings mutation increments and + returns atomically. Include the revision in boot state, PATCH responses, and + `user.settings.updated` events. + +The existing `users.settings` JSON blob carries the visibility field. The SQL +migration is limited to the independent revision counter used to order complete +settings snapshots. + +### Runtime flag retirement + +- Remove the App status bar field from `common/config.FeaturesConfig`. +- Remove its profile entry from + `apps/backend/internal/profiles/profiles.yaml`. +- Remove its active runtime registry registration and add the exact key/env pair + to `retiredRuntimeFlagIdentities`. +- Remove status-bar-specific config/profile/runtime tests and replace the + registry metadata test with explicit graduation tests: the definition is + absent and the retired identity is present. +- Do not delete existing runtime override rows. Registry handling for unknown + keys already makes them harmless. + +## Frontend + +### Hydration and live settings + +- Add optional `app_status_bar_enabled` fields to HTTP user-settings response + and PATCH types. +- Add `appStatusBarEnabled` to `UserSettingsState`, with a frontend placeholder + default of `false`. +- Extend the shared wire mapper so boot hydration, PATCH responses, and + `user.settings.updated` all apply the field. An absent field preserves the + current mapped value; the initial current value is the default-false state. +- Compare the atomic numeric revision at every boot, HTTP, and WebSocket + ingestion path. Older snapshots cannot replace newer state, including when a + queued status-order PATCH finishes after a newer live update. +- Keep backend defaults authoritative. Do not add localStorage, cookies, or a + second compatibility mapper. + +### Appearance control + +- Add a controlled `AppStatusBarSettingsCard` under Preferences > Appearance. +- Use localized copy: + - label: **Show status bar** + - explanation: the preference shows connection, optional host metrics, and + plugin status in the desktop/tablet bottom bar or phone Status drawer; + important connection warnings remain visible when it is off. +- Give the switch row a minimum 44 px interactive height and reuse the current + Settings card, label, switch, dirty marker, and focus treatment. +- Extend `createAppearanceSavedState`, the Appearance draft, the shared save + PATCH, discard behavior, and post-save Zustand update with + `appStatusBarEnabled`. +- Skip the backend PATCH when an Appearance save contains only local theme or + menu changes. +- Add a settings-discovery target and aliases for status bar, status drawer, + bottom bar, and appearance under the Preferences group. +- Add English locale keys and regenerate the pseudo locale. Do not hand-edit + generated pseudo output. + +### Surface gates + +Replace `useFeature("appStatusBar")` and feature fixtures with the portable +setting in: + +- `AppStatusSurfaceProvider`, which owns desktop/tablet versus phone + presentation and the connection-only exception; +- `AppSidebarFooter`, which owns the desktop/tablet warning fallback; +- `TopbarMetrics`, which owns the old metrics fallback; +- `useLspStatusPlacement`, which passes the effective visibility to the pure + placement resolver. + +The pure resolver may retain the internal `appStatusBarEnabled` parameter. Its +input source changes from runtime features to user settings. Tests and E2E +fixtures must stop mutating the frontend feature slice for this behavior. + +### Frontend feature contract + +- Remove `appStatusBar` from `defaultFeatureFlags`. +- Update feature action/contract fixtures to match the backend + `/api/v1/features` response after the config field is removed. +- Keep the generic backend/frontend feature-key parity test as the contract + oracle. + +## Mobile design contract + +- **Desktop outcome:** a saved-on preference renders the shipped 24 px in-flow + bar. Saved-off removes it and releases its ordinary contribution/metrics + mounts. +- **Phone entry:** saved-on keeps the shipped native Status entry points and + inset drawer. Saved-off removes healthy-state Status entry points. +- **Nearest exemplar:** `SystemMetricsSettingsCard` supplies the controlled + Appearance switch pattern. `AppStatusDrawer` and + `mobile-resource-metrics-display.spec.ts` supply phone drawer and settings + interaction patterns. +- **Hierarchy:** the setting is an Appearance choice, not a System Feature + Toggle. Its description names the desktop/tablet bar, phone drawer, and + warning exception. +- **Presentation:** reuse the existing Appearance page and shared floating save + affordance. Do not add a mobile-only page, modal, or direct-save switch. +- **Shared state:** backend-owned `appStatusBarEnabled` selects both responsive + presentations. Mobile code only selects existing touch presentation. +- **Geometry:** the switch row and Status triggers remain at least 44 px; the + existing drawer remains viewport-contained, safe-area-aware, and the sole + internal scroll owner. No document horizontal overflow is introduced. + +## Test strategy + +### Backend unit and integration tests + +- Store tests prove old/missing JSON defaults false, explicit true survives a + repository round trip, encoding emits the complete field, legacy schemas gain + the revision column, and concurrent writes receive distinct revisions. +- DTO tests prove response mapping plus PATCH omission versus explicit false. +- Service tests prove apply semantics and exact event payload. +- Boot-state tests prove `appStatusBarEnabled` is present and preserves false. +- Runtime registry/config/profile tests prove the active flag is gone and its + exact identity is retired. + +### Frontend unit and component tests + +- Shared settings mapper tests prove default false, explicit true, and partial + update preservation plus revision ordering across boot, HTTP, and WebSocket + paths. +- Status-order tests prove a delayed older PATCH response cannot inherit a + newer store revision or replace that newer snapshot. +- Appearance tests prove the controlled switch's accessible copy, dirty state, + discard, PATCH payload, and post-save store update. +- Status surface provider tests prove on/off responsive mounting and + connection-only bypass. +- Sidebar, top-bar metrics, and LSP tests prove their existing fallbacks now + follow user settings. +- Feature contract tests prove `appStatusBar` is absent from the runtime feature + response and frontend feature state. + +### E2E + +- Extend desktop `layout/app-status-bar.spec.ts` with the persisted Appearance + opt-out/opt-in flow and retain geometry/order coverage. +- Add `layout/mobile-app-status-bar.spec.ts` under the `mobile-chrome` project. + Prove switch/save/reload persistence, ordinary Status path absence while off, + restored drawer access while on, 44 px controls, viewport containment, focus + return, and zero horizontal overflow. +- Update desktop/tablet and mobile connectivity warning specs to establish the + preference through user settings instead of feature-state mutation. +- Update agent-runtime availability coverage so its status-bar-off setup also + uses user settings and the alert remains independent. +- Capture and restore every persisted baseline field changed by these specs in + `afterEach`; the E2E worker does not reset user settings between tests. +- Use API PATCH only for setup/cleanup. Exercise the visible Appearance control + for the product flow itself. Continue injecting transient warning severity + through the E2E store bridge so Playwright does not wait ten seconds. + +## Public documentation + +Implementation updates these shipped-behavior pages in the same change: + +- `docs/public/configuration.md`: remove the runtime flag/config/env entries and + profile-default claims. +- `docs/public/operations.md`: describe **Show status bar**, no-restart behavior, + metrics independence, and the connectivity warning exception. +- `docs/public/agents-and-profiles.md`, + `docs/public/developer-tools.md`, and + `docs/public/sessions-and-review.md`: replace feature-gated wording with the + default-off user preference and responsive surface behavior. + +No new public page or navigation entry is needed. + +## Implementation waves + +Execution is sequential in the primary conversation. + +| Wave | Task | Status | Gate | +|---|---|---|---| +| 1 | [01 portable visibility contract](task-01-portable-visibility-contract.md) | Done | Default-false backend/frontend setting round trips without changing live gates. | +| 2 | [02 Appearance control and surface gates](task-02-appearance-control-and-surface-gates.md) | Done | UI saves the portable preference and every surface consumes it. | +| 3 | [03 retire runtime flag](task-03-retire-runtime-flag.md) | Done | Flag/config/profile identities are removed from active contracts and retired permanently. | +| 4 | [04 E2E, public docs, and verification](task-04-e2e-public-docs-and-verification.md) | Done | Desktop/mobile persistence, fallbacks, docs, and final checks pass. | + +Tasks share settings and feature contracts, so none are parallel-safe. + +## Verification order + +If `apps/node_modules` is absent, run once before frontend checks: + +```sh +(cd apps && pnpm install --frozen-lockfile) +``` + +Each task runs its focused commands. Final verification: + +```sh +(cd apps/backend && go test ./internal/user/... ./internal/backendapp/... ./internal/runtimeflags ./internal/common/config ./internal/profiles) +make -C apps/backend lint +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +(cd apps/web && pnpm e2e:run --project chromium tests/layout/app-status-bar.spec.ts tests/layout/ws-connectivity-warning.spec.ts tests/layout/agent-runtime-unavailable.spec.ts tests/settings/resource-metrics-display.spec.ts) +(cd apps/web && pnpm e2e:run --project mobile-chrome tests/layout/mobile-app-status-bar.spec.ts tests/layout/mobile-ws-connectivity-warning.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts) +git diff --check +``` + +The managed E2E runner must rebuild production assets for the first run. The +second project may use `--no-build` only when the first build completed from the +same final tree. + +## Validation results + +- Backend focused tests and lint passed. +- Frontend focused tests passed: 12 files and 114 tests. +- Frontend typecheck, lint, i18n check, and i18n ratchet passed. The i18n check + reported only its advisory real-locale parity backlog. +- Public-doc tests passed: 58 tests; validation passed for 41 pages. +- Production-build desktop status bar, warning, agent-runtime fallback, and + resource-metrics E2E passed: 9 tests. +- Mobile status bar, warning, and resource-metrics E2E passed: 4 tests. +- Older status-surface consumers now opt in explicitly; their desktop suite + passed 16 tests and their mobile suite passed 29 tests. +- `git diff --check` passed. + +## Risks + +- A backend or frontend fallback that remains true would enable ordinary status + chrome for users who never opted in. +- Updating only `AppStatusSurfaceProvider` would leave sidebar warnings, + top-bar metrics, or LSP placement bound to a removed feature key. +- Removing the runtime registration without retiring its identity would permit + a future flag to reinterpret stale persisted overrides. +- Reusing `show_in_topbar` would make metrics visibility hide unrelated + connection/plugin status. +- Direct feature-store mutation in E2E would pass without exercising the new + persistence path. Persisted setup without cleanup would leak into later specs. +- Saving Appearance while the current route owns the status bar changes shell + geometry immediately. Tests must wait on the surface and floating-save state, + not fixed delays. diff --git a/docs/plans/app-status-bar-appearance-setting/task-01-portable-visibility-contract.md b/docs/plans/app-status-bar-appearance-setting/task-01-portable-visibility-contract.md new file mode 100644 index 0000000000..738da093da --- /dev/null +++ b/docs/plans/app-status-bar-appearance-setting/task-01-portable-visibility-contract.md @@ -0,0 +1,128 @@ +--- +id: app-status-bar-appearance-01 +title: Portable status bar visibility contract +status: done +wave: 1 +depends_on: [] +plan: docs/plans/app-status-bar-appearance-setting/plan.md +spec: docs/specs/ui/app-status-bar.md +decision: docs/decisions/2026-08-11-user-owned-status-bar-visibility.md +--- + +# Portable status bar visibility contract + +## Inputs + +The approved [App Status Bar spec](../../specs/ui/app-status-bar.md), ADR 0041 +backend-owned portable settings, the new visibility ADR, and the existing +`app_status_bar_order` user-settings round trip. + +## TDD sequence + +1. Add failing backend tests for missing-value default false, explicit-true + round trip, PATCH omission versus explicit values, event data, boot mapping, + atomic revisions, and legacy-schema migration. +2. Add failing frontend mapper/handler tests for default false, explicit true, + and omission preserving current state. +3. Add the smallest model, DTO, service, storage, boot, HTTP type, state, and + mapper changes that make those tests pass. +4. Refactor only repeated mapping code exposed by the tests. Do not change live + status surface gates in this task. + +## Implementation + +- Add `AppStatusBarEnabled bool` to + `apps/backend/internal/user/models/models.go`. +- Add the response field and pointer PATCH field to + `apps/backend/internal/user/dto/dto.go`, including `FromUserSettings`. +- Thread the PATCH pointer through + `apps/backend/internal/user/controller/controller.go` and + `apps/backend/internal/user/service/service.go`. +- Apply an explicit value only when the pointer is non-nil. Publish + `app_status_bar_enabled` in the complete `user.settings.updated` event. +- In `apps/backend/internal/user/store/sqlite.go`: + - set `AppStatusBarEnabled: false` in `defaultUserSettings`; + - include the field in JSON writes; + - decode it as `*bool`; + - overwrite the default only when the stored pointer is present; + - migrate a `settings_revision` column and atomically increment and return it + with every settings mutation. +- Add `appStatusBarEnabled` to + `apps/backend/internal/backendapp/boot_state_routes.go`. +- Carry the numeric revision through the response DTO, boot state, and complete + settings event so clients can order snapshots without timestamps. +- Add `app_status_bar_enabled?: boolean` to response and PATCH shapes in + `apps/web/lib/types/http-user-settings.ts`. +- Add `appStatusBarEnabled: boolean` to + `apps/web/lib/state/slices/settings/types.ts`. +- Initialize it to false and map it through the shared paths in + `apps/web/lib/ssr/user-settings.ts`. The common mapper must preserve the + current value for partial updates. +- Update the existing user WebSocket handler and ensure-user-settings fixtures + through their shared mapper; do not add a second field-specific handler. +- Apply one revision comparison at boot, HTTP, and WebSocket ingestion points; + reject older snapshots without assigning them a newer local revision. + +The only SQL migration adds the per-user settings revision counter. No browser +storage is allowed. + +## Files likely touched + +- `apps/backend/internal/user/models/models.go` +- `apps/backend/internal/user/dto/dto.go` +- `apps/backend/internal/user/dto/dto_test.go` +- `apps/backend/internal/user/controller/controller.go` +- `apps/backend/internal/user/service/service.go` +- `apps/backend/internal/user/service/service_test.go` +- `apps/backend/internal/user/store/sqlite.go` +- `apps/backend/internal/user/store/sqlite_test.go` +- `apps/backend/internal/backendapp/boot_state_routes.go` +- `apps/backend/internal/backendapp/boot_state_user_settings_test.go` +- `apps/web/lib/types/http-user-settings.ts` +- `apps/web/lib/state/slices/settings/types.ts` +- `apps/web/lib/ssr/user-settings.ts` +- `apps/web/lib/ssr/user-settings.test.ts` +- `apps/web/lib/ws/handlers/users.test.ts` +- `apps/web/hooks/use-ensure-user-settings.test.ts` + +## Acceptance + +1. A new user, an old settings JSON object with no visibility field, and an + initial compatibility payload that omits it all resolve to false. +2. Stored and PATCHed true remains true through repository read, DTO response, + boot state, frontend mapping, reload, and event mapping. +3. An omitted PATCH field leaves the existing setting unchanged. +4. A partial WebSocket payload without the field preserves the current + frontend value. +5. The backend emits `app_status_bar_enabled` in the complete user-settings + event and `appStatusBarEnabled` in boot state. +6. Concurrent settings writes receive distinct increasing revisions, and an + existing database without the revision column upgrades cleanly. +7. Runtime feature behavior remains intact until Tasks 02 and 03. + +## Verification + +```sh +(cd apps/backend && go test ./internal/user/... ./internal/backendapp/...) +(cd apps && pnpm --filter @kandev/web exec vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts hooks/use-ensure-user-settings.test.ts) +git diff --check +``` + +## Dependencies + +None. This task creates the portable contract consumed by Task 02. + +## Risks + +- A default that remains true in either mapper would enable the surface for old + rows that have no preference. +- Defaulting independently inside several frontend consumers can make boot and + live updates disagree. Use the shared mapper. +- A complete event map can silently omit the new field even when HTTP works. + Pin the event key in service tests. + +## Output contract + +Report the exact default/false/omission tests, API and event shapes, files +changed, commands run, and blockers. Mark this task done only when the portable +setting round trip is green and live UI remains on the old gate. diff --git a/docs/plans/app-status-bar-appearance-setting/task-02-appearance-control-and-surface-gates.md b/docs/plans/app-status-bar-appearance-setting/task-02-appearance-control-and-surface-gates.md new file mode 100644 index 0000000000..c024b06ec8 --- /dev/null +++ b/docs/plans/app-status-bar-appearance-setting/task-02-appearance-control-and-surface-gates.md @@ -0,0 +1,163 @@ +--- +id: app-status-bar-appearance-02 +title: Appearance control and surface gates +status: done +wave: 2 +depends_on: [app-status-bar-appearance-01] +plan: docs/plans/app-status-bar-appearance-setting/plan.md +spec: docs/specs/ui/app-status-bar.md +decision: docs/decisions/2026-08-11-user-owned-status-bar-visibility.md +--- + +# Appearance control and surface gates + +## Inputs + +Task 01's default-false portable setting, the existing Appearance-page shared +save coordinator, `SystemMetricsSettingsCard`, `AppStatusSurfaceProvider`, and +the warning/LSP fallback contracts in the linked specs. + +## TDD sequence + +1. Add failing tests for the controlled Appearance card and shared save payload, + discard, dirty state, and post-save store update. +2. Convert existing surface/sidebar/top-bar/LSP tests from feature fixtures to + user-setting fixtures and make the desired on/off/fallback cases fail. +3. Implement the localized card, Appearance integration, and user-setting + selectors. +4. Regenerate pseudo translations, run focused tests, then refactor duplicated + visibility selectors or test setup only if needed. + +## Implementation + +### Appearance + +- Create + `apps/web/components/settings/app-status-bar-settings-card.tsx` as a + controlled Settings card. +- Render a localized **Show status bar** switch and explanation. The row must + remain at least 44 px tall and expose the existing `data-settings-dirty` + marker. +- Add a Status bar section to + `apps/web/components/settings/general-settings.tsx`. +- Extend `createAppearanceSavedState`, saved/draft state, the + `general-appearance` contributor's PATCH payload, discard behavior, and + post-save `setUserSettings` call with `appStatusBarEnabled`. +- Keep one shared **Save changes** action. Do not save on switch activation and + do not add optimistic durable state before the API succeeds. +- Do not send an empty user-settings PATCH when the save contains only local + theme or menu changes. +- Add `GENERAL_SETTINGS_TARGETS.appStatusBar` and a discovery control in + `apps/web/lib/settings-discovery/catalog/preferences.ts`, under the + Preferences Appearance page. +- Add English keys in `apps/web/src/locales/en/settings.json` and run + `pnpm run i18n:pseudo`. + +### Runtime consumers + +- Replace the feature selector in + `apps/web/components/app-status-bar/app-status-surface-provider.tsx` with + `state.userSettings.appStatusBarEnabled`. +- Preserve `connectionOnly = !enabled && issueSeverity !== "none"` and mount no + metrics or plugin contributions for that exception. +- Replace the feature selector in + `apps/web/components/app-sidebar/app-sidebar-footer.tsx`; warning fallback + appears only while the preference is off and severity is active. +- Replace the feature selector in + `apps/web/components/system-metrics/topbar-metrics.tsx`; the existing top-bar + metrics fallback remains available while the bar is off. +- Replace the feature selector in + `apps/web/hooks/use-lsp-status-placement.ts`; keep the pure + `resolveLspStatusPlacement` behavior and saved preference unchanged. +- Remove obsolete feature mocks from related drawer/LSP integration tests where + they no longer affect rendering. + +## Mobile design contract + +- **Desktop outcome:** saved on mounts the existing 24 px bottom bar; saved off + removes it after the shared save completes. +- **Phone entry:** saved on exposes existing native Status controls; saved off + removes ordinary Status controls while warning-only controls remain possible. +- **Nearest exemplar:** the resource metrics card provides the controlled + switch/dirty pattern. The shipped App Status drawer remains the responsive + surface exemplar. +- **Hierarchy and presentation:** use a dedicated Status bar Settings section + inside Appearance and the existing floating save action. No mobile-only + dialog or duplicate setting. +- **Shared state:** one user-setting selector governs desktop, tablet, and phone. + Breakpoint code only chooses presentation. +- **Geometry:** keep the switch row and native triggers touch-sized; make no + shell, safe-area, or scroll-owner changes. + +## Tests + +- Add + `apps/web/components/settings/app-status-bar-settings-card.test.tsx` for + accessible label/description, controlled value, callback, and dirty marker. +- Add a focused + `apps/web/components/settings/general-settings.test.tsx` for the shared save + payload, successful store update, and discard behavior. +- Update: + - `components/app-status-bar/app-status-surface-provider.test.tsx` + - `components/app-sidebar/app-sidebar-footer.test.tsx` + - `components/system-metrics/topbar-metrics.test.tsx` + - `lib/lsp/lsp-status-placement.test.ts` + - `components/app-status-bar/lsp-status-item.integration.test.tsx` + - any drawer fixture that still injects `features.appStatusBar`. + +Tests must prove: + +1. Explicit true mounts the ordinary responsive status surface. +2. False removes ordinary bar/drawer access. +3. False plus an active connectivity issue mounts only warning fallback. +4. Metrics and LSP fall back to their old non-bar locations without overwriting + their own saved settings. +5. Failed save leaves the confirmed Zustand value active and the Appearance + draft dirty/retryable. +6. A delayed status-order PATCH response cannot replace a newer settings + revision that arrived through another ingestion path. + +## Verification + +```sh +(cd apps && pnpm --filter @kandev/web exec vitest run components/settings/app-status-bar-settings-card.test.tsx components/settings/general-settings.test.tsx components/app-status-bar/app-status-surface-provider.test.tsx components/app-sidebar/app-sidebar-footer.test.tsx components/system-metrics/topbar-metrics.test.tsx lib/lsp/lsp-status-placement.test.ts components/app-status-bar/lsp-status-item.integration.test.tsx) +(cd apps/web && pnpm run i18n:pseudo) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +git diff --check +``` + +## Dependencies + +Task 01. This task makes the runtime flag unused by product surfaces; Task 03 +then removes its active contracts safely. + +## Risks + +- Updating the visible switch but omitting it from `createAppearanceSavedState` + or the post-save store update can create false dirty states or delayed UI. +- Mounting the warning-only drawer through the general enabled path can run + metrics/plugin effects while the user opted out. +- Translating copy at module scope or comparing translated text would violate + i18n rules. Render copy through `t()` inside components. +- A small switch with an untappable surrounding row fails mobile parity even if + desktop tests pass. + +## Output contract + +Report the Appearance payload and failure behavior, responsive gate results, +fallback coverage, generated locale changes, exact commands, and blockers. Mark +this task done only after every live consumer reads user settings and the flag +has no UI consumers. + +## Validation results + +- RED: preference-backed fixtures failed against the former feature selectors; + the Appearance integration could not find **Show status bar**. +- GREEN: focused Vitest run passed 43 tests across the Appearance control, + responsive surfaces, warning fallback, metrics fallback, and LSP placement. +- `pnpm run i18n:check`, `pnpm run i18n:ratchet`, and `pnpm run typecheck` + passed. Focused ESLint passed after extracting the Appearance section body + and the added WebSocket case to respect repository size limits. diff --git a/docs/plans/app-status-bar-appearance-setting/task-03-retire-runtime-flag.md b/docs/plans/app-status-bar-appearance-setting/task-03-retire-runtime-flag.md new file mode 100644 index 0000000000..849cb18a36 --- /dev/null +++ b/docs/plans/app-status-bar-appearance-setting/task-03-retire-runtime-flag.md @@ -0,0 +1,165 @@ +--- +id: app-status-bar-appearance-03 +title: Retire App status bar runtime flag +status: done +wave: 3 +depends_on: [app-status-bar-appearance-02] +plan: docs/plans/app-status-bar-appearance-setting/plan.md +spec: docs/specs/ui/app-status-bar.md +decision: docs/decisions/2026-08-11-user-owned-status-bar-visibility.md +--- + +# Retire App status bar runtime flag + +## Inputs + +Task 02's complete user-setting gates, the runtime flag graduation checklist, +the append-only retired identity registry, and the backend/frontend feature-key +contract test. + +## TDD sequence + +1. Replace the registry inclusion test with failing assertions that + `features.appStatusBar` is absent from active definitions and its exact key + and env variable are retired. +2. Add a failing runtime-service regression that seeds both a stale + `features.appStatusBar` override and explicit + `KANDEV_FEATURES_APP_STATUS_BAR`, then proves neither produces an active + state or an `appStatusBar` field in the serialized feature response. +3. Add failing user-store regressions that run with the retired environment + variable present and prove missing `app_status_bar_enabled` still defaults + to false while a stored true remains true. +4. Update feature/profile contract tests to expect no App status bar field or + profile key, then remove the active configuration and frontend fields. +5. Run focused Go and frontend contract tests before lint/typecheck. + +## Implementation + +- In `apps/backend/internal/runtimeflags/registry.go`: + - remove the active App status bar registration; + - append + `{key: "features.appStatusBar", envVar: "KANDEV_FEATURES_APP_STATUS_BAR"}` + to `retiredRuntimeFlagIdentities`; + - preserve the generic no-reuse validation. +- In `apps/backend/internal/runtimeflags/registry_test.go`, replace metadata + inclusion coverage with explicit active-exclusion and retired-identity + coverage. +- In `apps/backend/internal/runtimeflags/service_test.go`, add a retirement + boundary test that seeds the former override key and environment variable, + calls `ListStates`, applies the returned states to config, and serializes + `FeaturesConfig` exactly as `/api/v1/features` does. Assert the retired key + is absent from both active states and response JSON. +- Replace App status bar inclusion cases in + `apps/backend/internal/runtimeflags/config_test.go` with an explicit check + that `OptionsFromConfig` ignores `KANDEV_FEATURES_APP_STATUS_BAR` even when it + is set. +- Remove `FeaturesConfig.AppStatusBar` from + `apps/backend/internal/common/config/config.go` and its env/default tests from + `config_test.go`. Keep `TestFeaturesConfig_JSONShape` as the API wire oracle + and add an explicit `appStatusBar` absence assertion. +- Remove `KANDEV_FEATURES_APP_STATUS_BAR` from + `apps/backend/internal/profiles/profiles.yaml` and all profile tests that + require, apply, or derive `app_status_bar`. +- Remove `appStatusBar` from + `apps/web/lib/state/slices/features/types.ts`. +- Update `apps/web/app/actions/features.test.ts` and any remaining feature + fixtures. Keep + `apps/web/lib/state/slices/features/features-contract.test.ts` green against + the reduced backend JSON shape. +- In `apps/backend/internal/user/store/sqlite_test.go`, run the missing-field + default and explicit-false round-trip cases with the retired environment + variable set. These tests must prove user visibility remains owned only by + `app_status_bar_enabled` after flag retirement. +- Search active source and public runtime configuration for stale identities. + Public docs are changed in Task 04; historical plans and the new retirement + ADR may retain the names. + +Do not delete or rewrite SQLite runtime override rows. Unknown keys are already +ignored, and retaining the retired identity prevents reinterpretation. + +## Files likely touched + +- `apps/backend/internal/runtimeflags/registry.go` +- `apps/backend/internal/runtimeflags/registry_test.go` +- `apps/backend/internal/runtimeflags/service_test.go` +- `apps/backend/internal/runtimeflags/config_test.go` +- `apps/backend/internal/common/config/config.go` +- `apps/backend/internal/common/config/config_test.go` +- `apps/backend/internal/user/store/sqlite_test.go` +- `apps/backend/internal/profiles/profiles.yaml` +- `apps/backend/internal/profiles/profiles_test.go` +- `apps/web/lib/state/slices/features/types.ts` +- `apps/web/lib/state/slices/features/features-contract.test.ts` +- `apps/web/app/actions/features.test.ts` + +## Acceptance + +1. `/api/v1/features` and frontend `FeatureName` have no `appStatusBar`. +2. Runtime definitions and Feature Toggles have no App status bar entry. +3. No shipped profile sets `KANDEV_FEATURES_APP_STATUS_BAR`. +4. The exact former key and environment variable exist in the append-only + retired identity list and fail generic active-reuse checks. +5. A focused runtime regression proves that setting the retired environment + variable and retaining an old override produces neither an active runtime + state nor an `appStatusBar` feature-response field. +6. Focused user-store regressions prove `app_status_bar_enabled` retains stored + true and defaults to false when absent, even when the retired environment + variable is present. +7. Status surfaces remain controlled by the default-false user setting from + Tasks 01 and 02. + +## Verification + +```sh +(cd apps/backend && go test ./internal/runtimeflags ./internal/common/config ./internal/profiles ./internal/user/store) +(cd apps && pnpm --filter @kandev/web exec vitest run lib/state/slices/features/features-contract.test.ts app/actions/features.test.ts) +make -C apps/backend lint +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +git diff --check +``` + +Also inspect: + +```sh +rg -n "appStatusBar|features\.appStatusBar|KANDEV_FEATURES_APP_STATUS_BAR" apps/backend/internal apps/web profiles.yaml +``` + +Expected matches are limited to retired-identity assertions or unrelated +user-setting/order names. Product consumers, active config, and profiles must +have none. + +## Dependencies + +Task 02. Removing the flag earlier would make live surfaces lose their gate +before the portable preference is wired. + +## Risks + +- Deleting the identity instead of retiring it permits future stale override + reinterpretation. +- Removing only the registry entry leaves the config field in + `/api/v1/features`, which keeps frontend contracts and mental models stale. +- Editing the root `profiles.yaml` symlink and its canonical target as separate + files can duplicate work. Change the canonical profile content once. +- Broad search results include historical plans and ADRs. Do not rewrite + implementation history to make the search empty. + +## Output contract + +Report the retired identity proof, reduced API/frontend feature shape, profile +removal, search results, exact commands, and blockers. Mark this task done only +when no active runtime path can observe the former flag. + +## Validation results + +- RED: active registry/config/profile/frontend tests exposed the old key, + environment variable, response field, and frontend feature name. +- GREEN: full runtime flag, common config, profile, and user-store packages + passed; the reduced frontend feature contract/action/surface suite passed 43 + tests. +- `pnpm run typecheck` and `pnpm run lint` passed. Focused Go lint passed after + the retired key/environment pair was named once and reused by package tests. +- Searches now find the former identities only in retirement assertions, + historical artifacts, public docs awaiting Task 04, and E2E fixtures awaiting + their portable-settings conversion. diff --git a/docs/plans/app-status-bar-appearance-setting/task-04-e2e-public-docs-and-verification.md b/docs/plans/app-status-bar-appearance-setting/task-04-e2e-public-docs-and-verification.md new file mode 100644 index 0000000000..bee224e947 --- /dev/null +++ b/docs/plans/app-status-bar-appearance-setting/task-04-e2e-public-docs-and-verification.md @@ -0,0 +1,165 @@ +--- +id: app-status-bar-appearance-04 +title: Status bar E2E docs and verification +status: done +wave: 4 +depends_on: [app-status-bar-appearance-03] +plan: docs/plans/app-status-bar-appearance-setting/plan.md +spec: docs/specs/ui/app-status-bar.md +decision: docs/decisions/2026-08-11-user-owned-status-bar-visibility.md +--- + +# Status bar E2E, public docs, and verification + +## Inputs + +Tasks 01 through 03, the desktop App status bar E2E, mobile Status drawer E2E, +connectivity warning E2E, agent-runtime availability E2E, and public +configuration/operations documentation. + +## E2E sequence + +1. Add a failing desktop Appearance persistence scenario to + `apps/web/e2e/tests/layout/app-status-bar.spec.ts`. +2. Add failing phone coverage in the correctly named + `apps/web/e2e/tests/layout/mobile-app-status-bar.spec.ts` and run it under + `mobile-chrome`. +3. Convert warning and runtime-availability tests from feature-store mutation + to backend-owned user settings. +4. Add baseline capture/restore before running the focused production-build + suites. + +## E2E implementation + +### Persisted baseline discipline + +- Read `app_status_bar_enabled` in `beforeEach` with + `apiClient.getUserSettings()`. +- Restore it in `afterEach` with PATCH even after an assertion failure. +- Any touched test that mutates `app_status_bar_order` or + `system_metrics_display` must capture and restore that field too. +- Use API PATCH for setup and cleanup. Use the visible switch and shared save + action in the main behavior scenarios. + +### Desktop and tablet + +- On `/settings/preferences/appearance`, verify **Show status bar** starts from the + backend value. +- Turn it off, confirm dirty state, save through the floating **Save changes** + action, and wait for the ordinary bar to disappear without a restart. +- Reload and prove the switch remains off and the bar remains absent. +- Turn it on, save, and prove the 24 px bar returns with existing shell geometry + intact. +- Retain modifier-drag order persistence coverage and restore its saved order. +- In `ws-connectivity-warning.spec.ts`, establish false through user settings + and retain the sidebar plus sub-768 drawer warning fallbacks. +- In `agent-runtime-unavailable.spec.ts`, establish false through user settings + and prove the independent persistent alert remains visible. + +### Phone and coarse pointer + +- Reach Appearance through the shipped mobile settings navigation. +- Turn the preference off and save. Prove ordinary native Status entry points + and the general drawer are absent after navigation and reload. +- Turn it back on and prove a native Status trigger opens the existing inset + drawer, closes with focus return, and persists. +- Assert the switch/trigger are at least 44 px, the drawer stays within the + viewport, has one internal scroll owner, honors safe-area composition, and + creates no document horizontal overflow. +- Update `mobile-ws-connectivity-warning.spec.ts` to use the false user setting + while retaining phone and coarse-pointer tablet connection-only coverage. +- Continue injecting only transient connection severity through + `__KANDEV_E2E_STORE__`; do not sleep through the three/ten-second thresholds. + +## Public documentation + +Update: + +- `docs/public/configuration.md` + - remove `features.app_status_bar`, + `KANDEV_FEATURES_APP_STATUS_BAR`, and profile-default rows; + - do not replace them with a config key. +- `docs/public/operations.md` + - point users to Settings > Preferences > Appearance > **Show status bar**; + - state that saves apply without restart; + - explain that host metrics remain separately controlled and urgent + connection warnings remain visible while ordinary status chrome is off. +- `docs/public/agents-and-profiles.md` +- `docs/public/developer-tools.md` +- `docs/public/sessions-and-review.md` + - replace feature-gated wording with the default-off portable preference and + the desktop/tablet versus phone presentation. + +Review all changed user-facing copy through i18n rules. Public Markdown does not +use runtime translation, but it must use current terms and no Unicode em dash. + +## Acceptance + +1. Desktop and mobile users can turn the status surface off and on through + Appearance, with shared-save dirty state and reload persistence. +2. The preference takes effect without backend restart. +3. Desktop/sidebar, narrow responsive, phone, and coarse-pointer warning + fallbacks remain reachable while ordinary status chrome is off. +4. Host metrics and LSP fallback behavior remain independent. +5. Agent-runtime availability remains visible regardless of the preference. +6. E2E tests restore persisted baselines and pass from one shared worker state. +7. Public docs contain no live App status bar runtime flag/env instructions. +8. Backend tests/lint, frontend-focused tests/lint/typecheck/i18n, public docs + validation, and production-build desktop/mobile E2E all pass. + +## Verification + +Install dependencies once if needed: + +```sh +(cd apps && pnpm install --frozen-lockfile) +``` + +Run focused and static checks: + +```sh +(cd apps/backend && go test ./internal/user/... ./internal/backendapp/... ./internal/runtimeflags ./internal/common/config ./internal/profiles) +make -C apps/backend lint +(cd apps && pnpm --filter @kandev/web exec vitest run lib/ssr/user-settings.test.ts lib/ws/handlers/users.test.ts hooks/use-ensure-user-settings.test.ts components/settings/app-status-bar-settings-card.test.tsx components/settings/general-settings.test.tsx components/app-status-bar/app-status-surface-provider.test.tsx components/app-sidebar/app-sidebar-footer.test.tsx components/system-metrics/topbar-metrics.test.tsx lib/lsp/lsp-status-placement.test.ts components/app-status-bar/lsp-status-item.integration.test.tsx lib/state/slices/features/features-contract.test.ts app/actions/features.test.ts) +(cd apps/web && pnpm run i18n:check) +(cd apps/web && pnpm run i18n:ratchet) +(cd apps/web && pnpm run typecheck) +(cd apps/web && pnpm run lint) +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +Run desktop and mobile through separate managed-runner invocations: + +```sh +(cd apps/web && pnpm e2e:run --project chromium tests/layout/app-status-bar.spec.ts tests/layout/ws-connectivity-warning.spec.ts tests/layout/agent-runtime-unavailable.spec.ts tests/settings/resource-metrics-display.spec.ts) +(cd apps/web && pnpm e2e:run --no-build --project mobile-chrome tests/layout/mobile-app-status-bar.spec.ts tests/layout/mobile-ws-connectivity-warning.spec.ts tests/settings/mobile-resource-metrics-display.spec.ts) +git diff --check +``` + +Do not use `--no-build` on the first E2E run. Use it on the second only when the +first run completed from the same final tree. + +## Dependencies + +Task 03. E2E and public docs must describe the final user-settings contract, +not the transitional state where the runtime flag still exists. + +## Risks + +- Settings persist across E2E tests; missing cleanup can make explicit-on tests + order-dependent. +- Saving off from the Appearance route changes shell height immediately. Await + surface and save-state locators instead of using delays. +- A phone test that opens the drawer directly can miss broken native entry + points. Navigate through the shipped menu/top-bar path. +- Running both Playwright projects in one runner invocation can use the wrong + project. Keep desktop and `mobile-chrome` separate. +- Removing configuration docs without adding the Appearance path leaves users + no discoverable replacement. + +## Output contract + +Report desktop and mobile flows, warning/alert exceptions, persisted cleanup, +public pages updated, exact command results, and blockers. Mark this task and +the parent plan done only after every acceptance item is green. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 2fd1f4db3a..c252491112 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -111,11 +111,11 @@ credentials. For account-wide provider usage across supported providers, install the [Provider Usage plugin](https://github.com/kdlbs/kandev-plugin-provider-usage). It adds a provider pill to the session top bar and can add a compact display to -the global status bar. The optional status-bar display requires **App status -bar** to be enabled under **Settings > System > Feature Toggles**; enable it -and restart Kandev for the change to take effect. If it remains disabled, the -session top-bar pill remains available; enabling it also adds the global -status-bar and phone Status drawer display. Configure the plugin under +the global status surface. That surface is off by default and follows the +portable **Show status bar** preference under **Settings > Preferences > +Appearance > Status Bar**; saving applies without a restart. If it is off, the +session top-bar pill remains available. When it is on, the plugin can also +appear in the desktop/tablet bottom bar or phone Status drawer. Configure the plugin under **Settings > Plugins > Provider Usage**. These usage surfaces are operational signals, not a billing ledger or a guarantee that the next request will be accepted; provider availability, account policy, and concurrent usage still diff --git a/docs/public/configuration.md b/docs/public/configuration.md index 386d598bd8..241bc3cb8f 100644 --- a/docs/public/configuration.md +++ b/docs/public/configuration.md @@ -128,7 +128,6 @@ The launcher starts `agentctl`, performs a one-time nonce handshake, and supplie | `office.jwtSigningKey` | `KANDEV_OFFICE_JWTSIGNINGKEY` | random per start | HMAC key for Office agent-runtime JWTs. Set a stable secret when Office tasks must survive restarts. | | `voice.openAIApiKey` | `KANDEV_VOICE_OPENAI_API_KEY` | empty | Server-side transcription fallback when browser speech recognition is unavailable. Empty disables the fallback and its endpoint returns unavailable. | | `features.office` | `KANDEV_FEATURES_OFFICE` | `false` in production | Experimental Office UI, routes, services, and automation. | -| `features.app_status_bar` | `KANDEV_FEATURES_APP_STATUS_BAR` | `false` in production | Opt-in global connection/host-metrics/plugin surface: bottom bar on tablet/desktop and Status drawer on phones. Disabling it does not suppress active WebSocket connectivity warnings. | | `features.auth` | `KANDEV_FEATURES_AUTH` | `false` in production | Opt-in authentication and per-user workspaces. The first visitor after enabling completes setup and becomes the admin. | | `features.claude_background_prompt_handoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | `false` | High-risk experiment that lets Claude Code accept a new prompt after its foreground yields while adapter-attested background work remains active. Other providers keep the coarse busy gate. | | `features.claude_mid_turn_steering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | `false` | High-risk experiment that delivers a new prompt into a Claude turn that is still generating (mid-turn steering) instead of queuing it, for agents that advertise prompt queueing. Whether the agent folds the prompt into the running turn or runs it next is the agent's decision; other providers keep the coarse busy gate. | @@ -292,7 +291,6 @@ voice: features: office: false - app_status_bar: false auth: false claude_background_prompt_handoff: false ``` @@ -308,7 +306,6 @@ Copying this entire file is unnecessary and can freeze old defaults in a deploym | Key | Environment lock | Production default | Effect | |---|---|---|---| | `features.office` | `KANDEV_FEATURES_OFFICE` | off | Experimental autonomous-agent Office surfaces and automation. | -| `features.appStatusBar` | `KANDEV_FEATURES_APP_STATUS_BAR` | off | Global status bar on tablet/desktop and Status drawer entry on phones. Enabling changes visibility only; urgent WebSocket connectivity warnings remain visible when it is off. | | `features.auth` | `KANDEV_FEATURES_AUTH` | off | Authentication and per-user workspaces for the whole install. | | `features.claudeBackgroundPromptHandoff` | `KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF` | off | High-risk Claude Code experiment that exposes recognized background-only activity and admits a successor prompt. | | `features.claudeMidTurnSteering` | `KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING` | off | High-risk Claude Code experiment that delivers a prompt into a still-generating turn (mid-turn steering) instead of queuing it, for agents advertising prompt queueing. | @@ -324,7 +321,7 @@ the rollout is complete, then remove the live flag and move its key and environment variable to the runtime registry's append-only retired identities. Plugins are part of the base product and are not a runtime toggle. -The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; the App status bar, authentication, and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. +The source checkout's `make dev` activates the embedded development profile, which enables Office, debug surfaces, ACP logging, and a mock agent; authentication and Claude background prompt handoff remain opt-in. Installed `run`/desktop builds select the safe production profile unless the environment explicitly opts in. E2E mock variables and routes are test-only and must never be enabled on a public deployment. ## Credentials and product settings The **Unread Messages** preference in **Settings > General > Task Actions** controls the Slack-style **New** divider in session transcripts. It defaults off for each user, persists with user settings, and takes effect immediately. Enabling it also allows that user's active transcript view to advance the session read cursor. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index ed4e1574ec..8e41b6f3a6 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -189,7 +189,12 @@ "apps/web/components/settings/agent-profile-page.test.tsx", "apps/web/hooks/domains/settings/use-dynamic-models.test.ts" ], - "settingsRoutes": ["/settings/agents", "/settings/agents/browse", "/settings/prompts", "/settings/utility-agents"], + "settingsRoutes": [ + "/settings/agents", + "/settings/agents/browse", + "/settings/prompts", + "/settings/utility-agents" + ], "mcpTools": [ "create_agent_profile_kandev", "delete_agent_profile_kandev", @@ -253,15 +258,18 @@ "audiences": ["user"], "stability": "stable", "docs": ["security", "configuration", "developer-tools", "operations", "sessions-and-review"], - "sources": ["apps/web/src/settings-routes.tsx", "apps/web/components/settings/general-settings.tsx", "apps/web/components/settings/layouts/layout-settings.tsx"], - "tests": ["apps/web/src/settings-routes.test.ts", "apps/web/components/settings/terminal-settings.test.ts", "apps/web/lib/layout/layout-profiles.test.ts"], + "sources": ["apps/web/src/settings-routes.tsx", "apps/web/components/settings/settings-index.tsx", "apps/web/components/settings/general-settings.tsx", "apps/web/components/settings/layouts/layout-settings.tsx"], + "tests": ["apps/web/src/settings-routes.test.ts", "apps/web/components/settings/settings-index.test.tsx", "apps/web/components/settings/terminal-settings.test.ts", "apps/web/lib/layout/layout-profiles.test.ts"], "settingsRoutes": [ + "/settings", "/settings/preferences", "/settings/preferences/appearance", "/settings/preferences/keyboard-shortcuts", "/settings/preferences/layouts", "/settings/preferences/notifications", + "/settings/preferences/task-behavior", "/settings/preferences/terminal-editors", + "/settings/secrets", "/settings/general", "/settings/general/appearance", "/settings/general/editors", @@ -271,9 +279,7 @@ "/settings/general/secrets", "/settings/general/sprites", "/settings/general/task-actions", - "/settings/general/terminal", - "/settings/secrets", - "/settings/preferences/task-behavior" + "/settings/general/terminal" ] }, { @@ -384,7 +390,6 @@ ], "exclusions": { "settingsRoutes": [ - { "route": "/settings", "reason": "Alias for General settings." }, { "route": "/settings/changelog", "reason": "Redirect to System updates." }, { "route": "/settings/general/changes-panel", "reason": "Legacy redirect to Appearance." }, { "route": "/settings/general/chat-input", "reason": "Legacy redirect to Keyboard Shortcuts." }, diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index 797754cc64..f4817a86bc 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -197,7 +197,7 @@ Language-server settings are part of **Settings > General > Editors**. Kandev cu Auto-start and auto-install are off for every language by default. Enable only the languages used by the workspace, then save settings. Each language card keeps its installation command, prerequisites, and managed destination visible next to the auto-install setting, including for touch and keyboard users. Open the language-server control to inspect its status and use the explicit **Start**, **Stop**, or **Retry** action. Browser-local storage remembers manual enablement for that session and language. If you stop an auto-started server, it stays off for that session and language in the current browser tab—even if a matching editor reopens—until you explicitly start it again or reload the page. Kandev disconnects an unused server connection after two minutes. -**Status location** defaults to the active file's editor toolbar. On a fine-pointer desktop with the Application status bar enabled, you can instead place it in that bar; the item follows the active supported file and is absent on unsupported files and non-file panels. A touch-oriented tablet keeps the saved preference but uses the 44 px editor-toolbar control and bottom drawer. The phone file viewer has no LSP control. Runtime fallbacks do not overwrite the saved preference. +**Status location** defaults to the active file's editor toolbar. On a fine-pointer desktop with **Show status bar** enabled under **Settings > Preferences > Appearance > Status Bar**, you can instead place it in that bar; the item follows the active supported file and is absent on unsupported files and non-file panels. Turning the status surface off moves the item back to the editor toolbar without overwriting its saved location. A touch-oriented tablet also keeps the saved preference but uses the 44 px editor-toolbar control and bottom drawer. The phone file viewer has no LSP control. Language servers run beside the project on the **task host**, with the task workspace as their working directory. V1 supports Local PC and Local Docker tasks. SSH, Sprites, and remote-Docker tasks show an unsupported-executor state instead of starting a server; an LSP request alone does not launch or resume those task resources. The desktop Monaco editor wires diagnostics plus completion, hover, definition, references, signature help, and semantic tokens when the server advertises them. TypeScript/JavaScript built-ins remain available to other sessions and models, and for features the active external server does not advertise or Kandev does not replace. After a file is successfully saved, Kandev sends its latest content change before notifying an active server that requested save synchronization; a failed save sends no save notification. The mobile file viewer does not start language servers in the background. diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index bcf7d26b35..1e3100f484 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -72,7 +72,7 @@ Released builds can lag this page. Check **Settings > System > About** or `kande | Voice input | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. | | Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. | | Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. | -| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or Application status bar; touch tablet falls back to the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while SSH, Sprites, remote Docker, and mobile LSP are not. | +| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or the opt-in status surface controlled by **Show status bar**; when that surface is off it falls back to the toolbar. Touch tablet uses the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while SSH, Sprites, remote Docker, and mobile LSP are not. | | Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. | | Prompts, keyboard shortcuts, notifications, appearance, and task actions | Supported | These are normal settings surfaces. Browser notification permission, custom command validity, and local shell/editor availability still apply to their effects. | | Web UI localization | Limited | **Settings > General > Appearance** offers English, Português (Portugal), and 简体中文. The choice applies immediately, persists per browser through a `kandev_locale` cookie, and is negotiated from `Accept-Language` before defaulting to English when no cookie is set. Externalization is complete: every reachable web UI surface renders through the translation layer, and dates, numbers, and relative timestamps follow the selected locale. Per-language completeness still differs. Portuguese tracks English closely; Simplified Chinese is maintained out of band and lags, most visibly in the task workbench. An untranslated key falls back to English rather than failing. About fifteen shared `@kandev/ui` accessibility labels — mostly screen-reader-only text such as a dialog's **Close** — stay English even where the visible UI is translated. Desktop-shell and CLI surfaces, API/log diagnostics, and agent output were never in scope; user content such as task titles, chat, and diffs is never translated. Development and E2E builds list an extra **Pseudo (QA)** locale that production builds do not ship. | diff --git a/docs/public/operations.md b/docs/public/operations.md index b69e1dccec..9c2d37a055 100644 --- a/docs/public/operations.md +++ b/docs/public/operations.md @@ -422,22 +422,27 @@ After restart, verify `/health`, **System > About**, **System > Status**, the da ## Resource metrics -Configure sampling at **Settings > General > Appearance > Resource Metrics**. Defaults are CPU, memory, and disk percentage every five seconds, backend disk path `/`, and execution-environment collection off. Valid intervals are 1–300 seconds; at least one of CPU, memory, disk, CPU temperature, or 1-minute system load remains selected. System load is the average number of tasks running or waiting for CPU during the last minute; compare it with the host's CPU core count. Enable **Simplified metrics** to show only each metric icon and value in the status bar or phone Status drawer, without the Host marker or percentage progress bars. +Configure sampling at **Settings > Preferences > Appearance > Resource Metrics**. Defaults are CPU, memory, and disk percentage every five seconds, backend disk path `/`, and execution-environment collection off. Valid intervals are 1–300 seconds; at least one of CPU, memory, disk, CPU temperature, or 1-minute system load remains selected. System load is the average number of tasks running or waiting for CPU during the last minute; compare it with the host's CPU core count. Enable **Simplified metrics** to show only each metric icon and value in the status bar, fallback top bar, or phone Status drawer, without the Host marker or percentage progress bars. -Collection starts only while at least one connected client displays metrics in the global status bar. Phone clients subscribe only while their Status drawer is open. The built-in status surface renders the Kandev host source only. Enabling execution metrics also adds active Docker, SSH, and Sprites `agentctl` sources to the metrics stream for separately owned consumers such as plugins; execution disk sampling uses `/`. A provider hook also exists for remote Docker, but creating that runtime currently returns a not-implemented error. Missing platform APIs, container permissions, an invalid disk path, a disconnected executor, macOS/Windows temperature support, or Windows load-average support produce unavailable samples rather than quotas. +Collection starts only while at least one connected client displays metrics in the status bar, fallback top bar, or an open phone Status drawer. Phone clients subscribe only while their Status drawer is open. The built-in status surface renders the Kandev host source only. Enabling execution metrics also adds active Docker, SSH, and Sprites `agentctl` sources to the metrics stream for separately owned consumers such as plugins; execution disk sampling uses `/`. A provider hook also exists for remote Docker, but creating that runtime currently returns a not-implemented error. Missing platform APIs, container permissions, an invalid disk path, a disconnected executor, macOS/Windows temperature support, or Windows load-average support produce unavailable samples rather than quotas. These metrics are lightweight UI observability. Set alerts, retention, CPU/memory limits, and disk quotas in the host, container platform, or external monitoring stack. +## Status bar visibility + +The status surface is off by default for each user. Open **Settings > Preferences > Appearance > Status Bar**, enable **Show status bar**, and use the page's shared **Save changes** action. The portable preference applies immediately without restarting Kandev. It shows a 24 px bottom bar on desktop and fine-pointer tablets; phones use native Status controls and an inset Status drawer. + +Turning it off removes ordinary status chrome. Host metrics remain controlled separately by **Show host metrics in status bar** and move to the existing top-bar fallback when enabled. A saved language-server **Status location** also falls back to the editor toolbar when the status surface is unavailable. Active WebSocket connectivity warnings remain reachable through the warning-only fallbacks described below. + ## WebSocket connectivity warnings -Kandev warns when its live WebSocket connection has not recovered for three seconds: yellow means the connection is unstable and reconnecting; after ten seconds the warning turns red, meaning live updates may be stale. The warning clears as soon as the connection recovers. With **App status bar** enabled, it appears in the bottom bar on desktop/tablet and through the existing Status controls on phones. When that feature is disabled, an active warning still appears beside the sidebar theme control on desktop/tablet and opens a connection-only Status drawer on phones. +Kandev warns when its live WebSocket connection has not recovered for three seconds: yellow means the connection is unstable and reconnecting; after ten seconds the warning turns red, meaning live updates may be stale. The warning clears as soon as the connection recovers. With **Show status bar** on, it appears in the bottom bar on desktop/tablet and through the existing Status controls on phones. When the preference is off, an active warning still appears beside the sidebar theme control on desktop/tablet and opens a connection-only Status drawer on phones. ## Feature toggles **Settings > System > Feature Toggles** currently exposes: - **Office mode** — experimental, medium risk, and off in the production profile by default. -- **App status bar** — stable, low risk, and off in the production profile by default. Enabling it adds the desktop/tablet bar and phone Status entry after restart; disabling it again does not stop connections, metrics collection requested by other clients, or plugins. Urgent WebSocket connectivity warnings still remain visible while the feature is off. - **Claude background prompt handoff** — experimental, high risk, and off in every profile by default. Enabling it lets Claude Code accept another prompt after its foreground yields while recognized async subagent, `run_in_background` shell, or Monitor work remains active. ACP lifecycle gaps can misclassify activity or overlap prompts; use it only for controlled testing. - **Unread divider** — a per-user setting at **Settings > General > Task Actions**. It defaults off, takes effect immediately, and controls both the Slack-style **New** divider and read-cursor updates while that user's transcript view is visible. - **Debug mode** — high risk; enables diagnostic endpoints and agent-message logging that can contain sensitive content. diff --git a/docs/public/sessions-and-review.md b/docs/public/sessions-and-review.md index d1e3276a5d..93141b7244 100644 --- a/docs/public/sessions-and-review.md +++ b/docs/public/sessions-and-review.md @@ -130,11 +130,11 @@ count approximate. For account-wide provider usage, install the [Provider Usage plugin](https://github.com/kdlbs/kandev-plugin-provider-usage), which adds a provider pill to the session top bar and can add a compact display to the global -status bar. The optional status-bar display requires **App status bar** to be -enabled under **Settings > System > Feature Toggles**; enable it and restart -Kandev for the change to take effect. The session top-bar pill remains available -on its own, while enabling App status bar also adds the global status-bar and -phone Status drawer display. Configure the plugin under **Settings > Plugins > +status surface. That surface is off by default and follows the portable **Show +status bar** preference under **Settings > Preferences > Appearance > Status Bar**; +saving applies without a restart. The session top-bar pill remains available on +its own when the preference is off. When it is on, the plugin can also appear in +the desktop/tablet bottom bar or phone Status drawer. Configure the plugin under **Settings > Plugins > Provider Usage**. Kandev hides the context ring rather than presenting impossible data when reported use exceeds the reported window. diff --git a/docs/specs/lsp-file-intelligence/spec.md b/docs/specs/lsp-file-intelligence/spec.md index e98a82ae9a..c2fc12edc6 100644 --- a/docs/specs/lsp-file-intelligence/spec.md +++ b/docs/specs/lsp-file-intelligence/spec.md @@ -1,6 +1,7 @@ --- status: building created: 2026-07-09 +updated: 2026-08-11 owner: tbd --- @@ -30,8 +31,8 @@ Users inspect and edit code inside Kandev task file tabs, but code navigation an - when the server reports no work progress, it says so instead of inventing an indexing state, percentage, or time remaining. - The LSP status location is a portable editor preference: - `toolbar` is the default and keeps the control beside the current Monaco editor's actions; - - `status_bar` moves the active Monaco editor's control and live summary into the application status bar when that feature is enabled on a fine-pointer layout; - - if the application status bar is disabled or the layout uses a coarse pointer, Kandev falls back to the editor toolbar without overwriting the saved preference; + - `status_bar` moves the active Monaco editor's control and live summary into the application status bar when **Show status bar** is on for a fine-pointer layout; + - if **Show status bar** is off or the layout uses a coarse pointer, Kandev falls back to the editor toolbar without overwriting the saved preference; - the status-bar item follows only an active, mounted Monaco text editor's session and language and is not a global dashboard of every live server; loading, binary/static, diff, CodeMirror, unsupported, and non-file panels do not expose it. - The effective status control remains disclosure-first: - fine-pointer toolbar and status-bar controls open the same anchored progress popover; @@ -233,8 +234,8 @@ No backend or task-host payload transforms are required: both WebSocket proxy ho - **GIVEN** initialize has completed and no work item is active, **WHEN** cross-file references are still missing, **THEN** the UI says the server has not reported ongoing analysis rather than labeling the condition as indexing. - **GIVEN** a fine-pointer Monaco editor, **WHEN** the user opens the LSP status control, **THEN** an anchored popover presents connection readiness, project progress, and the available lifecycle action. - **GIVEN** an LSP server reports a project-progress title or message containing a long URL, path, or identifier without ordinary break points, **WHEN** the user opens either desktop progress popover or the coarse-pointer tablet drawer, **THEN** the full text wraps within that surface without clipping, truncation, or horizontal overflow. -- **GIVEN** the saved LSP status location is `status_bar`, the application status bar is enabled, and a supported Monaco file is active on a fine-pointer layout, **WHEN** the editor renders, **THEN** the toolbar control is absent and one reorderable status-bar item shows that active file's language and live LSP summary. -- **GIVEN** the saved LSP status location is `status_bar`, **WHEN** the application status bar is disabled or the current Monaco layout uses a coarse pointer, **THEN** the toolbar control remains available and the saved `status_bar` preference is unchanged. +- **GIVEN** the saved LSP status location is `status_bar`, **Show status bar** is on, and a supported Monaco file is active on a fine-pointer layout, **WHEN** the editor renders, **THEN** the toolbar control is absent and one reorderable status-bar item shows that active file's language and live LSP summary. +- **GIVEN** the saved LSP status location is `status_bar`, **WHEN** **Show status bar** is off or the current Monaco layout uses a coarse pointer, **THEN** the toolbar control remains available and the saved `status_bar` preference is unchanged. - **GIVEN** the active panel changes from a supported Monaco file to a non-file panel or unsupported file, **WHEN** the status bar is the preferred location, **THEN** the LSP status-bar item hides rather than showing another session or language. - **GIVEN** a supported filename is routed to a loading, binary/static, diff, or CodeMirror surface, **WHEN** the status bar is the preferred location, **THEN** no LSP status-bar item or inert Start/Retry action is exposed until an actual Monaco text editor mounts. - **GIVEN** a coarse-pointer tablet Monaco editor, **WHEN** the user taps the LSP status control, **THEN** an inset bottom drawer presents the same progress and lifecycle action with a touch-sized trigger and no document-level horizontal overflow. diff --git a/docs/specs/platform/agent-runtime-availability.md b/docs/specs/platform/agent-runtime-availability.md index aed26bac92..6b48b13dbe 100644 --- a/docs/specs/platform/agent-runtime-availability.md +++ b/docs/specs/platform/agent-runtime-availability.md @@ -1,6 +1,7 @@ --- status: draft created: 2026-08-08 +updated: 2026-08-11 owner: kandev --- @@ -31,8 +32,9 @@ data. persistent, non-dismissible alert explaining that the local agent runtime stopped, agent actions and workspace tools may not update, saved data remains intact, and Kandev must restart to recover. -- The alert is independent of the optional App status bar and remains visible - when route data is empty or stale. It is not a transient toast. +- The alert is independent of the **Show status bar** Appearance preference and + remains visible when route data is empty or stale. It is not a transient + toast. - When the local restart capability is supported, the alert offers **Restart Kandev** and reuses the existing supervised restart progress flow. Otherwise it gives localized terminal or service-manager guidance. A failed restart @@ -125,7 +127,7 @@ message, and session persistence is unchanged. - **GIVEN** the runtime became unavailable before a browser connected, **WHEN** the browser loads or subscribes after WebSocket reconnect, **THEN** it receives the current unavailable snapshot and shows the same alert. -- **GIVEN** the App status bar feature is disabled, **WHEN** the runtime becomes +- **GIVEN** **Show status bar** is off, **WHEN** the runtime becomes unavailable, **THEN** the alert is still visible on every authenticated route. - **GIVEN** supervised restart is supported, **WHEN** the user chooses **Restart Kandev**, **THEN** the existing restart flow waits for a changed `boot_id` and diff --git a/docs/specs/ui/app-status-bar.md b/docs/specs/ui/app-status-bar.md index 27f7d5f7a7..0e197a7fa6 100644 --- a/docs/specs/ui/app-status-bar.md +++ b/docs/specs/ui/app-status-bar.md @@ -1,6 +1,7 @@ --- status: approved created: 2026-07-21 +updated: 2026-08-11 owner: kandev --- @@ -14,7 +15,7 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s - Non-phone viewports render one persistent **24 px**, in-flow bottom status bar in the route-content column. Where the global sidebar is visible, the bar begins at the sidebar's right edge and tracks its expanded, collapsed, and resized width; the sidebar itself continues to the bottom of the viewport. Where the sidebar is hidden, the bar fills the available app width. Desktop uses `full` density; tablet uses `compact` density. - Phone renders no persistent second bottom bar. Native route controls open one global **Status** inset bottom drawer, so it does not collide with task bottom navigation. The drawer mirrors the saved bar order vertically (saved left sequence, then saved right sequence), has a fixed header, one internal scroll body, safe-area clearance, 44 px action rows, and returns focus to its trigger. -- The production-default-off `features.appStatusBar` runtime toggle controls both general-purpose presentations. When disabled, neither the desktop/tablet bar nor ordinary phone Status entry points render. An active [WebSocket connectivity warning](ws-connectivity-warning.md) is the sole exception: it uses problem-only fallback chrome and a connection-only phone drawer without mounting the bar, metrics, or plugin contributions. Enabling or disabling the toggle requires a Kandev restart. It changes visibility only; it does not stop connections, metrics collection requested by other clients, or plugin execution. +- The portable **Show status bar** preference in Settings > Preferences > Appearance controls both general-purpose presentations and defaults off. Turning it on adds the desktop/tablet bar and ordinary phone Status entry points as soon as the shared Appearance save succeeds; no Kandev restart is required. An active [WebSocket connectivity warning](ws-connectivity-warning.md) is the sole visibility exception: it uses problem-only fallback chrome and a connection-only phone drawer without mounting the bar, metrics, or plugin contributions. The preference changes visibility only; it does not stop connections, metrics collection requested by other clients, or plugin execution. - Built-ins are limited to Kandev-owned state: - Canonical connection state and error from `state.connection.status` / `state.connection.error`, with a restrained semantic dot, the connected detail **Connected to Kandev**, accessible text, and readable failure detail. - Existing Kandev-host CPU/memory metrics, preserving enabled-metric order, formatting, thresholds, limits, and tooltips. The built-in surface does not render active-task, active-session, or executor metrics. @@ -31,8 +32,8 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s - `AppShell` owns viewport geometry: an `h-dvh` row with the sidebar beside a `min-w-0 flex-1` route/status column. That column contains a `min-h-0 flex-1` route region followed by the status surface. Shell-owned route roots use parent height and explicit local overflow rather than adding a second viewport height. - The status bar's left edge equals the sidebar layout edge after expand/collapse settles and throughout direct resize updates. Its top separator begins at that same edge; no status-bar strip or empty footer is rendered beneath the sidebar. When responsive CSS hides the sidebar, the route/status column occupies the full viewport width. -- `--app-status-bar-height` is `1.5rem` on tablet/desktop and `0` on phone. It offsets only audited desktop `position: fixed` overlays; it is not global content padding. Phone bottom navigation and phone drop targets retain `bottom: 0`. -- Exactly one general-purpose presentation mounts at once: bar on tablet/desktop, drawer contents only while the phone drawer is open. This prevents duplicate plugin effects and metrics subscriptions at breakpoints. Disabling `features.appStatusBar` mounts neither general-purpose presentation; a connection-only warning drawer may mount under the exception above. +- `--app-status-bar-height` is `1.5rem` exactly while the inline tablet/desktop bar is mounted and `0` otherwise, including when the preference is off or the phone drawer is used. It offsets only audited desktop `position: fixed` overlays; it is not global content padding. Phone bottom navigation and phone drop targets retain `bottom: 0`. +- Exactly one general-purpose presentation mounts at once: bar on tablet/desktop, drawer contents only while the phone drawer is open. This prevents duplicate plugin effects and metrics subscriptions at breakpoints. Turning off **Show status bar** mounts neither general-purpose presentation; a connection-only warning drawer may mount under the exception above. - Standard mobile headers, Home utility menu, task bottom navigation, Settings mobile menu, and Office topbar expose Status. A full-bleed plugin route (`topbar: false`) owns its chrome and must mount `AppStatusDrawerTrigger` if it wants status access. ## Plugin slots @@ -59,9 +60,9 @@ The existing setting is the first gate. If disabled, no status-surface metrics s ## Data, API, and persistence -The surface reads existing Zustand connection, active-context, user-settings, system-metrics, and feature state. `features.appStatusBar` is a default-off, restart-required runtime flag exposed through the existing Feature Toggles page; `KANDEV_FEATURES_APP_STATUS_BAR` takes precedence and locks the control when set explicitly. The E2E profile enables it only for browser coverage. The phone drawer's open state is presentation-local and is not persisted. Existing `show_in_topbar` user-setting persistence remains authoritative. +The surface reads existing Zustand connection, active-context, user-settings, and system-metrics state. The backend-owned user settings JSON is authoritative for the portable top-level `app_status_bar_enabled` field. Settings > Preferences > Appearance exposes it as **Show status bar**. An absent stored value or an initial compatibility payload that omits the field means `false`; omission in a PATCH or partial live update leaves the current value unchanged, and an explicit boolean is preserved. Every user-settings mutation atomically increments a per-user numeric revision. Boot hydration, PATCH responses, and live events carry that revision so every client ingestion path rejects older snapshots without treating wall-clock timestamps or store object identity as ordering signals. Appearance saves with only local theme or menu changes do not send an empty user-settings PATCH. The phone drawer's open state is presentation-local and is not persisted. Existing `show_in_topbar` user-setting persistence remains authoritative for metrics visibility. -The existing backend-owned user settings JSON stores the metrics display preference and portable layout: +The existing backend-owned user settings JSON stores visibility, the metrics display preference, and the portable layout: ```ts type SystemMetricsDisplay = { @@ -73,11 +74,19 @@ type AppStatusBarOrder = { left_item_ids: string[]; right_item_ids: string[]; }; + +type UserSettingsStatusSurface = { + app_status_bar_enabled: boolean; + system_metrics_display: SystemMetricsDisplay; + app_status_bar_order: AppStatusBarOrder; +}; ``` -The metrics field remains `system_metrics_display`; an absent `simplified` value means `false` so existing users retain the detailed presentation. The layout field name is `app_status_bar_order`. Omission keeps the existing value and an absent value uses the default order: connection plus left-slot registrations, flexible spacer, metrics plus right-slot registrations. Built-ins have reserved stable identities. A plugin identity is derived from its plugin ID, original slot, and zero-based registration ordinal within that plugin and slot; the serialized ID is host-owned and opaque to plugins. Temporarily unavailable identities remain stored but are not rendered, so a disabled plugin or hidden metrics cluster returns to its saved position. A newly seen identity appends to its default side. Successful PATCHes survive frontend/backend restarts and propagate through `user.settings.updated`; last successful write wins across clients. See [ADR-2026-07-21-portable-status-bar-order](../../decisions/2026-07-21-portable-status-bar-order.md). +The visibility field name is `app_status_bar_enabled` and defaults to `false` when absent, preserving the former production and development default while making the shipped surface a portable opt-in for existing and new users. The metrics field remains `system_metrics_display`; an absent `simplified` value means `false` so existing users retain the detailed presentation. The layout field name is `app_status_bar_order`. Omission keeps each existing value, and an absent order uses the default order: connection plus left-slot registrations, flexible spacer, metrics plus right-slot registrations. Built-ins have reserved stable identities. A plugin identity is derived from its plugin ID, original slot, and zero-based registration ordinal within that plugin and slot; the serialized ID is host-owned and opaque to plugins. Temporarily unavailable identities remain stored but are not rendered, so a disabled plugin or hidden metrics cluster returns to its saved position. A newly seen identity appends to its default side. Successful PATCHes survive frontend/backend restarts and propagate through `user.settings.updated`; last successful write wins across clients. See [ADR-2026-07-21-portable-status-bar-order](../../decisions/2026-07-21-portable-status-bar-order.md) and [ADR-2026-08-11-user-owned-status-bar-visibility](../../decisions/2026-08-11-user-owned-status-bar-visibility.md). -No new relational schema, endpoint, WebSocket action, plugin manifest field, or plugin protocol is added. The existing user-settings PATCH/event contracts carry the order, and the existing runtime-flags persistence and `/api/v1/features` response carry the visibility setting. +The former runtime identities `features.appStatusBar` and `KANDEV_FEATURES_APP_STATUS_BAR` are retired and never reused. Existing unknown override rows remain inert; no install-wide value is migrated into a per-user preference, and the retired environment variable has no effect. The `/api/v1/features` response no longer includes `appStatusBar`. + +The `users.settings_revision` relational column is added as an atomic per-user counter so clients can reject stale boot, HTTP, and WebSocket settings snapshots. No new table, endpoint, WebSocket action, plugin manifest field, or plugin protocol is added. The existing user-settings PATCH, boot payload, and `user.settings.updated` event carry visibility, order, and revision. The only public API addition is `registerComponent("app-status-bar-left" | "app-status-bar-right", Component)` with the exact slot props above. Plugin registration ownership, enable/disable lifecycle, and error isolation reuse the existing registry. @@ -90,6 +99,7 @@ The only public API addition is `registerComponent("app-status-bar-left" | "app- - If Status drawer closes during a metrics update or breakpoint changes, the inactive presentation unmounts and releases only its own ref-counted subscription. - Invalid, duplicate, or temporarily unavailable saved item identities never mount duplicate UI. The host normalizes active items once and retains unavailable identities for later plugin re-enable. - If an order write fails after the standard user-settings retries, the UI restores the last confirmed order and reports the save failure; it does not create a browser-storage fallback. +- If a visibility save fails, the last confirmed surface remains active and Appearance stays dirty and retryable; the frontend does not invent a browser-storage fallback. ## Accessibility @@ -105,7 +115,8 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea ## Scenarios -- **GIVEN** the feature is enabled where the global sidebar is visible, **WHEN** a route opens or the sidebar finishes expanding or collapsing, **THEN** one 24 px app status bar begins at the sidebar's right edge, ends at the viewport's right edge, and the sidebar continues to the viewport bottom without a new page scrollbar. +- **GIVEN** **Show status bar** is on where the global sidebar is visible, **WHEN** a route opens or the sidebar finishes expanding or collapsing, **THEN** one 24 px app status bar begins at the sidebar's right edge, ends at the viewport's right edge, and the sidebar continues to the viewport bottom without a new page scrollbar. +- **GIVEN** **Show status bar** has never been stored, **WHEN** user settings hydrate, **THEN** ordinary desktop/tablet status chrome and phone Status entry points remain off while active connectivity warnings retain their problem-only fallback. - **GIVEN** an expanded sidebar and visible status bar, **WHEN** the user resizes the sidebar, **THEN** the bar's left edge follows the sidebar layout edge while its right edge remains at the viewport edge. - **GIVEN** a non-phone viewport where responsive layout hides the global sidebar, **WHEN** the route opens, **THEN** the 24 px status bar spans the available viewport width. - **GIVEN** metrics preference enabled, **WHEN** a desktop/tablet status bar mounts, **THEN** existing Kandev-host metrics appear there, task/session/executor metrics do not, and no route header still renders metrics. @@ -114,9 +125,10 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea - **GIVEN** the user selects **Simplified metrics** and saves Appearance settings, **WHEN** host metrics render in the desktop/tablet status bar or pre-status-bar topbar fallback, **THEN** each enabled metric shows its icon and formatted value without a host marker or percentage meter bar, and the choice survives reload. - **GIVEN** the simplified metrics preference, **WHEN** a phone user opens Status, **THEN** the metrics row shows the same icon-and-value presentation without a host marker or percentage meter bar. - **GIVEN** metrics preference disabled, or a phone Status drawer closed, **WHEN** the app runs, **THEN** no system-metrics WebSocket subscription is held by this feature. -- **GIVEN** the feature is enabled for a phone user, **WHEN** they choose Status from a native entry point, **THEN** the drawer shows the same built-ins and plugin regions; dismissing it restores focus and leaves no persistent status bar. -- **GIVEN** an administrator disables **App status bar** in Feature Toggles and restarts Kandev, **WHEN** a route renders without a WebSocket warning, **THEN** the bar/drawer and native Status triggers are absent while the rest of the shell remains available. -- **GIVEN** App status bar is disabled, **WHEN** a WebSocket warning becomes active, **THEN** only the problem-only fallback defined by [WebSocket connectivity warning](ws-connectivity-warning.md) appears; metrics and plugin contributions remain unmounted. +- **GIVEN** **Show status bar** is on for a phone user, **WHEN** they choose Status from a native entry point, **THEN** the drawer shows the same built-ins and plugin regions; dismissing it restores focus and leaves no persistent status bar. +- **GIVEN** a user turns off **Show status bar** and saves Appearance, **WHEN** the save succeeds, **THEN** the bar/drawer and ordinary native Status triggers disappear immediately, remain absent after reload and on another client, and no restart is required. +- **GIVEN** **Show status bar** is off, **WHEN** the user turns it on and saves, **THEN** the correct responsive presentation becomes available immediately without changing metrics or saved item order. +- **GIVEN** **Show status bar** is off, **WHEN** a WebSocket warning becomes active, **THEN** only the problem-only fallback defined by [WebSocket connectivity warning](ws-connectivity-warning.md) appears; metrics and plugin contributions remain unmounted. - **GIVEN** a plugin registered for either status slot, **WHEN** it enables or disables, **THEN** its contribution appears or disappears without reload in the active presentation. A failed contribution does not suppress a following healthy one after registrations change. - **GIVEN** a desktop/tablet bar, **WHEN** the user holds Cmd/Ctrl and mouse-drags a built-in or plugin contribution across another item or the spacer, **THEN** the item moves horizontally, normal click activation is suppressed only after a drag begins, and the new side/order survives reload and backend restart. - **GIVEN** a modified mouse press begins on status text, **WHEN** the pointer crosses the drag threshold, **THEN** the item enters its dragging state without selecting text in the bar. @@ -132,7 +144,9 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea - Per-metric label controls, per-breakpoint metrics styles, or changes to which metrics are collected. - Broad global fixed-position padding; only audited desktop overlays receive the height offset. - Changing the sidebar's existing snap-and-overlay animation behavior or adding a second sidebar-width state source. +- An install-wide status-bar kill switch, runtime feature toggle, environment override, or migration from the former install-wide value into individual user preferences. -## Implementation plan +## Implementation plans -[App status bar plan](../../plans/app-status-bar/plan.md) +- [Original App status bar plan](../../plans/app-status-bar/plan.md) +- [Status bar Appearance setting promotion plan](../../plans/app-status-bar-appearance-setting/plan.md) diff --git a/docs/specs/ui/ws-connectivity-warning.md b/docs/specs/ui/ws-connectivity-warning.md index ef4dc021b5..3ef8f25b19 100644 --- a/docs/specs/ui/ws-connectivity-warning.md +++ b/docs/specs/ui/ws-connectivity-warning.md @@ -1,6 +1,7 @@ --- status: approved created: 2026-07-30 +updated: 2026-08-11 owner: kandev --- @@ -28,15 +29,15 @@ showing permanent healthy-state chrome or flashing for harmless reconnects. - Color is never the only signal. The active surface exposes the matching detail as an accessible status name and through hover/focus on fine-pointer layouts or tap on phone. - Desktop and tablet show exactly one warning: - - When **App status bar** is enabled, its built-in connection item is problem-only: absent while + - When **Show status bar** is on, its built-in connection item is problem-only: absent while healthy or in grace, yellow while unstable, and red while lost. - - When **App status bar** is disabled, a problem-only connection indicator appears in the persistent + - When **Show status bar** is off, a problem-only connection indicator appears in the persistent app-sidebar footer beside the theme control. The fallback is absent while healthy or in grace. - Phone uses its existing native Status paths rather than adding a persistent bar or overlay: - Existing Status entry points receive yellow/red warning treatment. - Route chrome that leads to a nested Status action, such as the Home or Settings menu, carries the same warning treatment so the issue is visible before opening the menu. - - When **App status bar** is disabled, these paths appear only during an active warning and open a + - When **Show status bar** is off, these paths appear only during an active warning and open a connection-only Status drawer. Plugin contributions and metrics stay disabled. - The warning does not alter reconnect attempts, WebSocket subscriptions, queued requests, or last-known application data. @@ -57,8 +58,9 @@ interval; no outage timestamp or warning history persists. - If the reconnect client exhausts its attempts, the warning remains red until a later `connected` transition or page reload. It does not claim that retries are still running. -- If the optional App status surface is disabled, an active warning bypasses only its visibility - gate. It does not mount metrics, plugin status contributions, or a desktop/tablet status bar. +- If the App status surface is hidden by the Appearance preference, an active warning bypasses + only its visibility gate. It does not mount metrics, plugin status contributions, or a + desktop/tablet status bar. - If a responsive presentation changes during an outage, the current severity moves to the new canonical surface without resetting its offline interval or rendering duplicate warnings. - If the warning timer owner unmounts, it cancels pending thresholds and does not update stale @@ -91,12 +93,12 @@ interval; no outage timestamp or warning history persists. the warning disappears immediately and a later outage receives a fresh grace interval. - **GIVEN** reconnect attempts cycle through non-connected raw statuses, **WHEN** the continuous outage crosses a threshold, **THEN** the threshold is measured from the original disconnect. -- **GIVEN** App status bar enabled on desktop or tablet, **WHEN** connection severity changes, +- **GIVEN** **Show status bar** is on for desktop or tablet, **WHEN** connection severity changes, **THEN** only its built-in connection item reflects the warning and no sidebar fallback appears. -- **GIVEN** App status bar disabled on desktop or tablet, **WHEN** connection severity becomes +- **GIVEN** **Show status bar** is off for desktop or tablet, **WHEN** connection severity becomes unstable or lost, **THEN** one warning indicator appears beside the sidebar theme control and exposes the matching detail on hover and focus. -- **GIVEN** App status bar disabled on phone, **WHEN** connection severity becomes unstable or +- **GIVEN** **Show status bar** is off on phone, **WHEN** connection severity becomes unstable or lost, **THEN** persistent route chrome signals the issue and its Status path opens a connection-only, touch-sized drawer without metrics or plugin contributions. - **GIVEN** a phone warning, **WHEN** the user opens and dismisses Status, **THEN** the connection diff --git a/scripts/validate-public-docs.mjs b/scripts/validate-public-docs.mjs index ef1c365e63..5ea0d8f0b2 100644 --- a/scripts/validate-public-docs.mjs +++ b/scripts/validate-public-docs.mjs @@ -659,7 +659,7 @@ async function collectSettingsRoutes(root) { "utf8", ); const routeTable = source.match( - /const SETTINGS_ROUTES[\s\S]*?\n};[\s\S]*?export function SettingsRoutes/, + /const SETTINGS_ROUTES[\s\S]*?\n};(?=[\s\S]*?\nexport (?:const SETTINGS_ROUTE_PATHS|function SettingsRoutes))/, )?.[0]; if (!routeTable) { throw new Error("could not locate SETTINGS_ROUTES in settings-routes.tsx"); diff --git a/scripts/validate-public-docs.test.mjs b/scripts/validate-public-docs.test.mjs index 8f74270d98..a87421d8a0 100644 --- a/scripts/validate-public-docs.test.mjs +++ b/scripts/validate-public-docs.test.mjs @@ -54,7 +54,7 @@ async function createCoverageRepo({ "docs/public/index.md": validPage, "apps/web/src/settings-routes.tsx": `const SETTINGS_ROUTES = {\n${settingsRoutes .map((route) => ` "${route}": () => null,`) - .join("\n")}\n};\n\nexport function SettingsRoutes() {}`, + .join("\n")}\n};\n\nexport const SETTINGS_ROUTE_PATHS = new Set(Object.keys(SETTINGS_ROUTES));\n\nexport function SettingsRoutes() {}`, "apps/web/src/settings-routes.test.ts": "// settings route coverage", "apps/backend/internal/mcp/server/server.go": mcpTools .map((tool) => `mcp.NewTool("${tool}")`)