From 9c16d337a2df5b2b6fa46d2fce0495bd4afe2a99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:28:54 +0100 Subject: [PATCH] feat(web): add global app status surface (#1869) * feat(web): add app status bar * feat(web): add app status bar visibility toggle * feat(web): persist status bar item order * fix(web): refine app status bar behavior * fix(web): polish app status bar presentation * test(web): split plugin slot coverage groups * fix(web): address status surface review feedback * fix(web): stabilize status bar overlay tests --- .../fixture-package/ui/bundle.js | 15 + .../internal/backendapp/boot_state_routes.go | 8 + .../boot_state_user_settings_test.go | 17 + apps/backend/internal/common/config/config.go | 5 + .../internal/common/config/config_test.go | 50 +- apps/backend/internal/profiles/profiles.yaml | 7 + .../internal/profiles/profiles_test.go | 30 +- apps/backend/internal/runtimeflags/config.go | 47 +- .../internal/runtimeflags/config_test.go | 33 + .../backend/internal/runtimeflags/registry.go | 29 +- .../internal/runtimeflags/registry_test.go | 13 + .../internal/user/controller/controller.go | 1 + apps/backend/internal/user/dto/dto.go | 3 + apps/backend/internal/user/dto/dto_test.go | 32 + apps/backend/internal/user/models/models.go | 6 + apps/backend/internal/user/service/service.go | 5 + .../internal/user/service/service_test.go | 52 ++ apps/backend/internal/user/store/sqlite.go | 14 + .../internal/user/store/sqlite_test.go | 35 + apps/web/app/github/github-page-client.tsx | 2 +- apps/web/app/gitlab/gitlab-page-client.tsx | 2 +- apps/web/app/globals.css | 7 + apps/web/app/jira/jira-page-client.tsx | 2 +- apps/web/app/layout.tsx | 11 +- .../app/office/components/office-topbar.tsx | 4 +- apps/web/app/stats/loading.tsx | 2 +- apps/web/app/stats/stats-page-client.tsx | 6 +- apps/web/app/tasks/[id]/kanban-task-shell.tsx | 2 +- apps/web/app/tasks/tasks-page-client.tsx | 2 +- .../app-status-bar-order.test.ts | 120 +++ .../app-status-bar/app-status-bar-order.ts | 106 +++ .../app-status-bar-plugin-slots.test.tsx | 56 ++ .../app-status-bar-plugin-slots.tsx | 21 + .../app-status-bar/app-status-bar.test.tsx | 300 ++++++++ .../app-status-bar/app-status-bar.tsx | 106 +++ .../app-status-bar/app-status-drawer.test.tsx | 117 +++ .../app-status-bar/app-status-drawer.tsx | 65 ++ .../app-status-bar/app-status-items.tsx | 86 +++ .../app-status-surface-provider.test.tsx | 94 +++ .../app-status-surface-provider.tsx | 113 +++ .../connection-status-item.test.tsx | 26 + .../app-status-bar/connection-status-item.tsx | 87 +++ .../app-status-bar/use-app-status-bar-drag.ts | 119 +++ .../use-app-status-bar-order.ts | 59 ++ .../config-chat/config-chat-panel.tsx | 2 +- .../diff/walkthrough-floating-window.tsx | 2 +- apps/web/components/kanban-board.tsx | 4 +- apps/web/components/kanban-with-preview.tsx | 6 +- .../kanban/kanban-header-mobile.test.tsx | 4 - .../kanban/kanban-header-mobile.tsx | 1 + .../components/kanban/mobile-menu-sheet.tsx | 19 + .../kanban/task-multi-select-toolbar.tsx | 2 +- apps/web/components/page-topbar.tsx | 12 +- apps/web/components/plugins/plugin-page.tsx | 2 +- .../components/plugins/plugin-slot.test.tsx | 78 +- apps/web/components/plugins/plugin-slot.tsx | 41 +- .../components/review/walkthrough-overlay.tsx | 2 +- .../settings/agent-usage-section.test.tsx | 6 +- .../settings/agent-usage-section.tsx | 2 +- .../settings/editors-settings-state.tsx | 2 + .../settings/settings-floating-save.tsx | 2 +- .../settings/settings-layout-client.tsx | 23 +- .../settings/settings-save-provider.test.tsx | 12 + .../system-metrics-settings-card.test.tsx | 1 + .../settings/system-metrics-settings-card.tsx | 11 +- .../status-surface-metrics.test.tsx | 150 ++++ .../system-metrics/status-surface-metrics.tsx | 272 +++++++ .../system-metrics/topbar-metrics.test.tsx | 127 +--- .../system-metrics/topbar-metrics.tsx | 265 +------ .../mobile/session-mobile-bottom-nav.test.tsx | 53 +- .../task/mobile/session-mobile-bottom-nav.tsx | 43 +- .../task/mobile/session-mobile-layout.tsx | 55 +- .../web/components/task/task-page-content.tsx | 4 +- apps/web/components/task/task-page-inner.tsx | 2 +- .../task/task-top-bar-plugin-actions.tsx | 6 +- apps/web/components/task/task-top-bar.tsx | 2 +- apps/web/components/toast-provider.tsx | 2 +- apps/web/e2e/pages/session-page.ts | 3 + .../e2e/tests/kanban/mobile-kanban.spec.ts | 18 +- .../e2e/tests/layout/app-status-bar.spec.ts | 71 ++ .../e2e/tests/layout/topbar-alignment.spec.ts | 24 +- .../plugins/mobile-status-drawer.spec.ts | 120 +++ apps/web/e2e/tests/plugins/plugins.spec.ts | 47 ++ .../e2e/tests/system/licenses-page.spec.ts | 5 + apps/web/generated/licenses.json | 717 ++++++++++++------ .../hooks/use-ensure-user-settings.test.ts | 1 + apps/web/hooks/use-user-display-settings.ts | 9 +- apps/web/lib/plugins/registry.test.ts | 57 ++ apps/web/lib/plugins/registry.ts | 40 +- apps/web/lib/plugins/types.ts | 24 +- apps/web/lib/ssr/user-settings.test.ts | 21 + apps/web/lib/ssr/user-settings.ts | 9 + .../slices/features/features-slice.test.ts | 7 +- .../state/slices/features/features-slice.ts | 1 + apps/web/lib/state/slices/features/types.ts | 1 + .../state/slices/settings/settings-slice.ts | 1 + apps/web/lib/state/slices/settings/types.ts | 6 + apps/web/lib/types/backend.ts | 2 + apps/web/lib/types/http-user-settings.ts | 7 + apps/web/lib/types/http.ts | 1 + apps/web/lib/types/system.ts | 2 +- apps/web/lib/ws/handlers/users.test.ts | 25 + apps/web/lib/ws/handlers/users.ts | 5 + apps/web/scripts/generate-licenses.test.ts | 4 +- apps/web/scripts/generate-licenses.ts | 38 +- apps/web/src/app-shell.tsx | 11 +- apps/web/src/settings-routes.tsx | 2 +- apps/web/src/task-detail-route.test.tsx | 4 +- apps/web/src/task-detail-route.tsx | 2 +- .../0017-resource-metrics-sampling.md | 8 +- .../2026-07-21-portable-status-bar-order.md | 62 ++ docs/decisions/INDEX.md | 1 + docs/features.md | 3 +- docs/plans/app-status-bar/plan.md | 109 +++ .../task-01-plugin-status-slots.md | 39 + .../app-status-bar/task-02-status-builtins.md | 39 + .../task-03-status-surface-shell.md | 45 ++ .../task-04-mobile-status-entry-points.md | 40 + .../task-05-fixed-overlay-audit.md | 40 + .../task-06-plugin-fixture-desktop-e2e.md | 38 + .../task-07-mobile-status-e2e.md | 36 + .../task-08-attribution-public-docs.md | 42 + .../task-09-integration-verification.md | 40 + .../task-10-portable-item-order.md | 58 ++ .../task-11-stable-status-item-identities.md | 56 ++ ...sk-12-reorder-interaction-and-alignment.md | 61 ++ .../task-13-reorder-docs-e2e-verification.md | 62 ++ docs/plans/plugins/PLUGIN-API.md | 49 +- docs/public/configuration.md | 7 +- docs/public/operations.md | 12 +- docs/public/plugins-authoring.md | 55 +- docs/public/plugins.md | 29 + docs/screenshots/plugin-architecture.png | Bin 124726 -> 128470 bytes docs/screenshots/plugin-install-dialog.png | Bin 66584 -> 67887 bytes docs/screenshots/plugin-native-page.png | Bin 34065 -> 28988 bytes docs/screenshots/plugin-settings-list.png | Bin 67735 -> 76138 bytes docs/screenshots/plugin-settings-page.png | Bin 100418 -> 97953 bytes docs/specs/INDEX.md | 1 + docs/specs/feature-toggles/spec.md | 8 +- docs/specs/plugins/spec.md | 20 +- docs/specs/ui/app-status-bar.md | 121 +++ 141 files changed, 4727 insertions(+), 771 deletions(-) create mode 100644 apps/web/components/app-status-bar/app-status-bar-order.test.ts create mode 100644 apps/web/components/app-status-bar/app-status-bar-order.ts create mode 100644 apps/web/components/app-status-bar/app-status-bar-plugin-slots.test.tsx create mode 100644 apps/web/components/app-status-bar/app-status-bar-plugin-slots.tsx create mode 100644 apps/web/components/app-status-bar/app-status-bar.test.tsx create mode 100644 apps/web/components/app-status-bar/app-status-bar.tsx create mode 100644 apps/web/components/app-status-bar/app-status-drawer.test.tsx create mode 100644 apps/web/components/app-status-bar/app-status-drawer.tsx create mode 100644 apps/web/components/app-status-bar/app-status-items.tsx create mode 100644 apps/web/components/app-status-bar/app-status-surface-provider.test.tsx create mode 100644 apps/web/components/app-status-bar/app-status-surface-provider.tsx create mode 100644 apps/web/components/app-status-bar/connection-status-item.test.tsx create mode 100644 apps/web/components/app-status-bar/connection-status-item.tsx create mode 100644 apps/web/components/app-status-bar/use-app-status-bar-drag.ts create mode 100644 apps/web/components/app-status-bar/use-app-status-bar-order.ts create mode 100644 apps/web/components/system-metrics/status-surface-metrics.test.tsx create mode 100644 apps/web/components/system-metrics/status-surface-metrics.tsx create mode 100644 apps/web/e2e/tests/layout/app-status-bar.spec.ts create mode 100644 apps/web/e2e/tests/plugins/mobile-status-drawer.spec.ts create mode 100644 docs/decisions/2026-07-21-portable-status-bar-order.md create mode 100644 docs/plans/app-status-bar/plan.md create mode 100644 docs/plans/app-status-bar/task-01-plugin-status-slots.md create mode 100644 docs/plans/app-status-bar/task-02-status-builtins.md create mode 100644 docs/plans/app-status-bar/task-03-status-surface-shell.md create mode 100644 docs/plans/app-status-bar/task-04-mobile-status-entry-points.md create mode 100644 docs/plans/app-status-bar/task-05-fixed-overlay-audit.md create mode 100644 docs/plans/app-status-bar/task-06-plugin-fixture-desktop-e2e.md create mode 100644 docs/plans/app-status-bar/task-07-mobile-status-e2e.md create mode 100644 docs/plans/app-status-bar/task-08-attribution-public-docs.md create mode 100644 docs/plans/app-status-bar/task-09-integration-verification.md create mode 100644 docs/plans/app-status-bar/task-10-portable-item-order.md create mode 100644 docs/plans/app-status-bar/task-11-stable-status-item-identities.md create mode 100644 docs/plans/app-status-bar/task-12-reorder-interaction-and-alignment.md create mode 100644 docs/plans/app-status-bar/task-13-reorder-docs-e2e-verification.md create mode 100644 docs/specs/ui/app-status-bar.md 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 ( -
+
{!loaded &&
Checking GitHub status…
} {loaded && !authed && ( diff --git a/apps/web/app/gitlab/gitlab-page-client.tsx b/apps/web/app/gitlab/gitlab-page-client.tsx index d49f8b9ad7..c3c398b73e 100644 --- a/apps/web/app/gitlab/gitlab-page-client.tsx +++ b/apps/web/app/gitlab/gitlab-page-client.tsx @@ -545,7 +545,7 @@ export function GitLabPageClient({ const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []); return ( -
+
setOpenTicket(ticket), []); return ( -
+
-
- -
{children}
-
+ +
+ +
{children}
+
+
diff --git a/apps/web/app/office/components/office-topbar.tsx b/apps/web/app/office/components/office-topbar.tsx index cf3a46c572..dc8cc77662 100644 --- a/apps/web/app/office/components/office-topbar.tsx +++ b/apps/web/app/office/components/office-topbar.tsx @@ -1,6 +1,7 @@ "use client"; import { usePathname } from "@/lib/routing/client-router"; +import { AppStatusDrawerTrigger } from "@/components/app-status-bar/app-status-surface-provider"; const PAGE_TITLES: Record = { "/office": "Dashboard", @@ -46,13 +47,14 @@ export function OfficeTopbar() { return (
{detail ? (
) : ( title &&

{title}

)} +
); } diff --git a/apps/web/app/stats/loading.tsx b/apps/web/app/stats/loading.tsx index 5ef46c2560..fafd6866ae 100644 --- a/apps/web/app/stats/loading.tsx +++ b/apps/web/app/stats/loading.tsx @@ -11,7 +11,7 @@ import { export default function StatsLoading() { return ( -
+
diff --git a/apps/web/app/stats/stats-page-client.tsx b/apps/web/app/stats/stats-page-client.tsx index b9391e8ad8..95867df796 100644 --- a/apps/web/app/stats/stats-page-client.tsx +++ b/apps/web/app/stats/stats-page-client.tsx @@ -67,7 +67,7 @@ interface StatsPageClientProps { function StatsEmptyState({ message }: { message: string }) { 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 ; return ( -
+
+
{showOfficeLink && }
+
{ + it("builds the default two-sided order when no preference exists", () => { + expect(reconcileAppStatusBarOrder({ leftItemIds: [], rightItemIds: [] }, items)).toEqual({ + leftItemIds: [APP_STATUS_CONNECTION_ID, PLUGIN_LEFT_ID], + rightItemIds: [APP_STATUS_METRICS_ID, PLUGIN_RIGHT_ID], + }); + }); + + it("keeps saved cross-spacer order and dormant plugin identities", () => { + const saved = { + leftItemIds: [PLUGIN_RIGHT_ID, DORMANT_ID, PLUGIN_RIGHT_ID], + rightItemIds: [APP_STATUS_CONNECTION_ID, DORMANT_ID], + }; + + expect(reconcileAppStatusBarOrder(saved, items)).toEqual({ + leftItemIds: [PLUGIN_RIGHT_ID, DORMANT_ID, PLUGIN_LEFT_ID], + rightItemIds: [APP_STATUS_CONNECTION_ID, APP_STATUS_METRICS_ID], + }); + }); + + it("projects only active items and restores a re-enabled item at its saved position", () => { + const saved = { + leftItemIds: [PLUGIN_LEFT_ID, DORMANT_ID, APP_STATUS_CONNECTION_ID], + rightItemIds: [APP_STATUS_METRICS_ID], + }; + const reconciled = reconcileAppStatusBarOrder(saved, items); + + expect(projectActiveStatusItems(reconciled, items).left.map((item) => item.id)).toEqual([ + PLUGIN_LEFT_ID, + APP_STATUS_CONNECTION_ID, + ]); + const reenabled = [...items, { id: DORMANT_ID, defaultSide: "right" as const }]; + expect(projectActiveStatusItems(reconciled, reenabled).left.map((item) => item.id)).toEqual([ + PLUGIN_LEFT_ID, + DORMANT_ID, + APP_STATUS_CONNECTION_ID, + ]); + }); +}); + +describe("moveAppStatusItem", () => { + it("moves an active item across the spacer without dropping dormant identities", () => { + const order = { + leftItemIds: [APP_STATUS_CONNECTION_ID, "dormant-left", PLUGIN_LEFT_ID], + rightItemIds: [APP_STATUS_METRICS_ID, DORMANT_RIGHT_ID, PLUGIN_RIGHT_ID], + }; + + expect(moveAppStatusItem(order, PLUGIN_LEFT_ID, "right", 1, items)).toEqual({ + leftItemIds: [APP_STATUS_CONNECTION_ID, "dormant-left"], + rightItemIds: [APP_STATUS_METRICS_ID, PLUGIN_LEFT_ID, DORMANT_RIGHT_ID, PLUGIN_RIGHT_ID], + }); + }); + + it("inserts after the last active item but before trailing dormant identities", () => { + const order = { + leftItemIds: [APP_STATUS_CONNECTION_ID], + rightItemIds: [APP_STATUS_METRICS_ID, PLUGIN_RIGHT_ID, DORMANT_RIGHT_ID], + }; + + expect(moveAppStatusItem(order, APP_STATUS_CONNECTION_ID, "right", 2, items)).toEqual({ + leftItemIds: [], + rightItemIds: [ + APP_STATUS_METRICS_ID, + PLUGIN_RIGHT_ID, + APP_STATUS_CONNECTION_ID, + DORMANT_RIGHT_ID, + ], + }); + }); +}); + +describe("resolveAppStatusDropTarget", () => { + const geometry = [ + { id: "left-a", side: "left" as const, left: 0, right: 20 }, + { id: "left-b", side: "left" as const, left: 20, right: 40 }, + { id: "right-a", side: "right" as const, left: 80, right: 100 }, + ]; + + it("uses the spacer midpoint to choose a side and item centers for insertion", () => { + expect(resolveAppStatusDropTarget(5, geometry, { left: 40, right: 80 })).toEqual({ + side: "left", + activeIndex: 0, + }); + expect(resolveAppStatusDropTarget(35, geometry, { left: 40, right: 80 })).toEqual({ + side: "left", + activeIndex: 2, + }); + expect(resolveAppStatusDropTarget(70, geometry, { left: 40, right: 80 })).toEqual({ + side: "right", + activeIndex: 0, + }); + expect(resolveAppStatusDropTarget(95, geometry, { left: 40, right: 80 })).toEqual({ + side: "right", + activeIndex: 1, + }); + }); +}); diff --git a/apps/web/components/app-status-bar/app-status-bar-order.ts b/apps/web/components/app-status-bar/app-status-bar-order.ts new file mode 100644 index 0000000000..8def620978 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-bar-order.ts @@ -0,0 +1,106 @@ +import type { AppStatusBarOrderState } from "@/lib/state/slices/settings/types"; + +export const APP_STATUS_CONNECTION_ID = "builtin:connection"; +export const APP_STATUS_METRICS_ID = "builtin:metrics"; + +export type AppStatusBarSide = "left" | "right"; + +export type AppStatusItemDescriptor = { + id: string; + defaultSide: AppStatusBarSide; +}; + +export type ProjectedStatusItems = { + left: T[]; + right: T[]; +}; + +export type AppStatusItemGeometry = { + id: string; + side: AppStatusBarSide; + left: number; + right: number; +}; + +export type AppStatusDropTarget = { + side: AppStatusBarSide; + activeIndex: number; +}; + +export function reconcileAppStatusBarOrder( + saved: AppStatusBarOrderState, + activeItems: AppStatusItemDescriptor[], +): AppStatusBarOrderState { + const seen = new Set(); + const leftItemIds = uniqueItemIds(saved.leftItemIds, seen); + const rightItemIds = uniqueItemIds(saved.rightItemIds, seen); + + for (const item of activeItems) { + if (!item.id || seen.has(item.id)) continue; + seen.add(item.id); + (item.defaultSide === "left" ? leftItemIds : rightItemIds).push(item.id); + } + return { leftItemIds, rightItemIds }; +} + +export function projectActiveStatusItems( + order: AppStatusBarOrderState, + activeItems: T[], +): ProjectedStatusItems { + const activeById = new Map(); + for (const item of activeItems) { + if (item.id && !activeById.has(item.id)) activeById.set(item.id, item); + } + return { + left: projectSide(order.leftItemIds, activeById), + right: projectSide(order.rightItemIds, activeById), + }; +} + +export function moveAppStatusItem( + order: AppStatusBarOrderState, + itemId: string, + targetSide: AppStatusBarSide, + targetActiveIndex: number, + activeItems: AppStatusItemDescriptor[], +): AppStatusBarOrderState { + const leftItemIds = order.leftItemIds.filter((id) => id !== itemId); + const rightItemIds = order.rightItemIds.filter((id) => id !== itemId); + const targetIds = targetSide === "left" ? leftItemIds : rightItemIds; + const activeIds = new Set(activeItems.map((item) => item.id)); + const targetActiveIds = targetIds.filter((id) => activeIds.has(id)); + const activeIndex = Math.max(0, Math.min(targetActiveIndex, targetActiveIds.length)); + const insertionIndex = + activeIndex === 0 ? 0 : targetIds.indexOf(targetActiveIds[activeIndex - 1]) + 1; + + targetIds.splice(insertionIndex, 0, itemId); + return { leftItemIds, rightItemIds }; +} + +export function resolveAppStatusDropTarget( + clientX: number, + items: AppStatusItemGeometry[], + spacer: { left: number; right: number }, +): AppStatusDropTarget { + const side: AppStatusBarSide = clientX < (spacer.left + spacer.right) / 2 ? "left" : "right"; + const sideItems = items.filter((item) => item.side === side); + const activeIndex = sideItems.findIndex((item) => clientX < (item.left + item.right) / 2); + return { side, activeIndex: activeIndex === -1 ? sideItems.length : activeIndex }; +} + +function uniqueItemIds(ids: string[], seen: Set): string[] { + const result: string[] = []; + for (const id of ids) { + if (!id || seen.has(id)) continue; + seen.add(id); + result.push(id); + } + return result; +} + +function projectSide(ids: string[], activeById: Map): T[] { + return ids.flatMap((id) => { + const item = activeById.get(id); + return item ? [item] : []; + }); +} diff --git a/apps/web/components/app-status-bar/app-status-bar-plugin-slots.test.tsx b/apps/web/components/app-status-bar/app-status-bar-plugin-slots.test.tsx new file mode 100644 index 0000000000..fd923e2a36 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-bar-plugin-slots.test.tsx @@ -0,0 +1,56 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import type { AppStatusBarSlotProps } from "@/lib/plugins/types"; +import { AppStatusBarPluginContribution } from "./app-status-bar-plugin-slots"; + +const SLOT = "app-status-bar-right"; + +const slotProps = { + placement: "right", + presentation: "mobile-drawer", + density: "compact", + pathname: "/tasks/task-1", + activeWorkspaceId: "workspace-1", + activeTaskId: "task-1", + activeSessionId: "session-1", +} satisfies AppStatusBarSlotProps; + +describe("AppStatusBarPluginContribution", () => { + afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin("plugin-a"); + }); + + it("forwards the exact status-bar context to registered plugins", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerComponent(SLOT, ({ slotProps: received }) => ( + {JSON.stringify(received)} + )); + + const registration = pluginRegistry.getSlotRegistrations(SLOT)[0]; + render(); + + expect(JSON.parse(screen.getByTestId("status-slot-props").textContent ?? "null")).toEqual( + slotProps, + ); + }); + + it("renders the slot matching its left-side placement", () => { + pluginRegistry + .forPlugin("plugin-a") + .registerComponent("app-status-bar-left", () =>
); + + const registration = pluginRegistry.getSlotRegistrations("app-status-bar-left")[0]; + render( + , + ); + + expect(screen.getByTestId("left-status-slot")).not.toBeNull(); + }); +}); diff --git a/apps/web/components/app-status-bar/app-status-bar-plugin-slots.tsx b/apps/web/components/app-status-bar/app-status-bar-plugin-slots.tsx new file mode 100644 index 0000000000..2152a7372e --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-bar-plugin-slots.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { PluginSlotRegistrationView } from "@/components/plugins/plugin-slot"; +import type { PluginSlotRegistration } from "@/lib/plugins/registry"; +import type { AppStatusBarSlotProps } from "@/lib/plugins/types"; + +/** Renders one opaque plugin-owned status item with host-owned context. */ +export function AppStatusBarPluginContribution({ + registration, + ...slotProps +}: AppStatusBarSlotProps & { registration: PluginSlotRegistration }) { + const name = slotProps.placement === "left" ? "app-status-bar-left" : "app-status-bar-right"; + return ( + + ); +} 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 new file mode 100644 index 0000000000..8da36ac2a9 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-bar.test.tsx @@ -0,0 +1,300 @@ +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +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 { ApiError } from "@/lib/api/client"; +import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import { AppStatusBar } from "./app-status-bar"; +import { APP_STATUS_CONNECTION_ID, APP_STATUS_METRICS_ID } from "./app-status-bar-order"; + +vi.mock("@/lib/api/domains/settings-api", () => ({ updateUserSettings: vi.fn() })); +vi.mock("sonner", () => ({ toast: { error: vi.fn() } })); + +const LEFT_PLUGIN_ID = "left-plugin"; +const RIGHT_PLUGIN_ID = "right-plugin"; +const APP_STATUS_BAR_TEST_ID = "app-status-bar"; +const APP_STATUS_LEFT_SLOT = "app-status-bar-left"; + +beforeEach(() => { + vi.mocked(updateUserSettings) + .mockReset() + .mockResolvedValue({ settings: {} } as never); + vi.mocked(toast.error).mockReset(); +}); + +afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin(LEFT_PLUGIN_ID); + pluginRegistry.unregisterPlugin(RIGHT_PLUGIN_ID); +}); + +describe("AppStatusBar rendering", () => { + it("keeps a 24px in-flow footer and renders both live plugin regions", () => { + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent(APP_STATUS_LEFT_SLOT, () => ( + Left + )); + pluginRegistry + .forPlugin(RIGHT_PLUGIN_ID) + .registerComponent("app-status-bar-right", () => ( + Right + )); + + render( + + + + + , + ); + + expect(screen.getByTestId(APP_STATUS_BAR_TEST_ID).className).toContain("h-6"); + expect(screen.getByTestId(LEFT_PLUGIN_ID)).toBeTruthy(); + expect(screen.getByTestId(RIGHT_PLUGIN_ID)).toBeTruthy(); + }); + + it("centers plugin regions inside the fixed-height bar", () => { + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent(APP_STATUS_LEFT_SLOT, () => ( + Left + )); + + render( + + + + + , + ); + + expect(screen.getByTestId("app-status-bar-left-plugins").className).toContain("items-center"); + expect(screen.getByTestId("app-status-bar-left-plugins").className).toContain("h-full"); + expect(screen.getByTestId(APP_STATUS_BAR_TEST_ID).className).not.toContain("border-t"); + expect(screen.getByTestId(APP_STATUS_BAR_TEST_ID).className).toContain("before:h-px"); + }); + + it("uses the same surface colors as the app sidebar", () => { + renderBar(); + + const bar = screen.getByTestId(APP_STATUS_BAR_TEST_ID); + expect(bar.className).toContain("bg-background"); + expect(bar.className).toContain("text-foreground/80"); + expect(bar.className).toContain("before:bg-border"); + }); + + it("does not render an empty metrics item when the metrics preference is disabled", () => { + renderBar(); + + expect(document.querySelector(`[data-status-item-id="${APP_STATUS_METRICS_ID}"]`)).toBeNull(); + }); + + it("collapses a plugin item when its contribution renders nothing", () => { + pluginRegistry.forPlugin(RIGHT_PLUGIN_ID).registerComponent("app-status-bar-right", () => null); + + renderBar(); + + expect(statusItem(`plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0`).className).toContain( + "empty:hidden", + ); + }); +}); + +describe("AppStatusBar drag input", () => { + it("moves an opaque plugin item across the spacer with Ctrl plus mouse drag", async () => { + const onClick = vi.fn(); + pluginRegistry.forPlugin(LEFT_PLUGIN_ID).registerComponent(APP_STATUS_LEFT_SLOT, () => ( + + )); + pluginRegistry + .forPlugin(RIGHT_PLUGIN_ID) + .registerComponent("app-status-bar-right", () => Right); + + renderBar(); + const leftId = `plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`; + const rightId = `plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right: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); + setRect(statusItem(rightId), 80, 100); + + const pointerDownAccepted = fireEvent.pointerDown(dragged, { + pointerId: 1, + pointerType: "mouse", + ctrlKey: true, + clientX: 30, + }); + expect(pointerDownAccepted).toBe(false); + fireEvent.pointerMove(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 1, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + fireEvent.pointerUp(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 1, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + fireEvent.click(screen.getByRole("button", { name: "Plugin action" })); + + await waitFor(() => + expect(updateUserSettings).toHaveBeenCalledWith({ + app_status_bar_order: { + left_item_ids: [APP_STATUS_CONNECTION_ID], + right_item_ids: [leftId, rightId], + }, + }), + ); + expect(statusItem(leftId).dataset.statusSide).toBe("right"); + expect(onClick).not.toHaveBeenCalled(); + }); + + it("keeps normal plugin clicks and ignores plain mouse, touch, and sub-threshold movement", () => { + const onClick = vi.fn(); + pluginRegistry.forPlugin(LEFT_PLUGIN_ID).registerComponent(APP_STATUS_LEFT_SLOT, () => ( + + )); + renderBar(); + const item = statusItem(`plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`); + const bar = screen.getByTestId(APP_STATUS_BAR_TEST_ID); + + fireEvent.pointerDown(item, { pointerId: 1, pointerType: "mouse", clientX: 10 }); + fireEvent.pointerMove(bar, { pointerId: 1, pointerType: "mouse", clientX: 30 }); + fireEvent.pointerUp(bar, { pointerId: 1, pointerType: "mouse", clientX: 30 }); + fireEvent.pointerDown(item, { + pointerId: 2, + pointerType: "touch", + ctrlKey: true, + clientX: 10, + }); + fireEvent.pointerMove(bar, { pointerId: 2, pointerType: "touch", clientX: 30 }); + fireEvent.pointerUp(bar, { pointerId: 2, pointerType: "touch", clientX: 30 }); + fireEvent.pointerDown(item, { + pointerId: 3, + pointerType: "mouse", + metaKey: true, + clientX: 10, + }); + fireEvent.pointerMove(bar, { pointerId: 3, pointerType: "mouse", clientX: 13 }); + fireEvent.pointerUp(bar, { pointerId: 3, pointerType: "mouse", clientX: 13 }); + fireEvent.click(screen.getByRole("button", { name: "Plugin action" })); + + expect(updateUserSettings).not.toHaveBeenCalled(); + expect(onClick).toHaveBeenCalledOnce(); + }); +}); + +describe("AppStatusBar persistence and recovery", () => { + it("restores the confirmed order and reports a rejected save", async () => { + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent(APP_STATUS_LEFT_SLOT, () => Left); + vi.mocked(updateUserSettings).mockRejectedValueOnce(new ApiError("rejected", 400, null)); + renderBar(); + 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: 4, + pointerType: "mouse", + ctrlKey: true, + clientX: 30, + }); + fireEvent.pointerMove(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 4, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + fireEvent.pointerUp(screen.getByTestId(APP_STATUS_BAR_TEST_ID), { + pointerId: 4, + pointerType: "mouse", + ctrlKey: true, + clientX: 85, + }); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith("Could not save status bar order"), + ); + expect(statusItem(leftId).dataset.statusSide).toBe("left"); + }); + + it("resets a failed contribution boundary when the registration is replaced", () => { + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + pluginRegistry.forPlugin(LEFT_PLUGIN_ID).registerComponent(APP_STATUS_LEFT_SLOT, () => { + throw new Error("plugin failed"); + }); + renderBar(); + + act(() => { + pluginRegistry.unregisterPlugin(LEFT_PLUGIN_ID); + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent(APP_STATUS_LEFT_SLOT, () => Healthy replacement); + }); + + expect(screen.getByText("Healthy replacement")).toBeTruthy(); + consoleError.mockRestore(); + }); +}); + +function renderBar() { + return render( + + + + + , + ); +} + +function statusItem(id: string): HTMLElement { + const item = document.querySelector(`[data-status-item-id="${id}"]`); + if (!item) throw new Error(`Missing status item ${id}`); + return item; +} + +function setRect(element: HTMLElement, left: number, right: number) { + element.getBoundingClientRect = () => + ({ + left, + right, + width: right - left, + top: 0, + bottom: 24, + height: 24, + x: left, + y: 0, + }) as DOMRect; +} diff --git a/apps/web/components/app-status-bar/app-status-bar.tsx b/apps/web/components/app-status-bar/app-status-bar.tsx new file mode 100644 index 0000000000..04a229ff54 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-bar.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { useMemo } from "react"; +import { cn } from "@kandev/ui/lib/utils"; +import { useAppStatusItems, type AppStatusItem } from "./app-status-items"; +import { useAppStatusBarDrag } from "./use-app-status-bar-drag"; +import { useAppStatusBarOrder } from "./use-app-status-bar-order"; + +type AppStatusBarProps = { + pathname: string; + activeWorkspaceId: string | null; + activeTaskId: string | null; + activeSessionId: string | null; + density: "full" | "compact"; +}; + +/** + * Interaction reference: Orca StatusBar at d9d939a33b5858495ffb33489a952f1ac9293610. + * Kandev implementation is independent; third-party notice ships in Settings > Licenses. + */ +export function AppStatusBar({ + pathname, + activeWorkspaceId, + activeTaskId, + activeSessionId, + density, +}: AppStatusBarProps) { + const context = useMemo( + () => ({ pathname, activeWorkspaceId, activeTaskId, activeSessionId }), + [pathname, activeWorkspaceId, activeTaskId, activeSessionId], + ); + const activeItems = useAppStatusItems(context); + const { projected, moveItem } = useAppStatusBarOrder(activeItems); + const drag = useAppStatusBarDrag({ moveItem }); + + return ( +
+ + {projected.left.map((item) => ( + + ))} + +
+ + {projected.right.map((item) => ( + + ))} + +
+ ); +} + +function StatusItemGroup({ + side, + testId, + children, +}: { + side: "left" | "right"; + testId: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + +function BarStatusItem({ + item, + side, + density, + drag, +}: { + item: AppStatusItem; + side: "left" | "right"; + density: "full" | "compact"; + drag: ReturnType; +}) { + return ( +
drag.onItemPointerDown(item.id, event)} + > + {item.render({ presentation: "bar", density, drawerOpen: false })} +
+ ); +} 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 new file mode 100644 index 0000000000..919fcc9674 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-drawer.test.tsx @@ -0,0 +1,117 @@ +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 { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice"; +import { pluginRegistry } from "@/lib/plugins/registry"; +import type { AppStatusBarSlotProps } from "@/lib/plugins/types"; +import { AppStatusDrawer } from "./app-status-drawer"; +import { APP_STATUS_CONNECTION_ID, APP_STATUS_METRICS_ID } from "./app-status-bar-order"; + +vi.mock("@kandev/ui/drawer", () => ({ + Drawer: ({ children }: { children: React.ReactNode }) => <>{children}, + DrawerContent: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DrawerHeader: ({ children, ...props }: React.HTMLAttributes) => ( +
{children}
+ ), + DrawerTitle: ({ children }: { children: React.ReactNode }) =>

{children}

, +})); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: true }), +})); + +const LEFT_PLUGIN_ID = "drawer-left"; +const RIGHT_PLUGIN_ID = "drawer-right"; + +describe("AppStatusDrawer", () => { + afterEach(() => { + cleanup(); + pluginRegistry.unregisterPlugin(LEFT_PLUGIN_ID); + pluginRegistry.unregisterPlugin(RIGHT_PLUGIN_ID); + }); + + it("mirrors saved left then right order as non-draggable 44px rows", () => { + pluginRegistry + .forPlugin(LEFT_PLUGIN_ID) + .registerComponent("app-status-bar-left", ({ slotProps }) => { + const props = slotProps as AppStatusBarSlotProps; + return {props.presentation}; + }); + pluginRegistry + .forPlugin(RIGHT_PLUGIN_ID) + .registerComponent("app-status-bar-right", ({ slotProps }) => { + const props = slotProps as AppStatusBarSlotProps; + return {props.presentation}; + }); + const leftId = `plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`; + const rightId = `plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0`; + + render( + + + {}} + /> + + , + ); + + const rows = Array.from( + screen + .getByTestId("app-status-drawer") + .querySelectorAll("[data-status-item-id]"), + ); + expect(rows.map((row) => row.dataset.statusItemId)).toEqual([ + rightId, + APP_STATUS_METRICS_ID, + APP_STATUS_CONNECTION_ID, + leftId, + ]); + expect(rows.every((row) => row.className.includes("min-h-11"))).toBe(true); + expect(screen.getByTestId(LEFT_PLUGIN_ID).textContent).toBe("mobile-drawer"); + expect(screen.getByTestId(RIGHT_PLUGIN_ID).textContent).toBe("mobile-drawer"); + }); + + it("collapses a plugin row when its contribution renders nothing", () => { + pluginRegistry.forPlugin(RIGHT_PLUGIN_ID).registerComponent("app-status-bar-right", () => null); + + render( + + + {}} + /> + + , + ); + + const row = document.querySelector( + `[data-status-item-id="plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0"]`, + ); + expect(row?.className).toContain("empty:hidden"); + }); +}); diff --git a/apps/web/components/app-status-bar/app-status-drawer.tsx b/apps/web/components/app-status-bar/app-status-drawer.tsx new file mode 100644 index 0000000000..8e0364c4b4 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-drawer.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useMemo } from "react"; +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer"; +import { useAppStatusItems, type AppStatusItem } from "./app-status-items"; +import { useAppStatusBarOrder } from "./use-app-status-bar-order"; + +type AppStatusDrawerProps = { + pathname: string; + activeWorkspaceId: string | null; + activeTaskId: string | null; + activeSessionId: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +export function AppStatusDrawer({ + pathname, + activeWorkspaceId, + activeTaskId, + activeSessionId, + open, + onOpenChange, +}: AppStatusDrawerProps) { + const context = useMemo( + () => ({ pathname, activeWorkspaceId, activeTaskId, activeSessionId }), + [pathname, activeWorkspaceId, activeTaskId, activeSessionId], + ); + const activeItems = useAppStatusItems(context); + const { projected } = useAppStatusBarOrder(activeItems); + const orderedItems = [...projected.left, ...projected.right]; + + return ( + + +
+ + Status + +
+
+ {orderedItems.map((item) => ( + + ))} +
+
+
+
+
+ ); +} + +function DrawerStatusItem({ item, open }: { item: AppStatusItem; open: boolean }) { + return ( +
+ {item.render({ presentation: "mobile-drawer", density: "full", drawerOpen: open })} +
+ ); +} diff --git a/apps/web/components/app-status-bar/app-status-items.tsx b/apps/web/components/app-status-bar/app-status-items.tsx new file mode 100644 index 0000000000..327b082d0e --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-items.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useMemo, type ReactNode } from "react"; +import { StatusSurfaceMetrics } from "@/components/system-metrics/status-surface-metrics"; +import { useAppStore } from "@/components/state-provider"; +import { usePluginRegistry, type PluginSlotRegistration } from "@/lib/plugins/registry"; +import type { AppStatusBarSlotProps } from "@/lib/plugins/types"; +import { ConnectionStatusItem } from "./connection-status-item"; +import { AppStatusBarPluginContribution } from "./app-status-bar-plugin-slots"; +import { + APP_STATUS_CONNECTION_ID, + APP_STATUS_METRICS_ID, + type AppStatusItemDescriptor, +} from "./app-status-bar-order"; + +export type AppStatusItemPresentation = { + presentation: "bar" | "mobile-drawer"; + density: "full" | "compact"; + drawerOpen: boolean; +}; + +export type AppStatusItem = AppStatusItemDescriptor & { + render: (presentation: AppStatusItemPresentation) => ReactNode; +}; + +type AppStatusContext = Pick< + AppStatusBarSlotProps, + "pathname" | "activeWorkspaceId" | "activeTaskId" | "activeSessionId" +>; + +export function useAppStatusItems(context: AppStatusContext): AppStatusItem[] { + const registry = usePluginRegistry(); + const registryVersion = registry.getVersion(); + const metricsEnabled = useAppStore( + (state) => state.userSettings.systemMetricsDisplay.showInTopbar, + ); + + return useMemo(() => { + const left = registry.getSlotRegistrations("app-status-bar-left"); + const right = registry.getSlotRegistrations("app-status-bar-right"); + return [ + connectionItem(), + ...left.map((registration) => pluginItem(registration, "left", context)), + ...(metricsEnabled ? [metricsItem()] : []), + ...right.map((registration) => pluginItem(registration, "right", context)), + ]; + }, [context, metricsEnabled, registry, registryVersion]); +} + +function connectionItem(): AppStatusItem { + return { + id: APP_STATUS_CONNECTION_ID, + defaultSide: "left", + render: ({ presentation }) => , + }; +} + +function metricsItem(): AppStatusItem { + return { + id: APP_STATUS_METRICS_ID, + defaultSide: "right", + render: ({ presentation, density, drawerOpen }) => ( + + ), + }; +} + +function pluginItem( + registration: PluginSlotRegistration, + placement: "left" | "right", + context: AppStatusContext, +): AppStatusItem { + return { + id: registration.orderingId, + defaultSide: placement, + render: ({ presentation, density }) => ( + + ), + }; +} diff --git a/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx new file mode 100644 index 0000000000..39d6d60299 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-surface-provider.test.tsx @@ -0,0 +1,94 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StateProvider } from "@/components/state-provider"; +import { AppStatusDrawerTrigger, AppStatusSurfaceProvider } from "./app-status-surface-provider"; + +const responsiveState = vi.hoisted(() => ({ + breakpoint: "desktop" as "mobile" | "tablet" | "desktop", +})); +const featureState = vi.hoisted(() => ({ appStatusBar: true })); +const STATUS_BAR_TEST_ID = "app-status-bar"; +const STATUS_DRAWER_TEST_ID = "app-status-drawer"; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveState.breakpoint, + isMobile: responsiveState.breakpoint === "mobile", + isTablet: responsiveState.breakpoint === "tablet", + isDesktop: responsiveState.breakpoint === "desktop", + isCompactDesktop: false, + isFullDesktop: responsiveState.breakpoint === "desktop", + isFinePointer: responsiveState.breakpoint !== "mobile", + usesDesktopWorkbench: responsiveState.breakpoint === "desktop", + }), +})); + +vi.mock("@/hooks/domains/features/use-feature", () => ({ + useFeature: (name: string) => (name === "appStatusBar" ? featureState.appStatusBar : true), +})); + +vi.mock("./app-status-bar", () => ({ + AppStatusBar: () =>
, +})); + +vi.mock("./app-status-drawer", () => ({ + AppStatusDrawer: ({ open }: { open: boolean }) => ( +
{String(open)}
+ ), +})); + +function renderSurface() { + return render( + + + + + , + ); +} + +describe("AppStatusSurfaceProvider", () => { + beforeEach(() => { + responsiveState.breakpoint = "desktop"; + featureState.appStatusBar = true; + }); + + 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 phone drawer and opens it from native trigger", () => { + responsiveState.breakpoint = "mobile"; + renderSurface(); + + expect(screen.queryByTestId(STATUS_BAR_TEST_ID)).toBeNull(); + expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("false"); + + fireEvent.click(screen.getByRole("button", { name: "Open status" })); + expect(screen.getByTestId(STATUS_DRAWER_TEST_ID).textContent).toBe("true"); + }); + + it("hides both presentations when the app-status-bar feature is disabled", () => { + responsiveState.breakpoint = "mobile"; + featureState.appStatusBar = false; + renderSurface(); + + expect(screen.queryByTestId(STATUS_BAR_TEST_ID)).toBeNull(); + expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("app-status-drawer-trigger")).toBeNull(); + }); + + it("does not expose a drawer trigger at the tablet breakpoint", () => { + responsiveState.breakpoint = "tablet"; + renderSurface(); + + expect(screen.getByTestId(STATUS_BAR_TEST_ID)).toBeTruthy(); + expect(screen.queryByTestId(STATUS_DRAWER_TEST_ID)).toBeNull(); + expect(screen.queryByTestId("app-status-drawer-trigger")).toBeNull(); + }); +}); 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 new file mode 100644 index 0000000000..ef19db9a56 --- /dev/null +++ b/apps/web/components/app-status-bar/app-status-surface-provider.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { + createContext, + useContext, + useMemo, + useState, + type ComponentProps, + type ReactNode, +} from "react"; +import { IconActivity } from "@tabler/icons-react"; +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"; +import { AppStatusDrawer } from "./app-status-drawer"; + +type AppStatusDrawerContextValue = { + enabled: boolean; + drawerOpen: boolean; + openStatusDrawer: () => void; + setStatusDrawerOpen: (open: boolean) => void; +}; + +const AppStatusDrawerContext = createContext(null); +const unavailableDrawer: AppStatusDrawerContextValue = { + enabled: false, + drawerOpen: false, + openStatusDrawer: () => {}, + setStatusDrawerOpen: () => {}, +}; + +export function useAppStatusDrawer() { + const context = useContext(AppStatusDrawerContext); + return context ?? unavailableDrawer; +} + +type AppStatusDrawerTriggerProps = Omit, "onClick"> & { + label?: string; +}; + +export function AppStatusDrawerTrigger({ + className, + children, + label = "Open status", + ...buttonProps +}: AppStatusDrawerTriggerProps) { + const drawer = useContext(AppStatusDrawerContext); + if (!drawer?.enabled) return null; + return ( + + ); +} + +export function AppStatusSurfaceProvider({ children }: { children: ReactNode }) { + const [drawerOpen, setStatusDrawerOpen] = useState(false); + const pathname = usePathname(); + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); + const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const appStatusBarEnabled = useFeature("appStatusBar"); + const { isMobile, isFullDesktop } = useResponsiveBreakpoint(); + const drawerEnabled = appStatusBarEnabled && isMobile; + const drawer = useMemo( + () => ({ + enabled: drawerEnabled, + drawerOpen, + openStatusDrawer: () => { + if (drawerEnabled) setStatusDrawerOpen(true); + }, + setStatusDrawerOpen, + }), + [drawerEnabled, drawerOpen], + ); + const surfaceProps = { + pathname, + activeWorkspaceId, + activeTaskId, + activeSessionId, + }; + + return ( + +
+ {children} + {appStatusBarEnabled && + (isMobile ? ( + + ) : ( + + ))} +
+
+ ); +} diff --git a/apps/web/components/app-status-bar/connection-status-item.test.tsx b/apps/web/components/app-status-bar/connection-status-item.test.tsx new file mode 100644 index 0000000000..a18da47734 --- /dev/null +++ b/apps/web/components/app-status-bar/connection-status-item.test.tsx @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { connectionStatusDetails } from "./connection-status-item"; + +describe("connectionStatusDetails", () => { + it.each([ + ["connected", "Connected", "Connected to Kandev"], + ["connecting", "Connecting", "Connecting to Kandev"], + ["reconnecting", "Reconnecting", "Reconnecting to Kandev"], + ["disconnected", "Offline", "Connection unavailable"], + ["error", "Connection error", "Connection error: socket closed"], + ] as const)("maps %s to accessible connection details", (status, label, description) => { + expect( + connectionStatusDetails(status, status === "error" ? "socket closed" : null), + ).toMatchObject({ + label, + description, + }); + }); + + it("uses a small green dot for an active connection", () => { + expect(connectionStatusDetails("connected", null)).toMatchObject({ + dotClass: "bg-success", + animate: false, + }); + }); +}); diff --git a/apps/web/components/app-status-bar/connection-status-item.tsx b/apps/web/components/app-status-bar/connection-status-item.tsx new file mode 100644 index 0000000000..27671f40e1 --- /dev/null +++ b/apps/web/components/app-status-bar/connection-status-item.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { cn } from "@kandev/ui/lib/utils"; +import { useAppStore } from "@/components/state-provider"; +import type { ConnectionStatus } from "@/lib/types/connection"; + +export type ConnectionStatusDetails = { + label: string; + description: string; + dotClass: string; + animate: boolean; +}; + +export function connectionStatusDetails( + status: ConnectionStatus, + error: string | null, +): ConnectionStatusDetails { + switch (status) { + case "connected": + return { + label: "Connected", + description: "Connected to Kandev", + dotClass: "bg-success", + animate: false, + }; + case "connecting": + return { + label: "Connecting", + description: "Connecting to Kandev", + dotClass: "bg-muted-foreground", + animate: true, + }; + case "reconnecting": + return { + label: "Reconnecting", + description: "Reconnecting to Kandev", + dotClass: "bg-amber-500", + animate: true, + }; + case "error": + return { + label: "Connection error", + description: error ? `Connection error: ${error}` : "Connection error", + dotClass: "bg-destructive", + animate: false, + }; + case "disconnected": + return { + label: "Offline", + description: "Connection unavailable", + dotClass: "bg-muted-foreground/50", + animate: false, + }; + } +} + +export function ConnectionStatusItem({ presentation }: { presentation: "bar" | "mobile-drawer" }) { + const status = useAppStore((state) => state.connection.status); + const error = useAppStore((state) => state.connection.error); + const details = connectionStatusDetails(status, error); + + return ( + + + + + + {details.description} + + ); +} diff --git a/apps/web/components/app-status-bar/use-app-status-bar-drag.ts b/apps/web/components/app-status-bar/use-app-status-bar-drag.ts new file mode 100644 index 0000000000..4d9e10b2fc --- /dev/null +++ b/apps/web/components/app-status-bar/use-app-status-bar-drag.ts @@ -0,0 +1,119 @@ +"use client"; + +import { + useCallback, + useRef, + useState, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; +import { + resolveAppStatusDropTarget, + type AppStatusDropTarget, + type AppStatusItemGeometry, +} from "./app-status-bar-order"; + +const DRAG_THRESHOLD_PX = 4; + +type DragCandidate = { + itemId: string; + pointerId: number; + startX: number; + dragging: boolean; + target: AppStatusDropTarget | null; +}; + +export function useAppStatusBarDrag({ + moveItem, +}: { + moveItem: (itemId: string, side: "left" | "right", activeIndex: number) => void; +}) { + const barRef = useRef(null); + const candidate = useRef(null); + const suppressedClick = useRef(false); + const [draggingId, setDraggingId] = useState(null); + + const onItemPointerDown = useCallback((itemId: string, event: ReactPointerEvent) => { + if (event.pointerType !== "mouse" || event.button !== 0 || (!event.metaKey && !event.ctrlKey)) { + return; + } + event.preventDefault(); + barRef.current?.setPointerCapture?.(event.pointerId); + candidate.current = { + itemId, + pointerId: event.pointerId, + startX: event.clientX, + dragging: false, + target: null, + }; + }, []); + + const onPointerMove = useCallback((event: ReactPointerEvent) => { + const current = candidate.current; + if (!current || current.pointerId !== event.pointerId) return; + if (!current.dragging && Math.abs(event.clientX - current.startX) <= DRAG_THRESHOLD_PX) return; + current.dragging = true; + document.getSelection()?.removeAllRanges(); + setDraggingId(current.itemId); + current.target = readDropTarget(barRef.current, current.itemId, event.clientX); + event.preventDefault(); + }, []); + + const finishDrag = useCallback( + (event: ReactPointerEvent, cancelled: boolean) => { + const current = candidate.current; + if (!current || current.pointerId !== event.pointerId) return; + candidate.current = null; + setDraggingId(null); + if (!current.dragging) return; + event.preventDefault(); + suppressedClick.current = true; + window.setTimeout(() => { + suppressedClick.current = false; + }, 0); + if (!cancelled && current.target) { + moveItem(current.itemId, current.target.side, current.target.activeIndex); + } + }, + [moveItem], + ); + + const onClickCapture = useCallback((event: ReactMouseEvent) => { + if (!suppressedClick.current) return; + suppressedClick.current = false; + event.preventDefault(); + event.stopPropagation(); + }, []); + + return { + barRef, + draggingId, + onItemPointerDown, + barHandlers: { + onPointerMove, + onPointerUp: (event: ReactPointerEvent) => finishDrag(event, false), + onPointerCancel: (event: ReactPointerEvent) => finishDrag(event, true), + onClickCapture, + }, + }; +} + +function readDropTarget( + bar: HTMLElement | null, + draggedItemId: string, + clientX: number, +): AppStatusDropTarget | null { + if (!bar) return null; + const spacer = bar.querySelector("[data-testid='app-status-bar-spacer']"); + if (!spacer) return null; + const items: AppStatusItemGeometry[] = []; + for (const element of bar.querySelectorAll("[data-status-item-id]")) { + const id = element.dataset.statusItemId; + const side = element.dataset.statusSide; + if (!id || id === draggedItemId || (side !== "left" && side !== "right")) continue; + const rect = element.getBoundingClientRect(); + items.push({ id, side, left: rect.left, right: rect.right }); + } + const spacerRect = spacer.getBoundingClientRect(); + return resolveAppStatusDropTarget(clientX, items, spacerRect); +} diff --git a/apps/web/components/app-status-bar/use-app-status-bar-order.ts b/apps/web/components/app-status-bar/use-app-status-bar-order.ts new file mode 100644 index 0000000000..25296b7726 --- /dev/null +++ b/apps/web/components/app-status-bar/use-app-status-bar-order.ts @@ -0,0 +1,59 @@ +"use client"; + +import { useCallback, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { createQueuedUserSettingsSync } from "@/lib/user-settings-sync"; +import type { AppStatusBarOrderState } from "@/lib/state/slices/settings/types"; +import { + moveAppStatusItem, + projectActiveStatusItems, + reconcileAppStatusBarOrder, + type AppStatusBarSide, + type AppStatusItemDescriptor, +} from "./app-status-bar-order"; + +const syncAppStatusBarOrder = createQueuedUserSettingsSync((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); + const store = useAppStoreApi(); + const [optimisticOrder, setOptimisticOrder] = useState(null); + const latestRevision = useRef(0); + const reconciledOrder = useMemo( + () => reconcileAppStatusBarOrder(savedOrder, activeItems), + [activeItems, savedOrder], + ); + const order = optimisticOrder ?? reconciledOrder; + const projected = useMemo( + () => projectActiveStatusItems(order, activeItems), + [activeItems, order], + ); + + const moveItem = useCallback( + (itemId: string, side: AppStatusBarSide, activeIndex: number) => { + const next = moveAppStatusItem(order, itemId, side, activeIndex, activeItems); + const revision = ++latestRevision.current; + setOptimisticOrder(next); + void syncAppStatusBarOrder(next) + .then(() => { + const state = store.getState(); + state.setUserSettings({ ...state.userSettings, appStatusBarOrder: next }); + if (latestRevision.current === revision) setOptimisticOrder(null); + }) + .catch(() => { + if (latestRevision.current !== revision) return; + setOptimisticOrder(null); + toast.error("Could not save status bar order"); + }); + }, + [activeItems, order, store], + ); + + return { projected, moveItem }; +} diff --git a/apps/web/components/config-chat/config-chat-panel.tsx b/apps/web/components/config-chat/config-chat-panel.tsx index 6ed1a771fe..10b5738ffb 100644 --- a/apps/web/components/config-chat/config-chat-panel.tsx +++ b/apps/web/components/config-chat/config-chat-panel.tsx @@ -144,7 +144,7 @@ export const ConfigChatPanel = memo(function ConfigChatPanel({ aria-hidden={panel.isOpen} tabIndex={panel.isOpen ? -1 : undefined} className={cn( - "fixed bottom-6 right-6 z-50 h-12 w-12 cursor-pointer rounded-full shadow-lg", + "fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-50 h-12 w-12 cursor-pointer rounded-full shadow-lg", panel.isOpen && "pointer-events-none opacity-0", )} aria-label="Configuration Chat" diff --git a/apps/web/components/diff/walkthrough-floating-window.tsx b/apps/web/components/diff/walkthrough-floating-window.tsx index 6ef9d21fd4..9a3332cef4 100644 --- a/apps/web/components/diff/walkthrough-floating-window.tsx +++ b/apps/web/components/diff/walkthrough-floating-window.tsx @@ -236,7 +236,7 @@ export function WalkthroughFloatingWindow({ data-testid="walkthrough-floating" data-mobile-variant="bottom-sheet" className={cn( - "fixed inset-x-2 bottom-2 z-[43] max-h-[calc(100dvh-1rem)] overflow-y-auto rounded-xl", + "fixed inset-x-2 bottom-[calc(0.5rem+var(--app-status-bar-height))] z-[43] max-h-[calc(100dvh-1rem)] overflow-y-auto rounded-xl", "sm:inset-x-auto sm:bottom-auto sm:w-[400px] sm:max-w-[calc(100vw-2rem)]", )} style={isDesktop && position ? { left: position.x, top: position.y } : undefined} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index ab18e72c12..0c841ffd93 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -408,11 +408,11 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB ); if (!s.isMounted) { - return
; + return
; } return ( -
+
+
); @@ -385,7 +385,7 @@ function DesktopPreviewSurface({ isOpen: boolean; }) { return ( -
+
{shouldFloat ? : }
); @@ -443,7 +443,7 @@ function FloatingPreviewLayout({ aria-label="Close preview" />
({ ), })); -vi.mock("@/components/system-metrics/topbar-metrics", () => ({ - TopbarMetrics: () =>
, -})); - vi.mock("./mobile-menu-sheet", () => ({ MobileMenuSheet: () => null, })); diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index 5e2cb62564..fce05d4d6e 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -134,6 +134,7 @@ export function KanbanHeaderMobile({ title={title} backLabel="" leading={} + showStatusTrigger={false} className="h-10 px-3 py-1" variant="root" leftActions={ diff --git a/apps/web/components/kanban/mobile-menu-sheet.tsx b/apps/web/components/kanban/mobile-menu-sheet.tsx index 6fc14d179e..af7049e5eb 100644 --- a/apps/web/components/kanban/mobile-menu-sheet.tsx +++ b/apps/web/components/kanban/mobile-menu-sheet.tsx @@ -11,6 +11,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { ToggleGroup, ToggleGroupItem } from "@kandev/ui/toggle-group"; import { IconAlertTriangle, + IconActivity, IconLayoutKanban, IconList, IconSettings, @@ -25,6 +26,7 @@ import { cn } from "@/lib/utils"; import type { Repository } from "@/lib/types/http"; import type { WorkflowsState } from "@/lib/state/slices"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider"; type MobileMenuSheetProps = { open: boolean; @@ -254,15 +256,32 @@ function MobileUtilityActions({ onOpenHealthDialog: () => void; onOpenChange: (open: boolean) => void; }) { + const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer(); const closeSheet = () => onOpenChange(false); const openHealth = () => { closeSheet(); onOpenHealthDialog(); }; + const openStatus = () => { + closeSheet(); + requestAnimationFrame(openStatusDrawer); + }; return (
Utilities
+ {statusDrawerEnabled && ( + + )}
)} + {showStatusTrigger ? ( + + ) : null}
); }); diff --git a/apps/web/components/plugins/plugin-page.tsx b/apps/web/components/plugins/plugin-page.tsx index a606f0479a..97712dbadf 100644 --- a/apps/web/components/plugins/plugin-page.tsx +++ b/apps/web/components/plugins/plugin-page.tsx @@ -65,7 +65,7 @@ export function PluginPageFrame({ registration, children }: PluginPageFrameProps const Icon = lookupPluginIcon(chrome.icon); const Actions = chrome.Actions; return ( -
+
pluginRegistry.unregisterPlugin(id)); } -describe("PluginSlot", () => { - afterEach(() => { - cleanup(); - cleanupPlugins("plugin-a", "plugin-b"); - }); +afterEach(() => { + cleanup(); + cleanupPlugins("plugin-a", "plugin-b", "plugin-c"); +}); +describe("PluginSlot", () => { it("renders nothing when no plugin has registered a component for the slot", () => { const { container } = render(); expect(container.innerHTML).toBe(""); @@ -101,7 +102,9 @@ describe("PluginSlot", () => { expect(screen.getByTestId("slot-b")).not.toBeNull(); consoleError.mockRestore(); }); +}); +describe("PluginSlot registration identity", () => { it("isolates a throwing slot component so a sibling still renders", () => { // eslint-disable-next-line no-console -- expected error boundary log, assert + silence it const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -116,6 +119,69 @@ describe("PluginSlot", () => { render(); expect(screen.getByTestId("slot-b")).not.toBeNull(); + expect(consoleError).toHaveBeenCalledWith( + '[plugins] plugin "plugin-a" slot "task-sidebar" component threw', + expect.any(Error), + expect.any(String), + ); consoleError.mockRestore(); }); + + it("does not give a healthy plugin the removed plugin's error state", () => { + // eslint-disable-next-line no-console -- expected error boundary log, assert + silence it + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + pluginRegistry.forPlugin("plugin-a").registerComponent(SLOT, () => { + throw new Error("boom"); + }); + pluginRegistry + .forPlugin("plugin-b") + .registerComponent(SLOT, () =>
B
); + + render(); + + act(() => { + pluginRegistry.unregisterPlugin("plugin-a"); + }); + + expect(screen.getByTestId("slot-b")).not.toBeNull(); + consoleError.mockRestore(); + }); + + it("keeps healthy plugin component identity through live slot changes", () => { + let nextMountId = 0; + function StatefulSlot({ id }: { id: string }) { + const [mountId] = useState(() => ++nextMountId); + return
{mountId}
; + } + + pluginRegistry + .forPlugin("plugin-a") + .registerComponent(SLOT, () => ); + pluginRegistry + .forPlugin("plugin-b") + .registerComponent(SLOT, () => ); + + render(); + + expect(screen.getByTestId("slot-a").textContent).toBe("1"); + expect(screen.getByTestId("slot-b").textContent).toBe("2"); + + act(() => { + pluginRegistry + .forPlugin("plugin-c") + .registerComponent(SLOT, () => ); + }); + + expect(screen.getByTestId("slot-a").textContent).toBe("1"); + expect(screen.getByTestId("slot-b").textContent).toBe("2"); + expect(screen.getByTestId("slot-c").textContent).toBe("3"); + + act(() => { + pluginRegistry.unregisterPlugin("plugin-a"); + }); + + expect(screen.getByTestId("slot-b").textContent).toBe("2"); + expect(screen.getByTestId("slot-c").textContent).toBe("3"); + }); }); diff --git a/apps/web/components/plugins/plugin-slot.tsx b/apps/web/components/plugins/plugin-slot.tsx index 5f9a6b06a4..d7fa3f5f2d 100644 --- a/apps/web/components/plugins/plugin-slot.tsx +++ b/apps/web/components/plugins/plugin-slot.tsx @@ -1,7 +1,7 @@ "use client"; import { usePluginRegistry } from "@/lib/plugins/registry"; -import type { SlotComponent } from "@/lib/plugins/types"; +import type { PluginSlotRegistration } from "@/lib/plugins/registry"; import { PluginErrorBoundary } from "./plugin-error-boundary"; export type PluginSlotProps = { @@ -26,22 +26,39 @@ export type PluginSlotProps = { */ export function PluginSlot({ name, slotProps, ownerPluginId }: PluginSlotProps) { const registry = usePluginRegistry(); - const components = ownerPluginId - ? registry.getSlotComponentsForPlugin(name, ownerPluginId) - : registry.getSlotComponents(name); + const registrations = registry + .getSlotRegistrations(name) + .filter((registration) => !ownerPluginId || registration.pluginId === ownerPluginId); - if (components.length === 0) return null; - - // Key by owner too, so navigating A -> B resets the boundary instead of reusing A's error (hiding B). - const keyBase = ownerPluginId ? `${name}-${ownerPluginId}` : name; + if (registrations.length === 0) return null; return ( <> - {components.map((SlotComponentImpl: SlotComponent, index) => ( - - - + {registrations.map((registration) => ( + ))} ); } + +export function PluginSlotRegistrationView({ + registration, + name, + slotProps, +}: { + registration: PluginSlotRegistration; + name: string; + slotProps?: unknown; +}) { + const { pluginId, Component } = registration; + return ( + + + + ); +} diff --git a/apps/web/components/review/walkthrough-overlay.tsx b/apps/web/components/review/walkthrough-overlay.tsx index e897c8894d..fed95d0fa1 100644 --- a/apps/web/components/review/walkthrough-overlay.tsx +++ b/apps/web/components/review/walkthrough-overlay.tsx @@ -46,7 +46,7 @@ function WalkthroughLauncher({ stepCount, }: WalkthroughLauncherProps) { return ( -
+
+ )} } + showStatusTrigger={false} className="h-10" actions={showIntegrationCopyAction ? : undefined} /> diff --git a/apps/web/components/settings/settings-save-provider.test.tsx b/apps/web/components/settings/settings-save-provider.test.tsx index d25cf78416..8a4554fe6e 100644 --- a/apps/web/components/settings/settings-save-provider.test.tsx +++ b/apps/web/components/settings/settings-save-provider.test.tsx @@ -113,6 +113,18 @@ afterEach(() => { }); describe("SettingsSaveProvider", () => { + it("offsets the standalone save action above the app status bar", async () => { + render( + + + , + ); + + expect((await screen.findByTestId("settings-floating-save")).className).toContain( + "var(--app-status-bar-height)", + ); + }); + it("saves dirty contributors in stable order and retries only failures", async () => { const calls: string[] = []; let failSecond = true; diff --git a/apps/web/components/settings/system-metrics-settings-card.test.tsx b/apps/web/components/settings/system-metrics-settings-card.test.tsx index a95dbcb3ea..4760fc79e4 100644 --- a/apps/web/components/settings/system-metrics-settings-card.test.tsx +++ b/apps/web/components/settings/system-metrics-settings-card.test.tsx @@ -33,6 +33,7 @@ describe("SystemMetricsSettingsCard", () => { ); const cpuMetric = await screen.findByRole("checkbox", { name: "CPU %" }); + expect(screen.getByRole("checkbox", { name: "System load (1 min)" })).toBeTruthy(); await waitFor(() => expect(cpuMetric.getAttribute("data-state")).toBe("checked")); fireEvent.click(cpuMetric); diff --git a/apps/web/components/settings/system-metrics-settings-card.tsx b/apps/web/components/settings/system-metrics-settings-card.tsx index 66a4e20d31..bace8f3782 100644 --- a/apps/web/components/settings/system-metrics-settings-card.tsx +++ b/apps/web/components/settings/system-metrics-settings-card.tsx @@ -16,7 +16,7 @@ const METRIC_OPTIONS: Array<{ id: SystemMetricId; label: string }> = [ { id: "memory_percent", label: "Memory %" }, { id: "disk_percent", label: "Disk %" }, { id: "cpu_temp", label: "CPU temp" }, - { id: "io_load", label: "Load avg" }, + { id: "io_load", label: "System load (1 min)" }, ]; const DEFAULT_METRICS_SETTINGS: SystemMetricsGlobalSettings = { @@ -89,7 +89,7 @@ export function SystemMetricsSettingsCard({

Useful when Kandev is self-hosted on a remote server and you want a lightweight view of - the machine resources from the kanban or task topbar. + the machine resources from the global status bar or phone Status drawer.

- +

- Collection starts only while at least one client displays metrics. + Shows Kandev host values only. Collection starts while a client displays them.

- Adds agentctl values for Docker, SSH, Sprites, and remote executors. + Makes agentctl values available to plugins and other consumers. The built-in status bar + remains host-only.

({ isMobile: false })); +const subscribeMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ + breakpoint: responsiveState.isMobile ? "mobile" : "desktop", + isMobile: responsiveState.isMobile, + isTablet: false, + isDesktop: !responsiveState.isMobile, + isCompactDesktop: false, + isFullDesktop: !responsiveState.isMobile, + isFinePointer: !responsiveState.isMobile, + usesDesktopWorkbench: !responsiveState.isMobile, + }), +})); + +vi.mock("@/hooks/use-system-metrics-subscription", () => ({ + useSystemMetricsSubscription: subscribeMock, +})); + +function renderMetrics(drawerOpen = false) { + return render( + + } + > + + + + , + ); +} + +describe("StatusSurfaceMetrics", () => { + beforeEach(() => { + responsiveState.isMobile = false; + subscribeMock.mockClear(); + }); + + afterEach(cleanup); + + it("subscribes and renders in the desktop status bar", async () => { + renderMetrics(); + + expect(subscribeMock).toHaveBeenCalledWith(true); + expect(screen.getByTestId("app-status-metrics")).toBeTruthy(); + expect(screen.getByLabelText("CPU 42%")).toBeTruthy(); + expect(screen.getByLabelText("Memory 51%")).toBeTruthy(); + expect(screen.getByLabelText("Disk 63%")).toBeTruthy(); + const systemLoad = screen.getByLabelText("System load (1 min) 2.5"); + expect(systemLoad).toBeTruthy(); + fireEvent.focus(systemLoad); + expect( + await screen.findAllByText( + "Average number of tasks running or waiting for CPU during the last minute. Compare this value with the host's CPU core count.", + ), + ).not.toHaveLength(0); + expect(screen.queryByLabelText("Executor metrics")).toBeNull(); + expect(screen.queryByLabelText("Memory 99%")).toBeNull(); + }); + + it("does not subscribe or render while the phone Status drawer is closed", () => { + responsiveState.isMobile = true; + renderMetrics(false); + + expect(subscribeMock).toHaveBeenCalledWith(false); + expect(screen.queryByTestId("app-status-metrics")).toBeNull(); + }); + + it("subscribes and renders rows when the phone Status drawer opens", () => { + responsiveState.isMobile = true; + renderMetrics(true); + + expect(subscribeMock).toHaveBeenCalledWith(true); + expect(screen.getByLabelText("Host metrics").parentElement?.className).toContain("min-h-11"); + }); +}); diff --git a/apps/web/components/system-metrics/status-surface-metrics.tsx b/apps/web/components/system-metrics/status-surface-metrics.tsx new file mode 100644 index 0000000000..553f92a8ac --- /dev/null +++ b/apps/web/components/system-metrics/status-surface-metrics.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { + IconActivity, + IconCpu, + IconDatabase, + IconDisc, + IconFlame, + IconGauge, + IconServer, +} from "@tabler/icons-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { formatDistanceToNow } from "date-fns"; +import { useAppStore } from "@/components/state-provider"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useSystemMetricsSubscription } from "@/hooks/use-system-metrics-subscription"; +import type { SystemMetricSample, SystemMetricsSource } from "@/lib/types/system"; + +type StatusSurfaceMetricsProps = { + presentation: "bar" | "mobile-drawer"; + density: "full" | "compact"; + drawerOpen: boolean; +}; + +export function StatusSurfaceMetrics({ + presentation, + density, + drawerOpen, +}: StatusSurfaceMetricsProps) { + // Wire/storage name stays stable for existing user settings and API payloads. + const enabled = useAppStore((state) => state.userSettings.systemMetricsDisplay.showInTopbar); + const snapshot = useAppStore((state) => state.system.metrics); + const { isMobile } = useResponsiveBreakpoint(); + const shouldSubscribe = enabled && (!isMobile || drawerOpen); + useSystemMetricsSubscription(shouldSubscribe); + + if (!enabled || (isMobile && !drawerOpen)) return null; + + const host = snapshot?.sources.find((source) => source.kind === "backend"); + if (presentation === "mobile-drawer") { + return ( +
+

System metrics

+ {!host ? ( + + ) : ( + + )} +
+ ); + } + + return ( +
+ {!host ? ( + + ) : ( + + )} +
+ ); +} + +function EmptyMetrics({ drawer = false }: { drawer?: boolean }) { + return ( +
+ + Metrics unavailable +
+ ); +} + +function BarSourceMetrics({ + source, + updatedAt, + showSourceLabel, + metricLimit, +}: { + source: SystemMetricsSource; + updatedAt?: string; + showSourceLabel: boolean; + metricLimit: number; +}) { + return ( +
+ + +
+ ); +} + +function DrawerSourceMetrics({ + source, + updatedAt, +}: { + source: SystemMetricsSource; + updatedAt?: string; +}) { + return ( +
+ +
+ +
+
+ ); +} + +function SourceBadge({ + source, + updatedAt, + showLabel, +}: { + source: SystemMetricsSource; + updatedAt?: string; + showLabel: boolean; +}) { + return ( + + + + + {showLabel ? Host : null} + + + +
+
Host
+
{source.label}
+
{lastUpdatedText(updatedAt)}
+
+
+
+ ); +} + +function MetricValues({ + source, + updatedAt, + limit, +}: { + source: SystemMetricsSource; + updatedAt?: string; + limit: number; +}) { + const metrics = source.metrics.slice(0, limit); + if (metrics.length === 0) return -; + return ( + + {metrics.map((metric) => ( + + ))} + + ); +} + +function MetricValue({ + metric, + source, + updatedAt, +}: { + metric: SystemMetricSample; + source: SystemMetricsSource; + updatedAt?: string; +}) { + const help = + metric.id === "io_load" + ? "Average number of tasks running or waiting for CPU during the last minute. Compare this value with the host's CPU core count." + : null; + return ( + + + + {metricIcon(metric.id)} + + + {formatMetric(metric)} + + + + +
+
{metricLabel(metric.id)}
+ {help ?
{help}
: null} +
Host: {source.label}
+
{formatMetric(metric)}
+ {metric.error ? ( +
{metric.error}
+ ) : null} +
{lastUpdatedText(updatedAt)}
+
+
+
+ ); +} + +function metricLabel(id: string) { + return ( + { + cpu_percent: "CPU", + memory_percent: "Memory", + disk_percent: "Disk", + cpu_temp: "CPU temperature", + io_load: "System load (1 min)", + }[id] ?? id + ); +} + +function metricIcon(id: string) { + const Icon = + { + cpu_percent: IconCpu, + memory_percent: IconDatabase, + disk_percent: IconDisc, + cpu_temp: IconFlame, + io_load: IconGauge, + }[id] ?? IconActivity; + return ; +} + +function MetricMeter({ metric }: { metric: SystemMetricSample }) { + if (metric.unit !== "%" || typeof metric.value !== "number") return null; + const width = `${Math.max(0, Math.min(100, metric.value))}%`; + return ( +