diff --git a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js index 914736803f..425b1da8e5 100644 --- a/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js +++ b/apps/backend/cmd/plugin-fixture/fixture-package/ui/bundle.js @@ -64,6 +64,19 @@ return jsx("span", { id: "hello-main-top-bar" }, "Hello " + slotProps.currentPage); } + function StatusSlot(props) { + var slotProps = props.slotProps || {}; + var id = slotProps.placement === "left" ? "hello-status-left" : "hello-status-right"; + return jsx( + "span", + { id: id }, + "Hello status " + + String(slotProps.presentation || "unknown") + + " " + + String(slotProps.activeTaskId || "no-task"), + ); + } + registry.registerNavItem({ id: "e2e-hello", label: "Hello E2E", @@ -73,6 +86,8 @@ registry.registerRoute("/plugins/e2e-hello", PluginPage); registry.registerComponent("task-sidebar", SidebarSlot); registry.registerComponent("main-top-bar", MainTopBarSlot); + registry.registerComponent("app-status-bar-left", StatusSlot); + registry.registerComponent("app-status-bar-right", StatusSlot); registry.registerWsHandler("task.created", function () { incrementCount(); }); diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 042f6bc6f5..3e10c4202a 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -474,6 +474,7 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "terminalFontSize": nullInt(settings.TerminalFontSize), "changesPanelLayout": changesPanelLayout(settings.ChangesPanelLayout), "systemMetricsDisplay": map[string]any{"showInTopbar": settings.SystemMetricsDisplay.ShowInTopbar}, + "appStatusBarOrder": mapAppStatusBarOrder(settings.AppStatusBarOrder), "voiceMode": mapVoiceMode(settings.VoiceMode), "loaded": true, } @@ -603,6 +604,13 @@ func mapSidebarTaskPrefs(prefs usermodels.SidebarTaskPrefs) map[string]any { } } +func mapAppStatusBarOrder(order usermodels.AppStatusBarOrder) map[string]any { + return map[string]any{ + "leftItemIds": stringSlice(order.LeftItemIDs), + "rightItemIds": stringSlice(order.RightItemIDs), + } +} + func mapTaskCreateLastUsed(value usermodels.TaskCreateLastUsed) map[string]any { return map[string]any{ "repositoryId": nullString(value.RepositoryID), diff --git a/apps/backend/internal/backendapp/boot_state_user_settings_test.go b/apps/backend/internal/backendapp/boot_state_user_settings_test.go index cc09f1b2a2..671c8e4367 100644 --- a/apps/backend/internal/backendapp/boot_state_user_settings_test.go +++ b/apps/backend/internal/backendapp/boot_state_user_settings_test.go @@ -28,3 +28,20 @@ func TestMapUserSettingsStateIncludesNormalizedMCPTaskAgentProfileDefault(t *tes t.Fatalf("mcpTaskAgentProfileDefault = %#v, want current_task", state["mcpTaskAgentProfileDefault"]) } } + +func TestMapUserSettingsStateIncludesAppStatusBarOrder(t *testing.T) { + state := mapUserSettingsState(userdto.UserSettingsResponse{ + Settings: userdto.UserSettingsDTO{AppStatusBarOrder: usermodels.AppStatusBarOrder{ + LeftItemIDs: []string{"left"}, + RightItemIDs: []string{"right"}, + }}, + }, "workspace-1") + + got, ok := state["appStatusBarOrder"].(map[string]any) + if !ok { + t.Fatalf("appStatusBarOrder = %#v, want map", state["appStatusBarOrder"]) + } + if left, ok := got["leftItemIds"].([]string); !ok || len(left) != 1 || left[0] != "left" { + t.Fatalf("leftItemIds = %#v, want [left]", got["leftItemIds"]) + } +} diff --git a/apps/backend/internal/common/config/config.go b/apps/backend/internal/common/config/config.go index bab8e95131..9b7954a5c5 100644 --- a/apps/backend/internal/common/config/config.go +++ b/apps/backend/internal/common/config/config.go @@ -343,6 +343,11 @@ type FeaturesConfig struct { // construction, HTTP/WS route registration, and frontend nav/route // visibility. Plugins bool `mapstructure:"plugins" json:"plugins"` + + // 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"` } // LoggingConfig holds logging configuration. diff --git a/apps/backend/internal/common/config/config_test.go b/apps/backend/internal/common/config/config_test.go index 33aaa39edb..156dc301c7 100644 --- a/apps/backend/internal/common/config/config_test.go +++ b/apps/backend/internal/common/config/config_test.go @@ -144,19 +144,15 @@ func TestValidate_PostgresSSLMode(t *testing.T) { }) } -// TestFeatures_DefaultOff pins the production-safety invariant: every -// feature flag in FeaturesConfig is false unless the deployment explicitly -// sets the matching env var. A regression that flips a default to true -// would ship an in-progress feature to users on the next release. -// See docs/decisions/0007-runtime-feature-flags.md. -func TestFeatures_DefaultOff(t *testing.T) { - // Force a clean env so KANDEV_FEATURES_* and profile-selector vars - // from the host shell can't bleed in and turn a default-off check - // into a default-on accident. Clearing the profile selectors ensures - // DetectEnvironment returns prod, so FeatureFlagDefaults uses the - // prod value ("false") rather than the dev value ("true"). +// TestFeatures_ProductionDefaults pins the production policy: in-progress +// features remain off, while the shipped app status bar remains on unless a +// deployment or saved runtime override explicitly disables it. +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", "") t.Setenv("KANDEV_FEATURES_PLUGINS", "") + unsetEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") t.Setenv("KANDEV_DEBUG_DEV_MODE", "") t.Setenv("KANDEV_DEBUG_PPROF_ENABLED", "") t.Setenv("KANDEV_E2E_MOCK", "") @@ -172,6 +168,9 @@ func TestFeatures_DefaultOff(t *testing.T) { if cfg.Features.Plugins { t.Errorf("Features.Plugins = 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)") + } } // TestFeatures_OfficeEnabledByEnv proves the documented opt-in path: @@ -206,6 +205,31 @@ func TestFeatures_PluginsEnabledByEnv(t *testing.T) { } } +func TestFeatures_AppStatusBarDisabledByEnv(t *testing.T) { + t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "false") + + 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)") + } +} + +func unsetEnv(t *testing.T, name string) { + t.Helper() + value, set := os.LookupEnv(name) + _ = os.Unsetenv(name) + t.Cleanup(func() { + if set { + _ = os.Setenv(name, value) + return + } + _ = os.Unsetenv(name) + }) +} + func TestServerHostFromEnv(t *testing.T) { t.Setenv("KANDEV_SERVER_HOST", "127.0.0.1") @@ -375,13 +399,13 @@ func TestServerHostsFromConfigWhenHostUnset(t *testing.T) { // without a tag) would surface as a capitalized JSON key and break the // frontend's case-sensitive read in apps/web/app/actions/features.ts. func TestFeaturesConfig_JSONShape(t *testing.T) { - cfg := FeaturesConfig{Office: true, Plugins: true} + cfg := FeaturesConfig{Office: true, Plugins: true, AppStatusBar: true} raw, err := json.Marshal(cfg) if err != nil { t.Fatalf("json.Marshal: %v", err) } got := string(raw) - want := `{"office":true,"plugins":true}` + want := `{"office":true,"plugins":true,"appStatusBar":true}` if got != want { t.Errorf("FeaturesConfig JSON = %s; want %s — missing or wrong `json:` struct tag", got, want) } diff --git a/apps/backend/internal/profiles/profiles.yaml b/apps/backend/internal/profiles/profiles.yaml index ae35f4c606..4136a47ff5 100644 --- a/apps/backend/internal/profiles/profiles.yaml +++ b/apps/backend/internal/profiles/profiles.yaml @@ -50,6 +50,13 @@ 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" + # ── Mock providers ──────────────────────────────────────────────── # Swap real third-party services for in-memory fakes. Production # leaves all of these unset (real APIs). Dev opts into the mock agent diff --git a/apps/backend/internal/profiles/profiles_test.go b/apps/backend/internal/profiles/profiles_test.go index 2aadc9f0d3..5ac8736bba 100644 --- a/apps/backend/internal/profiles/profiles_test.go +++ b/apps/backend/internal/profiles/profiles_test.go @@ -8,8 +8,8 @@ import ( // TestApplyProfile_DefaultsToProd pins the production-safety // invariant: with no profile-selector env var set, ApplyProfile picks -// prod and Office stays off. A regression that flips the default would -// surface here as a failing test, not as an unintended release. +// prod and optional product surfaces stay off. A regression that flips the +// default would surface here as a failing test, not as an unintended release. func TestApplyProfile_DefaultsToProd(t *testing.T) { clearProfileSelectors(t) clearProfilesYAMLVars(t) @@ -21,11 +21,9 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if env != EnvProd { t.Errorf("env = %q, want %q (no selector env vars set)", env, EnvProd) } - // prod values for the office and plugins flags are "false"; everything - // else in the prod column is "". Only those two should have been - // written. - if count != 2 { - t.Errorf("ApplyProfile wrote %d vars in prod; want 2 (KANDEV_FEATURES_OFFICE=false, KANDEV_FEATURES_PLUGINS=false)", count) + // Prod writes each registered feature flag with its safe shipped default. + if count != 3 { + t.Errorf("ApplyProfile wrote %d vars in prod; want 3", count) } if v := os.Getenv("KANDEV_FEATURES_OFFICE"); v != "false" { t.Errorf("KANDEV_FEATURES_OFFICE = %q after prod ApplyProfile; want %q", v, "false") @@ -33,12 +31,14 @@ func TestApplyProfile_DefaultsToProd(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_PLUGINS"); v != "false" { t.Errorf("KANDEV_FEATURES_PLUGINS = %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") + } } -// TestApplyProfile_DevTurnsOnFeatures verifies that selecting the dev -// profile flips the in-progress features on. This is what `make dev` -// relies on once apps/cli/src/dev.ts stops setting these by hand. -func TestApplyProfile_DevTurnsOnFeatures(t *testing.T) { +// TestApplyProfile_DevUsesDevelopmentDefaults verifies the mixed dev profile: +// active development tools turn on while the user-facing status bar stays opt-in. +func TestApplyProfile_DevUsesDevelopmentDefaults(t *testing.T) { clearProfileSelectors(t) clearProfilesYAMLVars(t) t.Setenv("KANDEV_DEBUG_DEV_MODE", "true") @@ -56,6 +56,9 @@ func TestApplyProfile_DevTurnsOnFeatures(t *testing.T) { if v := os.Getenv("KANDEV_FEATURES_PLUGINS"); v != "true" { t.Errorf("KANDEV_FEATURES_PLUGINS = %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") + } } // TestApplyProfile_E2EWinsOverDev checks the documented detection @@ -140,6 +143,9 @@ func TestFeatureFlagDefaults_LowercasesShortName(t *testing.T) { if _, ok := defaults["plugins"]; !ok { t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "plugins", defaults) } + if _, ok := defaults["app_status_bar"]; !ok { + t.Errorf("FeatureFlagDefaults missing %q key; got %#v", "app_status_bar", defaults) + } } func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { @@ -167,7 +173,7 @@ func TestMarkApplied_OnlyAllowsKnownDerivedEnvVars(t *testing.T) { // hell out of whoever debugs it. func TestProfilesYAML_ContainsRequiredSections(t *testing.T) { yaml := string(ProfilesYAML()) - for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", "KANDEV_FEATURES_PLUGINS:"} { + for _, section := range []string{"features:", "mocks:", "debug:", "KANDEV_FEATURES_OFFICE:", "KANDEV_FEATURES_PLUGINS:", "KANDEV_FEATURES_APP_STATUS_BAR:"} { if !strings.Contains(yaml, section) { t.Errorf("embedded profiles.yaml missing section/key %q; embed broken or file truncated", section) } diff --git a/apps/backend/internal/runtimeflags/config.go b/apps/backend/internal/runtimeflags/config.go index a92c22d036..fe0d2924c5 100644 --- a/apps/backend/internal/runtimeflags/config.go +++ b/apps/backend/internal/runtimeflags/config.go @@ -8,16 +8,30 @@ import ( "github.com/kandev/kandev/internal/profiles" ) +const ( + featureOfficeKey = "features.office" + featurePluginsKey = "features.plugins" + featureAppStatusBarKey = "features.appStatusBar" + debugDevModeKey = "debug.devMode" + envFeaturesOffice = "KANDEV_FEATURES_OFFICE" + envFeaturesPlugins = "KANDEV_FEATURES_PLUGINS" + envFeaturesAppStatusBar = "KANDEV_FEATURES_APP_STATUS_BAR" + envDebugDevMode = "KANDEV_DEBUG_DEV_MODE" + envDebugPprofEnabled = "KANDEV_DEBUG_PPROF_ENABLED" + envDebugAgentMessages = "KANDEV_DEBUG_AGENT_MESSAGES" +) + func OptionsFromConfig(cfg *config.Config) Options { return Options{ DefaultValues: ValuesFromConfig(cfg), RuntimeValues: ValuesFromConfig(cfg), EnvValues: map[string]bool{ - "KANDEV_FEATURES_OFFICE": isTruthy(os.Getenv("KANDEV_FEATURES_OFFICE")), - "KANDEV_FEATURES_PLUGINS": isTruthy(os.Getenv("KANDEV_FEATURES_PLUGINS")), - "KANDEV_DEBUG_DEV_MODE": isTruthy(os.Getenv("KANDEV_DEBUG_DEV_MODE")), - "KANDEV_DEBUG_PPROF_ENABLED": isTruthy(os.Getenv("KANDEV_DEBUG_PPROF_ENABLED")), - "KANDEV_DEBUG_AGENT_MESSAGES": isTruthy(os.Getenv("KANDEV_DEBUG_AGENT_MESSAGES")), + envFeaturesOffice: isTruthy(os.Getenv(envFeaturesOffice)), + envFeaturesPlugins: isTruthy(os.Getenv(envFeaturesPlugins)), + envFeaturesAppStatusBar: isTruthy(os.Getenv(envFeaturesAppStatusBar)), + envDebugDevMode: isTruthy(os.Getenv(envDebugDevMode)), + envDebugPprofEnabled: isTruthy(os.Getenv(envDebugPprofEnabled)), + envDebugAgentMessages: isTruthy(os.Getenv(envDebugAgentMessages)), }, IsExplicitEnv: func(name string) bool { _, ok := os.LookupEnv(name) @@ -29,28 +43,31 @@ func OptionsFromConfig(cfg *config.Config) Options { func ValuesFromConfig(cfg *config.Config) map[string]bool { debugEnabled := cfg.Debug.DevMode || cfg.Debug.PprofEnabled return map[string]bool{ - "features.office": cfg.Features.Office, - "features.plugins": cfg.Features.Plugins, - "debug.devMode": debugEnabled, + featureOfficeKey: cfg.Features.Office, + featurePluginsKey: cfg.Features.Plugins, + featureAppStatusBarKey: cfg.Features.AppStatusBar, + debugDevModeKey: debugEnabled, } } func ApplyStatesToConfig(cfg *config.Config, states []RuntimeFlagState) { for _, state := range states { switch state.Key { - case "features.office": + case featureOfficeKey: cfg.Features.Office = state.EffectiveValue - case "features.plugins": + case featurePluginsKey: cfg.Features.Plugins = state.EffectiveValue - case "debug.devMode": + case featureAppStatusBarKey: + cfg.Features.AppStatusBar = state.EffectiveValue + case debugDevModeKey: cfg.Debug.DevMode = state.EffectiveValue cfg.Debug.PprofEnabled = state.EffectiveValue if state.EffectiveValue { - setIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES", "true") - setIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED", "true") + setIfNotExplicit(envDebugAgentMessages, "true") + setIfNotExplicit(envDebugPprofEnabled, "true") } else { - unsetIfNotExplicit("KANDEV_DEBUG_AGENT_MESSAGES") - unsetIfNotExplicit("KANDEV_DEBUG_PPROF_ENABLED") + unsetIfNotExplicit(envDebugAgentMessages) + unsetIfNotExplicit(envDebugPprofEnabled) } } } diff --git a/apps/backend/internal/runtimeflags/config_test.go b/apps/backend/internal/runtimeflags/config_test.go index 35790382ad..c17cfdaa16 100644 --- a/apps/backend/internal/runtimeflags/config_test.go +++ b/apps/backend/internal/runtimeflags/config_test.go @@ -69,6 +69,17 @@ func TestOptionsFromConfigParsesUppercaseTruthyEnvForPlugins(t *testing.T) { } } +func TestOptionsFromConfigParsesUppercaseTruthyEnvForAppStatusBar(t *testing.T) { + preserveEnv(t, "KANDEV_FEATURES_APP_STATUS_BAR") + t.Setenv("KANDEV_FEATURES_APP_STATUS_BAR", "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") + } +} + func TestValuesFromConfigIncludesPlugins(t *testing.T) { cfg := &config.Config{} cfg.Features.Plugins = true @@ -92,6 +103,28 @@ func TestApplyStatesToConfigSetsPlugins(t *testing.T) { } } +func TestApplyStatesToConfigSetsAppStatusBar(t *testing.T) { + cfg := &config.Config{Features: config.FeaturesConfig{AppStatusBar: true}} + ApplyStatesToConfig(cfg, []RuntimeFlagState{{ + Key: "features.appStatusBar", + EffectiveValue: false, + }}) + + 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") + } +} + func TestApplyStatesToConfigMarksImpliedDebugEnvAsApplied(t *testing.T) { for _, name := range []string{ "KANDEV_DEBUG_PPROF_ENABLED", diff --git a/apps/backend/internal/runtimeflags/registry.go b/apps/backend/internal/runtimeflags/registry.go index 4c7bd1d146..fc68eedb2f 100644 --- a/apps/backend/internal/runtimeflags/registry.go +++ b/apps/backend/internal/runtimeflags/registry.go @@ -2,8 +2,8 @@ package runtimeflags var definitions = []RuntimeFlagDefinition{ { - Key: "features.office", - EnvVar: "KANDEV_FEATURES_OFFICE", + Key: featureOfficeKey, + EnvVar: envFeaturesOffice, Kind: KindFeature, Label: "Office mode", Description: "Enables autonomous agent office workflows and related settings.", @@ -15,8 +15,8 @@ var definitions = []RuntimeFlagDefinition{ Mutable: true, }, { - Key: "features.plugins", - EnvVar: "KANDEV_FEATURES_PLUGINS", + Key: featurePluginsKey, + EnvVar: envFeaturesPlugins, Kind: KindFeature, Label: "Plugins", Description: "Enables the extensible plugin system and related settings.", @@ -29,8 +29,21 @@ var definitions = []RuntimeFlagDefinition{ Mutable: true, }, { - Key: "debug.devMode", - EnvVar: "KANDEV_DEBUG_DEV_MODE", + Key: featureAppStatusBarKey, + EnvVar: envFeaturesAppStatusBar, + 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, + }, + { + Key: debugDevModeKey, + EnvVar: envDebugDevMode, Kind: KindDebug, Label: "Debug mode", Description: "Enables local diagnostic endpoints and agent message debug logs for troubleshooting backend, agent, and tool-call behavior.", @@ -41,8 +54,8 @@ var definitions = []RuntimeFlagDefinition{ RestartRequired: true, Mutable: true, ImpliedEnvVars: []string{ - "KANDEV_DEBUG_PPROF_ENABLED", - "KANDEV_DEBUG_AGENT_MESSAGES", + envDebugPprofEnabled, + envDebugAgentMessages, }, }, } diff --git a/apps/backend/internal/runtimeflags/registry_test.go b/apps/backend/internal/runtimeflags/registry_test.go index 45de926e79..36f0f3e7e8 100644 --- a/apps/backend/internal/runtimeflags/registry_test.go +++ b/apps/backend/internal/runtimeflags/registry_test.go @@ -40,6 +40,19 @@ func TestDefinitionsIncludePluginsExperimentalMetadata(t *testing.T) { } } +func TestDefinitionsIncludeAppStatusBarMetadata(t *testing.T) { + def, ok := DefinitionByKey("features.appStatusBar") + if !ok { + t.Fatal("features.appStatusBar definition missing") + } + if def.EnvVar != "KANDEV_FEATURES_APP_STATUS_BAR" { + t.Fatalf("EnvVar = %q, want KANDEV_FEATURES_APP_STATUS_BAR", def.EnvVar) + } + if !def.RestartRequired { + t.Fatal("RestartRequired = false, want true") + } +} + func TestDefinitionsExposeSingleUserFacingDebugToggle(t *testing.T) { def, ok := DefinitionByKey("debug.devMode") if !ok { diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index 4a128fe74a..27a28cb341 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -82,6 +82,7 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS TerminalFontSize: req.TerminalFontSize, ChangesPanelLayout: req.ChangesPanelLayout, SystemMetricsDisplay: req.SystemMetricsDisplay, + AppStatusBarOrder: req.AppStatusBarOrder, VoiceMode: req.VoiceMode, }) if err != nil { diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index 7a208c9eeb..0796eab1c4 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -54,6 +54,7 @@ type UserSettingsDTO struct { TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` SystemMetricsDisplay models.SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode models.VoiceModeSettings `json:"voice_mode"` UpdatedAt string `json:"updated_at"` } @@ -112,6 +113,7 @@ type UpdateUserSettingsRequest struct { TerminalFontSize *int `json:"terminal_font_size,omitempty"` ChangesPanelLayout *string `json:"changes_panel_layout,omitempty"` SystemMetricsDisplay *models.SystemMetricsDisplaySettings `json:"system_metrics_display,omitempty"` + AppStatusBarOrder *models.AppStatusBarOrder `json:"app_status_bar_order,omitempty"` VoiceMode *models.VoiceModeSettings `json:"voice_mode,omitempty"` } @@ -226,6 +228,7 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { TerminalFontSize: settings.TerminalFontSize, ChangesPanelLayout: settings.ChangesPanelLayout, SystemMetricsDisplay: settings.SystemMetricsDisplay, + AppStatusBarOrder: settings.AppStatusBarOrder, VoiceMode: settings.VoiceMode, 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 f3915c9c26..db12a45c07 100644 --- a/apps/backend/internal/user/dto/dto_test.go +++ b/apps/backend/internal/user/dto/dto_test.go @@ -2,11 +2,43 @@ package dto import ( "encoding/json" + "reflect" "testing" "github.com/kandev/kandev/internal/user/models" ) +func TestAppStatusBarOrderDTOAndPatchSemantics(t *testing.T) { + want := models.AppStatusBarOrder{ + LeftItemIDs: []string{"builtin:connection", "plugin:left"}, + RightItemIDs: []string{"builtin:metrics"}, + } + got := FromUserSettings(&models.UserSettings{AppStatusBarOrder: want}).AppStatusBarOrder + if !reflect.DeepEqual(got, want) { + t.Fatalf("AppStatusBarOrder = %#v, want %#v", got, want) + } + + t.Run("omitted value stays nil", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{}`), &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.AppStatusBarOrder != nil { + t.Fatalf("AppStatusBarOrder = %#v, want nil", req.AppStatusBarOrder) + } + }) + + t.Run("explicit replacement is retained", func(t *testing.T) { + var req UpdateUserSettingsRequest + if err := json.Unmarshal([]byte(`{"app_status_bar_order":{"left_item_ids":["left"],"right_item_ids":[]}}`), &req); err != nil { + t.Fatalf("decode request: %v", err) + } + if req.AppStatusBarOrder == nil || !reflect.DeepEqual(req.AppStatusBarOrder.LeftItemIDs, []string{"left"}) { + t.Fatalf("AppStatusBarOrder = %#v, want explicit replacement", req.AppStatusBarOrder) + } + }) +} + func TestFromUserSettingsIncludesArchiveConfirmation(t *testing.T) { for _, want := range []bool{true, false} { dto := FromUserSettings(&models.UserSettings{ConfirmTaskArchive: want}) diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 60c62615c6..eb321c4300 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -64,6 +64,7 @@ type UserSettings struct { TerminalFontSize int `json:"terminal_font_size"` ChangesPanelLayout string `json:"changes_panel_layout"` // "flat" | "tree" SystemMetricsDisplay SystemMetricsDisplaySettings `json:"system_metrics_display"` + AppStatusBarOrder AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode VoiceModeSettings `json:"voice_mode"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -73,6 +74,11 @@ type SystemMetricsDisplaySettings struct { ShowInTopbar bool `json:"show_in_topbar"` } +type AppStatusBarOrder struct { + LeftItemIDs []string `json:"left_item_ids"` + RightItemIDs []string `json:"right_item_ids"` +} + // VoiceModeSettings is the per-user configuration surface for the chat // voice-input feature. Stored as a nested JSON object inside the `users.settings` // blob — adding fields here does not require a schema migration. diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index 9e04ae486e..7c02f3a333 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -73,6 +73,7 @@ type UpdateUserSettingsRequest struct { TerminalFontSize *int ChangesPanelLayout *string SystemMetricsDisplay *models.SystemMetricsDisplaySettings + AppStatusBarOrder *models.AppStatusBarOrder VoiceMode *models.VoiceModeSettings } @@ -248,6 +249,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.SystemMetricsDisplay != nil { settings.SystemMetricsDisplay = *req.SystemMetricsDisplay } + if req.AppStatusBarOrder != nil { + settings.AppStatusBarOrder = *req.AppStatusBarOrder + } if req.TerminalFontFamily != nil { settings.TerminalFontFamily = strings.TrimSpace(*req.TerminalFontFamily) } @@ -607,6 +611,7 @@ 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_order": settings.AppStatusBarOrder, "voice_mode": settings.VoiceMode, "updated_at": settings.UpdatedAt.Format(time.RFC3339), } diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index a2dfbea98b..eb85405902 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -140,6 +140,36 @@ func TestApplyBasicSettings_ConfirmTaskArchive(t *testing.T) { }) } +func TestApplyBasicSettingsAppStatusBarOrder(t *testing.T) { + saved := models.AppStatusBarOrder{ + LeftItemIDs: []string{"builtin:connection"}, + RightItemIDs: []string{"builtin:metrics"}, + } + t.Run("omission preserves saved order", func(t *testing.T) { + settings := &models.UserSettings{AppStatusBarOrder: saved} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{}); err != nil { + t.Fatalf("apply settings: %v", err) + } + if fmt.Sprint(settings.AppStatusBarOrder) != fmt.Sprint(saved) { + t.Fatalf("AppStatusBarOrder = %#v, want %#v", settings.AppStatusBarOrder, saved) + } + }) + + t.Run("explicit value replaces saved order", func(t *testing.T) { + next := models.AppStatusBarOrder{ + LeftItemIDs: []string{"builtin:metrics"}, + RightItemIDs: []string{"builtin:connection"}, + } + settings := &models.UserSettings{AppStatusBarOrder: saved} + if err := applyBasicSettings(settings, &UpdateUserSettingsRequest{AppStatusBarOrder: &next}); err != nil { + t.Fatalf("apply settings: %v", err) + } + if fmt.Sprint(settings.AppStatusBarOrder) != fmt.Sprint(next) { + t.Fatalf("AppStatusBarOrder = %#v, want %#v", settings.AppStatusBarOrder, next) + } + }) +} + func TestApplyBasicSettingsMCPTaskAgentProfileDefault(t *testing.T) { t.Run("omission preserves saved value", func(t *testing.T) { settings := &models.UserSettings{MCPTaskAgentProfileDefault: models.MCPTaskAgentProfileDefaultWorkspaceDefault} @@ -856,6 +886,28 @@ func TestPublishUserSettingsEventIncludesNormalizedMCPTaskAgentProfileDefault(t } } +func TestPublishUserSettingsEventIncludesAppStatusBarOrder(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) + want := models.AppStatusBarOrder{ + LeftItemIDs: []string{"left"}, + RightItemIDs: []string{"right"}, + } + svc.publishUserSettingsEvent(context.Background(), &models.UserSettings{AppStatusBarOrder: want}) + + 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_order"].(models.AppStatusBarOrder); !ok || fmt.Sprint(got) != fmt.Sprint(want) { + t.Fatalf("app_status_bar_order = %#v, want %#v", eventData["app_status_bar_order"], want) + } +} + func TestUpdateUserSettingsRejectsInvalidMCPTaskAgentProfileDefaultWithoutPersisting(t *testing.T) { log, err := logger.NewFromZap(zap.NewNop()) if err != nil { diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 610fb97064..f60346cf9e 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -363,6 +363,7 @@ 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_order": normalizeAppStatusBarOrder(settings.AppStatusBarOrder), "voice_mode": settings.VoiceMode, }) } @@ -463,6 +464,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.ChangesPanelLayout = "tree" settings.SidebarViews = []models.SidebarView{} settings.SidebarTaskPrefs = normalizeSidebarTaskPrefs(models.SidebarTaskPrefs{}) + settings.AppStatusBarOrder = normalizeAppStatusBarOrder(models.AppStatusBarOrder{}) settings.VoiceMode = defaultVoiceModeSettings() return settings, nil } @@ -505,6 +507,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"` + AppStatusBarOrder models.AppStatusBarOrder `json:"app_status_bar_order"` VoiceMode *storedVoiceMode `json:"voice_mode"` } if err := json.Unmarshal([]byte(settingsRaw), &payload); err != nil { @@ -584,6 +587,7 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.TerminalFontSize = payload.TerminalFontSize settings.VoiceMode = mergeVoiceModeDefaults(payload.VoiceMode) settings.SystemMetricsDisplay = payload.SystemMetricsDisplay + settings.AppStatusBarOrder = normalizeAppStatusBarOrder(payload.AppStatusBarOrder) if payload.ChangesPanelLayout == "flat" { settings.ChangesPanelLayout = "flat" } else { @@ -604,3 +608,13 @@ func normalizeSidebarTaskPrefs(prefs models.SidebarTaskPrefs) models.SidebarTask } return prefs } + +func normalizeAppStatusBarOrder(order models.AppStatusBarOrder) models.AppStatusBarOrder { + if order.LeftItemIDs == nil { + order.LeftItemIDs = []string{} + } + if order.RightItemIDs == nil { + order.RightItemIDs = []string{} + } + return order +} diff --git a/apps/backend/internal/user/store/sqlite_test.go b/apps/backend/internal/user/store/sqlite_test.go index 926b55787f..3acc8b475c 100644 --- a/apps/backend/internal/user/store/sqlite_test.go +++ b/apps/backend/internal/user/store/sqlite_test.go @@ -3,6 +3,7 @@ package store import ( "context" "encoding/json" + "reflect" "strings" "testing" "time" @@ -231,6 +232,40 @@ func TestSQLiteRepositorySystemMetricsDisplayRoundTrip(t *testing.T) { } } +func TestSQLiteRepositoryAppStatusBarOrderDefaultAndRoundTrip(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) + } + if settings.AppStatusBarOrder.LeftItemIDs == nil || settings.AppStatusBarOrder.RightItemIDs == nil { + t.Fatalf("default AppStatusBarOrder = %#v, want non-nil empty arrays", settings.AppStatusBarOrder) + } + settings.AppStatusBarOrder = models.AppStatusBarOrder{ + LeftItemIDs: []string{"builtin:connection", "plugin:left"}, + RightItemIDs: []string{"builtin:metrics", "plugin:right"}, + } + upsertUserSettingsForTest(t, repo, ctx, settings) + got, err := repo.GetUserSettings(ctx, DefaultUserID) + if err != nil { + t.Fatalf("get settings: %v", err) + } + if !reflect.DeepEqual(got.AppStatusBarOrder, settings.AppStatusBarOrder) { + t.Fatalf("AppStatusBarOrder = %#v, want %#v", got.AppStatusBarOrder, settings.AppStatusBarOrder) + } +} + func TestSQLiteRepositoryUpdateTaskCreateLastUsedPatchesNonEmptyFields(t *testing.T) { conn, err := sqlx.Open("sqlite3", ":memory:") if err != nil { diff --git a/apps/web/app/github/github-page-client.tsx b/apps/web/app/github/github-page-client.tsx index cc80085921..e458b6acfb 100644 --- a/apps/web/app/github/github-page-client.tsx +++ b/apps/web/app/github/github-page-client.tsx @@ -503,7 +503,7 @@ export function GitHubPageClient({ }; return ( -
{message}
@@ -409,7 +409,7 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat if (initialError) return ( -Error loading stats: {initialError}
@@ -419,7 +419,7 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat if (!workspaceId) return