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
This commit is contained in:
José Almeida
2026-07-23 12:28:54 +01:00
committed by GitHub
parent 7de788ceed
commit 9c16d337a2
141 changed files with 4727 additions and 771 deletions
@@ -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();
});
@@ -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),
@@ -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"])
}
}
@@ -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.
@@ -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)
}
@@ -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
+18 -12
View File
@@ -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)
}
+32 -15
View File
@@ -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)
}
}
}
@@ -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",
+21 -8
View File
@@ -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,
},
},
}
@@ -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 {
@@ -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 {
+3
View File
@@ -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),
}
@@ -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})
@@ -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.
@@ -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),
}
@@ -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 {
@@ -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
}
@@ -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 {
+1 -1
View File
@@ -503,7 +503,7 @@ export function GitHubPageClient({
};
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageHeader onOpenMobileSidebar={loaded && authed ? onOpenMobileSidebar : undefined} />
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking GitHub status…</div>}
{loaded && !authed && (
+1 -1
View File
@@ -545,7 +545,7 @@ export function GitLabPageClient({
const onOpenMobileSidebar = useCallback(() => setMobileSidebarOpen(true), []);
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageHeader
host={host}
onOpenMobileSidebar={!statusLoading && connected ? onOpenMobileSidebar : undefined}
+7
View File
@@ -71,6 +71,7 @@
}
:root {
--app-status-bar-height: 0px;
--font-geist-sans: "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-sans: "Figtree", "Geist", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--font-geist-mono:
@@ -131,6 +132,12 @@
--git-deletion: 244 112 103;
}
@media (min-width: 640px) {
:root {
--app-status-bar-height: 1.5rem;
}
}
.dark {
--background: #181818;
--foreground: #d4d4d4;
+1 -1
View File
@@ -328,7 +328,7 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient
const onOpenTicket = useCallback((ticket: JiraTicket) => setOpenTicket(ticket), []);
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar
title="Jira"
subtitle="Tickets across your Atlassian projects."
+7 -4
View File
@@ -11,6 +11,7 @@ import { GlobalCommands } from "@/components/global-commands";
import { RecentTaskSwitcher } from "@/components/task/recent-task-switcher";
import { DiffWorkerPoolProvider } from "@/components/diff-worker-pool-provider";
import { AppSidebar } from "@/components/app-sidebar/app-sidebar";
import { AppStatusSurfaceProvider } from "@/components/app-status-bar/app-status-surface-provider";
import { QuickChatProvider } from "@/components/quick-chat/quick-chat-provider";
import { ConfigChatProvider } from "@/components/config-chat/config-chat-provider";
import { SessionFailureToastBridge } from "@/components/session-failure-toast-bridge";
@@ -86,10 +87,12 @@ export default async function RootLayout({
<RecentTaskSwitcher />
<ConfigChatProvider>
<QuickChatProvider>
<div className="flex h-screen min-h-0 w-full overflow-hidden">
<AppSidebar />
<div className="flex-1 min-w-0 min-h-0 flex flex-col">{children}</div>
</div>
<AppStatusSurfaceProvider>
<div className="flex min-h-0 flex-1 overflow-hidden">
<AppSidebar />
<div className="flex min-h-0 min-w-0 flex-1 flex-col">{children}</div>
</div>
</AppStatusSurfaceProvider>
</QuickChatProvider>
</ConfigChatProvider>
</CommandRegistryProvider>
@@ -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<string, string> = {
"/office": "Dashboard",
@@ -46,13 +47,14 @@ export function OfficeTopbar() {
return (
<div
data-testid="office-topbar"
className="flex items-center gap-2 px-4 h-10 border-b border-border bg-background shrink-0"
className="flex h-10 min-h-11 shrink-0 items-center gap-2 border-b border-border bg-background px-4 md:min-h-10"
>
{detail ? (
<div id="office-topbar-slot" className="flex items-center gap-2 flex-1 min-w-0" />
) : (
title && <h1 className="truncate text-sm font-medium text-foreground">{title}</h1>
)}
<AppStatusDrawerTrigger className="ml-auto" />
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import {
export default function StatsLoading() {
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<header className="flex items-center gap-3 p-4 pb-3 shrink-0">
<Skeleton className="h-8 w-16" />
<div className="flex items-center gap-2">
+3 -3
View File
@@ -67,7 +67,7 @@ interface StatsPageClientProps {
function StatsEmptyState({ message }: { message: string }) {
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center">
<p className="text-muted-foreground">{message}</p>
@@ -409,7 +409,7 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat
if (initialError)
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar title="Statistics" icon={<IconChartBar className="h-4 w-4" />} />
<div className="flex-1 flex items-center justify-center">
<p className="text-destructive">Error loading stats: {initialError}</p>
@@ -419,7 +419,7 @@ export function StatsPageClient({ workspaceId, activeRange, initialError }: Stat
if (!workspaceId) return <StatsEmptyState message="Select a workspace to view statistics." />;
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<StatsHeader
global={globalReady}
range={range}
@@ -73,7 +73,7 @@ export function KanbanTaskShell({
);
const simpleSlot = (
<div className="flex h-screen w-full flex-col overflow-y-auto bg-background p-6">
<div className="flex h-full min-h-0 w-full flex-col overflow-y-auto bg-background p-6">
{showOfficeLink && <CrossLinkRow taskId={taskId} target="office" />}
<div className="mt-4 max-w-3xl">
<TaskHeader
+1 -1
View File
@@ -503,7 +503,7 @@ export function TasksPageClient(props: TasksPageClientProps) {
}, [setMobileSearchOpen]);
return (
<div className="h-screen w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<KanbanHeader
workspaceId={s.activeWorkspaceId ?? undefined}
currentPage="tasks"
@@ -0,0 +1,120 @@
import { describe, expect, it } from "vitest";
import {
APP_STATUS_CONNECTION_ID,
APP_STATUS_METRICS_ID,
moveAppStatusItem,
projectActiveStatusItems,
reconcileAppStatusBarOrder,
resolveAppStatusDropTarget,
type AppStatusItemDescriptor,
} from "./app-status-bar-order";
const PLUGIN_LEFT_ID = "plugin:left";
const PLUGIN_RIGHT_ID = "plugin:right";
const DORMANT_ID = "dormant";
const DORMANT_RIGHT_ID = "dormant-right";
const items: AppStatusItemDescriptor[] = [
{ id: APP_STATUS_CONNECTION_ID, defaultSide: "left" },
{ id: PLUGIN_LEFT_ID, defaultSide: "left" },
{ id: APP_STATUS_METRICS_ID, defaultSide: "right" },
{ id: PLUGIN_RIGHT_ID, defaultSide: "right" },
];
describe("reconcileAppStatusBarOrder", () => {
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,
});
});
});
@@ -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<T extends AppStatusItemDescriptor> = {
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<string>();
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<T extends AppStatusItemDescriptor>(
order: AppStatusBarOrderState,
activeItems: T[],
): ProjectedStatusItems<T> {
const activeById = new Map<string, T>();
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>): 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<T>(ids: string[], activeById: Map<string, T>): T[] {
return ids.flatMap((id) => {
const item = activeById.get(id);
return item ? [item] : [];
});
}
@@ -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 }) => (
<output data-testid="status-slot-props">{JSON.stringify(received)}</output>
));
const registration = pluginRegistry.getSlotRegistrations(SLOT)[0];
render(<AppStatusBarPluginContribution {...slotProps} registration={registration} />);
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", () => <div data-testid="left-status-slot" />);
const registration = pluginRegistry.getSlotRegistrations("app-status-bar-left")[0];
render(
<AppStatusBarPluginContribution
{...slotProps}
placement="left"
registration={registration}
/>,
);
expect(screen.getByTestId("left-status-slot")).not.toBeNull();
});
});
@@ -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 (
<PluginSlotRegistrationView
key={registration.registrationId}
registration={registration}
name={name}
slotProps={slotProps}
/>
);
}
@@ -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, () => (
<span data-testid={LEFT_PLUGIN_ID}>Left</span>
));
pluginRegistry
.forPlugin(RIGHT_PLUGIN_ID)
.registerComponent("app-status-bar-right", () => (
<span data-testid={RIGHT_PLUGIN_ID}>Right</span>
));
render(
<StateProvider>
<TooltipProvider>
<AppStatusBar
pathname="/tasks/task-1"
activeWorkspaceId="workspace-1"
activeTaskId="task-1"
activeSessionId="session-1"
density="full"
/>
</TooltipProvider>
</StateProvider>,
);
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, () => (
<span data-testid={LEFT_PLUGIN_ID}>Left</span>
));
render(
<StateProvider>
<TooltipProvider>
<AppStatusBar
pathname="/"
activeWorkspaceId={null}
activeTaskId={null}
activeSessionId={null}
density="full"
/>
</TooltipProvider>
</StateProvider>,
);
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, () => (
<button type="button" onClick={onClick}>
Plugin action
</button>
));
pluginRegistry
.forPlugin(RIGHT_PLUGIN_ID)
.registerComponent("app-status-bar-right", () => <span>Right</span>);
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, () => (
<button type="button" onClick={onClick}>
Plugin action
</button>
));
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, () => <span>Left</span>);
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, () => <span>Healthy replacement</span>);
});
expect(screen.getByText("Healthy replacement")).toBeTruthy();
consoleError.mockRestore();
});
});
function renderBar() {
return render(
<StateProvider>
<TooltipProvider>
<AppStatusBar
pathname="/"
activeWorkspaceId={null}
activeTaskId={null}
activeSessionId={null}
density="full"
/>
</TooltipProvider>
</StateProvider>,
);
}
function statusItem(id: string): HTMLElement {
const item = document.querySelector<HTMLElement>(`[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;
}
@@ -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 (
<footer
ref={drag.barRef}
{...drag.barHandlers}
className={cn(
"relative flex h-6 shrink-0 select-none items-center gap-4 overflow-hidden bg-background px-3 text-xs font-medium leading-none text-foreground/80 antialiased [font-family:var(--font-geist-sans)] before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-px before:bg-border",
drag.draggingId && "cursor-grabbing",
)}
data-testid="app-status-bar"
aria-label="Application status"
>
<StatusItemGroup side="left" testId="app-status-bar-left-plugins">
{projected.left.map((item) => (
<BarStatusItem key={item.id} item={item} side="left" density={density} drag={drag} />
))}
</StatusItemGroup>
<div className="h-full min-w-0 flex-1" data-testid="app-status-bar-spacer" />
<StatusItemGroup side="right" testId="app-status-bar-right-plugins">
{projected.right.map((item) => (
<BarStatusItem key={item.id} item={item} side="right" density={density} drag={drag} />
))}
</StatusItemGroup>
</footer>
);
}
function StatusItemGroup({
side,
testId,
children,
}: {
side: "left" | "right";
testId: string;
children: React.ReactNode;
}) {
return (
<div
className="flex h-full min-w-0 items-center gap-3 overflow-hidden"
data-testid={testId}
data-status-side={side}
>
{children}
</div>
);
}
function BarStatusItem({
item,
side,
density,
drag,
}: {
item: AppStatusItem;
side: "left" | "right";
density: "full" | "compact";
drag: ReturnType<typeof useAppStatusBarDrag>;
}) {
return (
<div
className={cn(
"inline-flex h-full min-w-0 shrink-0 items-center leading-none transition-opacity duration-150 empty:hidden",
drag.draggingId === item.id && "opacity-45",
)}
data-status-item-id={item.id}
data-status-side={side}
onPointerDown={(event) => drag.onItemPointerDown(item.id, event)}
>
{item.render({ presentation: "bar", density, drawerOpen: false })}
</div>
);
}
@@ -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<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DrawerHeader: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div {...props}>{children}</div>
),
DrawerTitle: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
}));
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 <span data-testid={LEFT_PLUGIN_ID}>{props.presentation}</span>;
});
pluginRegistry
.forPlugin(RIGHT_PLUGIN_ID)
.registerComponent("app-status-bar-right", ({ slotProps }) => {
const props = slotProps as AppStatusBarSlotProps;
return <span data-testid={RIGHT_PLUGIN_ID}>{props.presentation}</span>;
});
const leftId = `plugin:${LEFT_PLUGIN_ID}:app-status-bar-left:0`;
const rightId = `plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0`;
render(
<StateProvider
initialState={{
userSettings: {
...defaultSettingsState.userSettings,
systemMetricsDisplay: { showInTopbar: true },
appStatusBarOrder: {
leftItemIds: [rightId, APP_STATUS_METRICS_ID],
rightItemIds: [APP_STATUS_CONNECTION_ID, leftId],
},
},
}}
>
<TooltipProvider>
<AppStatusDrawer
pathname="/tasks/task-1"
activeWorkspaceId="workspace-1"
activeTaskId="task-1"
activeSessionId="session-1"
open
onOpenChange={() => {}}
/>
</TooltipProvider>
</StateProvider>,
);
const rows = Array.from(
screen
.getByTestId("app-status-drawer")
.querySelectorAll<HTMLElement>("[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(
<StateProvider>
<TooltipProvider>
<AppStatusDrawer
pathname="/"
activeWorkspaceId={null}
activeTaskId={null}
activeSessionId={null}
open
onOpenChange={() => {}}
/>
</TooltipProvider>
</StateProvider>,
);
const row = document.querySelector<HTMLElement>(
`[data-status-item-id="plugin:${RIGHT_PLUGIN_ID}:app-status-bar-right:0"]`,
);
expect(row?.className).toContain("empty:hidden");
});
});
@@ -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 (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[min(32rem,calc(100dvh-16px))] max-h-[calc(100dvh-16px)] overflow-hidden pb-[max(0.5rem,env(safe-area-inset-bottom))]">
<div
data-testid="app-status-drawer"
className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-background"
>
<DrawerHeader className="shrink-0 border-b border-border/70 pb-3 text-left">
<DrawerTitle>Status</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-3">
<section className="space-y-1" aria-label="Application status items">
{orderedItems.map((item) => (
<DrawerStatusItem key={item.id} item={item} open={open} />
))}
</section>
</div>
</div>
</DrawerContent>
</Drawer>
);
}
function DrawerStatusItem({ item, open }: { item: AppStatusItem; open: boolean }) {
return (
<div
className="flex min-h-11 w-full min-w-0 items-center rounded-md px-3 hover:bg-muted/60 empty:hidden"
data-status-item-id={item.id}
>
{item.render({ presentation: "mobile-drawer", density: "full", drawerOpen: open })}
</div>
);
}
@@ -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 }) => <ConnectionStatusItem presentation={presentation} />,
};
}
function metricsItem(): AppStatusItem {
return {
id: APP_STATUS_METRICS_ID,
defaultSide: "right",
render: ({ presentation, density, drawerOpen }) => (
<StatusSurfaceMetrics presentation={presentation} density={density} drawerOpen={drawerOpen} />
),
};
}
function pluginItem(
registration: PluginSlotRegistration,
placement: "left" | "right",
context: AppStatusContext,
): AppStatusItem {
return {
id: registration.orderingId,
defaultSide: placement,
render: ({ presentation, density }) => (
<AppStatusBarPluginContribution
registration={registration}
{...context}
placement={placement}
presentation={presentation}
density={density}
/>
),
};
}
@@ -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: () => <div data-testid={STATUS_BAR_TEST_ID} />,
}));
vi.mock("./app-status-drawer", () => ({
AppStatusDrawer: ({ open }: { open: boolean }) => (
<div data-testid={STATUS_DRAWER_TEST_ID}>{String(open)}</div>
),
}));
function renderSurface() {
return render(
<StateProvider>
<AppStatusSurfaceProvider>
<AppStatusDrawerTrigger />
</AppStatusSurfaceProvider>
</StateProvider>,
);
}
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();
});
});
@@ -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<AppStatusDrawerContextValue | null>(null);
const unavailableDrawer: AppStatusDrawerContextValue = {
enabled: false,
drawerOpen: false,
openStatusDrawer: () => {},
setStatusDrawerOpen: () => {},
};
export function useAppStatusDrawer() {
const context = useContext(AppStatusDrawerContext);
return context ?? unavailableDrawer;
}
type AppStatusDrawerTriggerProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
label?: string;
};
export function AppStatusDrawerTrigger({
className,
children,
label = "Open status",
...buttonProps
}: AppStatusDrawerTriggerProps) {
const drawer = useContext(AppStatusDrawerContext);
if (!drawer?.enabled) return null;
return (
<Button
{...buttonProps}
type="button"
variant={buttonProps.variant ?? "ghost"}
size={buttonProps.size ?? "icon"}
className={cn("h-11 w-11 cursor-pointer sm:hidden", className)}
aria-label={label}
onClick={drawer.openStatusDrawer}
data-testid="app-status-drawer-trigger"
>
{children ?? <IconActivity className="h-4 w-4" />}
</Button>
);
}
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<AppStatusDrawerContextValue>(
() => ({
enabled: drawerEnabled,
drawerOpen,
openStatusDrawer: () => {
if (drawerEnabled) setStatusDrawerOpen(true);
},
setStatusDrawerOpen,
}),
[drawerEnabled, drawerOpen],
);
const surfaceProps = {
pathname,
activeWorkspaceId,
activeTaskId,
activeSessionId,
};
return (
<AppStatusDrawerContext.Provider value={drawer}>
<div className="flex h-dvh min-h-0 w-full flex-col overflow-hidden">
{children}
{appStatusBarEnabled &&
(isMobile ? (
<AppStatusDrawer
{...surfaceProps}
open={drawerOpen}
onOpenChange={setStatusDrawerOpen}
/>
) : (
<AppStatusBar {...surfaceProps} density={isFullDesktop ? "full" : "compact"} />
))}
</div>
</AppStatusDrawerContext.Provider>
);
}
@@ -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,
});
});
});
@@ -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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex h-full items-center leading-none",
presentation === "bar" ? "w-5 justify-center" : "min-h-11 gap-3 px-1 text-sm",
)}
role="status"
aria-label={details.description}
data-testid="app-status-connection"
>
<span
className={`size-1.5 shrink-0 rounded-full ${details.dotClass} ${details.animate ? "animate-pulse" : ""}`}
aria-hidden="true"
/>
<span className={presentation === "bar" ? "sr-only" : "text-foreground"}>
{presentation === "bar" ? details.label : details.description}
</span>
</span>
</TooltipTrigger>
<TooltipContent>{details.description}</TooltipContent>
</Tooltip>
);
}
@@ -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<HTMLElement>(null);
const candidate = useRef<DragCandidate | null>(null);
const suppressedClick = useRef(false);
const [draggingId, setDraggingId] = useState<string | null>(null);
const onItemPointerDown = useCallback((itemId: string, event: ReactPointerEvent<HTMLElement>) => {
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<HTMLElement>) => {
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<HTMLElement>, 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<HTMLElement>) => {
if (!suppressedClick.current) return;
suppressedClick.current = false;
event.preventDefault();
event.stopPropagation();
}, []);
return {
barRef,
draggingId,
onItemPointerDown,
barHandlers: {
onPointerMove,
onPointerUp: (event: ReactPointerEvent<HTMLElement>) => finishDrag(event, false),
onPointerCancel: (event: ReactPointerEvent<HTMLElement>) => finishDrag(event, true),
onClickCapture,
},
};
}
function readDropTarget(
bar: HTMLElement | null,
draggedItemId: string,
clientX: number,
): AppStatusDropTarget | null {
if (!bar) return null;
const spacer = bar.querySelector<HTMLElement>("[data-testid='app-status-bar-spacer']");
if (!spacer) return null;
const items: AppStatusItemGeometry[] = [];
for (const element of bar.querySelectorAll<HTMLElement>("[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);
}
@@ -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<AppStatusBarOrderState>((order) => ({
app_status_bar_order: {
left_item_ids: order.leftItemIds,
right_item_ids: order.rightItemIds,
},
}));
export function useAppStatusBarOrder<T extends AppStatusItemDescriptor>(activeItems: T[]) {
const savedOrder = useAppStore((state) => state.userSettings.appStatusBarOrder);
const store = useAppStoreApi();
const [optimisticOrder, setOptimisticOrder] = useState<AppStatusBarOrderState | null>(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 };
}
@@ -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"
@@ -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}
+2 -2
View File
@@ -408,11 +408,11 @@ export function KanbanBoard({ onPreviewTask, onOpenTask, onBeforeEdit }: KanbanB
);
if (!s.isMounted) {
return <div className="h-dvh w-full bg-background" />;
return <div className="h-full min-h-0 w-full bg-background" />;
}
return (
<div className="h-dvh w-full flex flex-col" data-testid="kanban-board">
<div className="flex h-full min-h-0 w-full flex-col" data-testid="kanban-board">
<HomepageCommands onCreateTask={s.handleCreate} />
<KanbanHeader
workspaceId={s.workspaceState.activeId ?? undefined}
+3 -3
View File
@@ -350,7 +350,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
// On mobile, skip the preview panel entirely — card clicks navigate directly
if (isMobile) {
return (
<div className="h-dvh w-full flex flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<KanbanBoard />
</div>
);
@@ -385,7 +385,7 @@ function DesktopPreviewSurface({
isOpen: boolean;
}) {
return (
<div ref={containerRef} className="h-screen w-full flex flex-col bg-background relative">
<div ref={containerRef} className="relative flex h-full min-h-0 w-full flex-col bg-background">
{shouldFloat ? <FloatingPreviewLayout {...props} /> : <InlinePreviewLayout {...props} />}
</div>
);
@@ -443,7 +443,7 @@ function FloatingPreviewLayout({
aria-label="Close preview"
/>
<div
className="fixed right-0 top-0 bottom-0 z-40 shadow-2xl bg-background flex"
className="fixed top-0 right-0 bottom-[var(--app-status-bar-height)] z-40 flex bg-background shadow-2xl"
style={{
width: `${previewWidthPx}px`,
maxWidth: `${PREVIEW_PANEL.MAX_WIDTH_VW}vw`,
@@ -25,10 +25,6 @@ vi.mock("@/components/page-topbar", () => ({
),
}));
vi.mock("@/components/system-metrics/topbar-metrics", () => ({
TopbarMetrics: () => <div data-testid="topbar-metrics" />,
}));
vi.mock("./mobile-menu-sheet", () => ({
MobileMenuSheet: () => null,
}));
@@ -134,6 +134,7 @@ export function KanbanHeaderMobile({
title={title}
backLabel=""
leading={<MobileBrandLink workspaceId={workspaceId} />}
showStatusTrigger={false}
className="h-10 px-3 py-1"
variant="root"
leftActions={
@@ -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 (
<div className="mt-auto flex flex-col gap-3 pt-4 border-t border-border">
<div className={mobileSectionTitleClass}>Utilities</div>
{statusDrawerEnabled && (
<Button
type="button"
variant="outline"
className="h-11 w-full cursor-pointer justify-start gap-3 px-3 text-sm"
onClick={openStatus}
data-testid="mobile-home-status-button"
>
<IconActivity className={mobileControlIconClass} />
Status
</Button>
)}
<Button
asChild
variant="outline"
@@ -145,7 +145,7 @@ export function TaskMultiSelectToolbar({
return (
<div
className={cn(
"fixed bottom-6 left-1/2 -translate-x-1/2 z-50",
"fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] left-1/2 z-50 -translate-x-1/2",
"flex items-center gap-2 px-4 py-2 rounded-xl shadow-lg border border-border",
"bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/75",
)}
+11 -1
View File
@@ -11,6 +11,7 @@ import {
BreadcrumbSeparator,
} from "@kandev/ui/breadcrumb";
import { cn } from "@kandev/ui/lib/utils";
import { AppStatusDrawerTrigger } from "@/components/app-status-bar/app-status-surface-provider";
type PageTopbarProps = {
/** Page title shown as the rightmost (current) breadcrumb */
@@ -41,6 +42,8 @@ type PageTopbarProps = {
className?: string;
centerClassName?: string;
actionsClassName?: string;
/** Phone-only Status entry point. Turn off where native route chrome owns it. */
showStatusTrigger?: boolean;
};
function BackLink({ href, label }: { href: string; label: string }) {
@@ -182,13 +185,17 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
className,
centerClassName,
actionsClassName,
showStatusTrigger = true,
},
ref,
) {
return (
<header
ref={ref}
className={cn("relative flex h-10 shrink-0 items-center gap-3 border-b px-3 py-1", className)}
className={cn(
"relative flex h-10 min-h-11 shrink-0 items-center gap-3 border-b px-3 py-1 md:min-h-10",
className,
)}
>
{leading}
<TopbarLeading
@@ -222,6 +229,9 @@ export const PageTopbar = forwardRef<HTMLElement, PageTopbarProps>(function Page
{actions}
</div>
)}
{showStatusTrigger ? (
<AppStatusDrawerTrigger className={cn(actions ? "ml-1" : "ml-auto", "shrink-0")} />
) : null}
</header>
);
});
+1 -1
View File
@@ -65,7 +65,7 @@ export function PluginPageFrame({ registration, children }: PluginPageFrameProps
const Icon = lookupPluginIcon(chrome.icon);
const Actions = chrome.Actions;
return (
<div className="flex h-screen w-full flex-col bg-background">
<div className="flex h-full min-h-0 w-full flex-col bg-background">
<PageTopbar
title={chrome.title}
subtitle={chrome.subtitle}
@@ -1,4 +1,5 @@
import { cleanup, render, screen } from "@testing-library/react";
import { act, cleanup, render, screen } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pluginRegistry } from "@/lib/plugins/registry";
import { PluginSlot } from "./plugin-slot";
@@ -10,12 +11,12 @@ function cleanupPlugins(...pluginIds: string[]) {
pluginIds.forEach((id) => 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(<PluginSlot name={SLOT} />);
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(<PluginSlot name={SLOT} />);
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, () => <div data-testid="slot-b">B</div>);
render(<PluginSlot name={SLOT} />);
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 <div data-testid={id}>{mountId}</div>;
}
pluginRegistry
.forPlugin("plugin-a")
.registerComponent(SLOT, () => <StatefulSlot id="slot-a" />);
pluginRegistry
.forPlugin("plugin-b")
.registerComponent(SLOT, () => <StatefulSlot id="slot-b" />);
render(<PluginSlot name={SLOT} />);
expect(screen.getByTestId("slot-a").textContent).toBe("1");
expect(screen.getByTestId("slot-b").textContent).toBe("2");
act(() => {
pluginRegistry
.forPlugin("plugin-c")
.registerComponent(SLOT, () => <StatefulSlot id="slot-c" />);
});
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");
});
});
+29 -12
View File
@@ -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) => (
<PluginErrorBoundary key={`${keyBase}-${index}`} context={`slot "${name}" component`}>
<SlotComponentImpl slotProps={slotProps} />
</PluginErrorBoundary>
{registrations.map((registration) => (
<PluginSlotRegistrationView
key={registration.registrationId}
registration={registration}
name={name}
slotProps={slotProps}
/>
))}
</>
);
}
export function PluginSlotRegistrationView({
registration,
name,
slotProps,
}: {
registration: PluginSlotRegistration;
name: string;
slotProps?: unknown;
}) {
const { pluginId, Component } = registration;
return (
<PluginErrorBoundary context={`plugin "${pluginId}" slot "${name}" component`}>
<Component slotProps={slotProps} />
</PluginErrorBoundary>
);
}
@@ -46,7 +46,7 @@ function WalkthroughLauncher({
stepCount,
}: WalkthroughLauncherProps) {
return (
<div className="group fixed bottom-6 right-6 z-[41]">
<div className="group fixed bottom-[calc(1.5rem+var(--app-status-bar-height))] right-6 z-[41]">
<button
type="button"
data-testid="walkthrough-launcher"
@@ -12,7 +12,9 @@ vi.mock("@/lib/api", () => ({
}));
vi.mock("@/components/agent-logo", () => ({
AgentLogo: ({ agentName }: { agentName: string }) => <span data-testid={`logo-${agentName}`} />,
AgentLogo: ({ agentName, className }: { agentName: string; className?: string }) => (
<span data-testid={`logo-${agentName}`} className={className} />
),
}));
afterEach(() => {
@@ -76,6 +78,8 @@ describe("AgentUsageSection", () => {
expect(screen.getByText("62%")).toBeDefined();
expect(screen.getByText("7d")).toBeDefined();
expect(screen.getByText("Codex")).toBeDefined();
expect(screen.getByTestId("logo-codex-acp").className).toContain("!opacity-100");
expect(screen.getByTestId("logo-codex-acp").className).toContain("brightness-100");
expect(screen.getByText("Could not fetch usage data.")).toBeDefined();
});
});
@@ -18,7 +18,7 @@ function AgentUsageCard({ item }: { item: AgentSubscriptionUsage }) {
<Card data-testid={`agent-usage-card-${item.agent_id}`}>
<CardContent className="py-4 space-y-4">
<div className="flex items-center gap-2">
<AgentLogo agentName={item.agent_id} className="shrink-0" />
<AgentLogo agentName={item.agent_id} className="shrink-0 !opacity-100 brightness-100" />
<h4 className="font-medium">{item.display_name}</h4>
{usage?.plan && (
<Badge variant="secondary" className="capitalize">
@@ -10,6 +10,7 @@ import type { EditorOption } from "@/lib/types/http";
import { type ComboboxOption } from "@/components/combobox";
import {
parseChangesPanelLayout,
parseAppStatusBarOrder,
parseSystemMetricsDisplay,
parseTerminalLinkBehavior,
taskCreateLastUsedHasValue,
@@ -315,6 +316,7 @@ function buildUserSettingsFromResponse(
terminalFontSize: s.terminal_font_size || null,
changesPanelLayout: parseChangesPanelLayout(s.changes_panel_layout),
systemMetricsDisplay: parseSystemMetricsDisplay(s.system_metrics_display),
appStatusBarOrder: parseAppStatusBarOrder(s.app_status_bar_order),
voiceMode: parseVoiceMode(s.voice_mode),
...mapEditorSettingsFields(s),
};
@@ -54,7 +54,7 @@ export function SettingsFloatingSave({
className={cn(
"pointer-events-none z-40 max-w-[calc(100vw_-_2rem_-_env(safe-area-inset-left)_-_env(safe-area-inset-right))]",
!isHostedByConfigChat &&
"fixed right-[calc(1rem_+_env(safe-area-inset-right))] bottom-[calc(5.25rem_+_env(safe-area-inset-bottom))]",
"fixed right-[calc(1rem_+_env(safe-area-inset-right))] bottom-[calc(5.25rem_+_env(safe-area-inset-bottom)_+_var(--app-status-bar-height))]",
)}
data-testid="settings-floating-save"
data-dirty-contributors={dirtyContributorIds}
@@ -5,7 +5,8 @@ import { usePathname } from "@/lib/routing/client-router";
import { Button } from "@kandev/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@kandev/ui/sheet";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { IconHome, IconMenu2 } from "@tabler/icons-react";
import { IconActivity, IconHome, IconMenu2 } from "@tabler/icons-react";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { PageTopbar } from "@/components/page-topbar";
import Link from "@/components/routing/app-link";
import { SettingsTree } from "@/components/app-sidebar/sections/settings/settings-tree";
@@ -142,12 +143,17 @@ function IntegrationCopyConfigAction() {
function SettingsMobileMenu({ pathname }: { pathname: string }) {
const [open, setOpen] = useState(false);
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const closeOnLinkClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target instanceof Element && event.target.closest("a[href]")) {
setOpen(false);
}
};
const openStatus = () => {
setOpen(false);
requestAnimationFrame(openStatusDrawer);
};
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -156,7 +162,7 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) {
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 cursor-pointer md:hidden"
className="h-11 w-11 cursor-pointer md:hidden"
aria-label="Open settings menu"
data-testid="settings-mobile-menu-button"
>
@@ -172,6 +178,18 @@ function SettingsMobileMenu({ pathname }: { pathname: string }) {
<SheetTitle>Settings</SheetTitle>
</SheetHeader>
<nav className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto p-3">
{statusDrawerEnabled && (
<Button
type="button"
variant="ghost"
className="h-11 cursor-pointer justify-start gap-2.5 px-2.5"
onClick={openStatus}
data-testid="settings-mobile-status-button"
>
<IconActivity className="h-4 w-4 shrink-0" />
<span>Status</span>
</Button>
)}
<Link
href="/"
className="flex h-10 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm font-medium text-foreground/80 transition-colors hover:bg-muted hover:text-foreground"
@@ -224,6 +242,7 @@ function SettingsShell({
backLabel={backLabel}
parents={parents}
leading={<SettingsMobileMenu pathname={pathname} />}
showStatusTrigger={false}
className="h-10"
actions={showIntegrationCopyAction ? <IntegrationCopyConfigAction /> : undefined}
/>
@@ -113,6 +113,18 @@ afterEach(() => {
});
describe("SettingsSaveProvider", () => {
it("offsets the standalone save action above the app status bar", async () => {
render(
<SettingsSaveProvider>
<DraftContributor id="appearance" onSave={vi.fn()} />
</SettingsSaveProvider>,
);
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;
@@ -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);
@@ -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({
<CardContent className="space-y-5">
<p className="max-w-3xl text-sm text-muted-foreground">
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.
</p>
<MetricsDisplayToggle
checked={showInTopbar}
@@ -127,9 +127,9 @@ function MetricsDisplayToggle({
return (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="show-system-metrics">Show in topbars</Label>
<Label htmlFor="show-system-metrics">Show host metrics in status bar</Label>
<p className="text-xs text-muted-foreground">
Collection starts only while at least one client displays metrics.
Shows Kandev host values only. Collection starts while a client displays them.
</p>
</div>
<Switch
@@ -246,7 +246,8 @@ function ExecutionMetricsToggle({
<div className="space-y-1">
<Label htmlFor="collect-execution-metrics">Collect execution environment metrics</Label>
<p className="text-xs text-muted-foreground">
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.
</p>
</div>
<Switch
@@ -0,0 +1,150 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StateProvider } from "@/components/state-provider";
import { defaultSettingsState } from "@/lib/state/slices/settings/settings-slice";
import { defaultSystemState } from "@/lib/state/slices/system/system-slice";
import type { AppState } from "@/lib/state/store";
import { StatusSurfaceMetrics } from "./status-surface-metrics";
const responsiveState = vi.hoisted(() => ({ 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(
<StateProvider
initialState={
{
userSettings: {
...defaultSettingsState.userSettings,
loaded: true,
systemMetricsDisplay: { showInTopbar: true },
},
system: {
...defaultSystemState.system,
metrics: {
timestamp: "2026-06-23T10:00:00Z",
interval_seconds: 5,
sources: [
{
id: "backend",
label: "Host",
kind: "backend",
metrics: [
{
id: "cpu_percent",
label: "CPU",
unit: "%",
value: 42,
available: true,
},
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 51,
available: true,
},
{
id: "disk_percent",
label: "Disk",
unit: "%",
value: 63,
available: true,
},
{ id: "io_load", label: "Load average", value: 2.5, available: true },
],
},
{
id: "executor-session-1",
label: "Demo executor",
kind: "execution",
session_id: "session-1",
metrics: [
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 99,
available: true,
},
],
},
],
},
},
} satisfies Partial<AppState>
}
>
<TooltipProvider>
<StatusSurfaceMetrics
presentation={responsiveState.isMobile ? "mobile-drawer" : "bar"}
density="full"
drawerOpen={drawerOpen}
/>
</TooltipProvider>
</StateProvider>,
);
}
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");
});
});
@@ -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 (
<section data-testid="app-status-metrics" className="space-y-1" aria-label="System metrics">
<h3 className="px-1 text-sm font-medium">System metrics</h3>
{!host ? (
<EmptyMetrics drawer />
) : (
<DrawerSourceMetrics source={host} updatedAt={snapshot?.timestamp} />
)}
</section>
);
}
return (
<div
data-testid="app-status-metrics"
className="flex h-full max-w-[52vw] items-center overflow-hidden leading-none text-current"
aria-label="System metrics"
>
{!host ? (
<EmptyMetrics />
) : (
<BarSourceMetrics
source={host}
updatedAt={snapshot?.timestamp}
showSourceLabel={density === "full"}
metricLimit={density === "compact" ? 2 : 4}
/>
)}
</div>
);
}
function EmptyMetrics({ drawer = false }: { drawer?: boolean }) {
return (
<div
className={
drawer
? "flex min-h-11 items-center gap-2 rounded-md px-3 text-sm text-muted-foreground"
: "flex h-full items-center gap-2 text-[11px] text-current opacity-70"
}
>
<IconActivity className="h-3.5 w-3.5" />
<span>Metrics unavailable</span>
</div>
);
}
function BarSourceMetrics({
source,
updatedAt,
showSourceLabel,
metricLimit,
}: {
source: SystemMetricsSource;
updatedAt?: string;
showSourceLabel: boolean;
metricLimit: number;
}) {
return (
<div className="flex h-full max-w-[360px] items-center gap-3 overflow-hidden text-[11px]">
<SourceBadge source={source} updatedAt={updatedAt} showLabel={showSourceLabel} />
<MetricValues source={source} updatedAt={updatedAt} limit={metricLimit} />
</div>
);
}
function DrawerSourceMetrics({
source,
updatedAt,
}: {
source: SystemMetricsSource;
updatedAt?: string;
}) {
return (
<div className="flex min-h-11 items-center gap-2 rounded-md px-3 text-sm hover:bg-muted/60">
<SourceBadge source={source} updatedAt={updatedAt} showLabel />
<div className="flex min-w-0 flex-1 items-center justify-end gap-3 overflow-hidden">
<MetricValues source={source} updatedAt={updatedAt} limit={4} />
</div>
</div>
);
}
function SourceBadge({
source,
updatedAt,
showLabel,
}: {
source: SystemMetricsSource;
updatedAt?: string;
showLabel: boolean;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex shrink-0 items-center gap-1.5 text-current" aria-label="Host metrics">
<IconServer className="size-3.5" stroke={1.6} />
{showLabel ? <span className="max-w-20 truncate">Host</span> : null}
</span>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<div className="font-medium">Host</div>
<div className="text-xs text-muted-foreground">{source.label}</div>
<div className="text-xs text-muted-foreground">{lastUpdatedText(updatedAt)}</div>
</div>
</TooltipContent>
</Tooltip>
);
}
function MetricValues({
source,
updatedAt,
limit,
}: {
source: SystemMetricsSource;
updatedAt?: string;
limit: number;
}) {
const metrics = source.metrics.slice(0, limit);
if (metrics.length === 0) return <span className="text-muted-foreground">-</span>;
return (
<span className="flex min-w-0 items-center gap-3 overflow-hidden">
{metrics.map((metric) => (
<MetricValue key={metric.id} metric={metric} source={source} updatedAt={updatedAt} />
))}
</span>
);
}
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 (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`inline-flex shrink-0 items-center gap-1.5 tabular-nums ${metricColor(metric)}`}
aria-label={`${metricLabel(metric.id)} ${formatMetric(metric)}`}
>
{metricIcon(metric.id)}
<MetricMeter metric={metric} />
<span className="font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]">
{formatMetric(metric)}
</span>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<div className="font-medium">{metricLabel(metric.id)}</div>
{help ? <div className="max-w-72 text-xs text-muted-foreground">{help}</div> : null}
<div className="text-xs text-muted-foreground">Host: {source.label}</div>
<div className="text-xs tabular-nums">{formatMetric(metric)}</div>
{metric.error ? (
<div className="text-xs text-muted-foreground">{metric.error}</div>
) : null}
<div className="text-xs text-muted-foreground">{lastUpdatedText(updatedAt)}</div>
</div>
</TooltipContent>
</Tooltip>
);
}
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 <Icon className="size-3.5 opacity-80" stroke={1.6} />;
}
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 (
<span
className="h-1 w-7 overflow-hidden rounded-full bg-muted-foreground/20"
aria-hidden="true"
>
<span className="block h-full rounded-full bg-current opacity-65" style={{ width }} />
</span>
);
}
function formatMetric(metric: SystemMetricSample) {
if (typeof metric.value !== "number") return "-";
const value = metric.unit === "%" ? Math.round(metric.value) : Math.round(metric.value * 10) / 10;
return `${value}${metric.unit ?? ""}`;
}
function metricColor(metric: SystemMetricSample) {
if (!metric.available) return "text-current opacity-50";
const thresholdValue = metric.unit === "%" || metric.id === "cpu_temp" ? metric.value : null;
if (typeof thresholdValue !== "number") return "text-current";
if (thresholdValue > 95) return "text-destructive";
if (thresholdValue >= 80) return "text-yellow-500 dark:text-yellow-400";
return "text-current";
}
function lastUpdatedText(updatedAt?: string) {
if (!updatedAt) return "Last update unknown";
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return "Last update unknown";
return `Updated ${formatDistanceToNow(date, { addSuffix: true })}`;
}
@@ -7,141 +7,70 @@ import { defaultSystemState } from "@/lib/state/slices/system/system-slice";
import type { AppState } from "@/lib/state/store";
import { TopbarMetrics } from "./topbar-metrics";
const responsiveState = vi.hoisted(() => ({ isMobile: true }));
const featureState = vi.hoisted(() => ({ appStatusBar: 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/domains/features/use-feature", () => ({
useFeature: () => featureState.appStatusBar,
}));
vi.mock("@/hooks/use-system-metrics-subscription", () => ({
useSystemMetricsSubscription: subscribeMock,
}));
function renderMetrics(initialState: Partial<AppState>, activeSessionId?: string | null) {
return render(
<StateProvider initialState={initialState}>
<TooltipProvider>
<TopbarMetrics activeSessionId={activeSessionId} />
</TooltipProvider>
</StateProvider>,
);
}
function initialState(showInTopbar: boolean): Partial<AppState> {
return {
function renderMetrics() {
const initialState = {
userSettings: {
...defaultSettingsState.userSettings,
loaded: true,
systemMetricsDisplay: { showInTopbar },
systemMetricsDisplay: { showInTopbar: true },
},
system: {
...defaultSystemState.system,
metrics: {
timestamp: "2026-06-23T10:00:00Z",
timestamp: "2026-07-22T10:00:00Z",
interval_seconds: 5,
sources: [
{
id: "backend",
label: "Host",
kind: "backend",
metrics: [
{
id: "cpu_percent",
label: "CPU",
unit: "%",
value: 42,
available: true,
},
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 68,
available: true,
},
{
id: "disk_percent",
label: "Disk",
unit: "%",
value: 12,
available: true,
},
],
},
{
id: "execution-session-1",
label: "Executor",
kind: "execution",
session_id: "session-1",
metrics: [
{
id: "cpu_percent",
label: "CPU",
unit: "%",
value: 17,
available: true,
},
{
id: "memory_percent",
label: "Memory",
unit: "%",
value: 33,
available: true,
},
],
metrics: [{ id: "cpu_percent", label: "CPU", unit: "%", value: 42, available: true }],
},
],
},
},
};
} satisfies Partial<AppState>;
return render(
<StateProvider initialState={initialState}>
<TooltipProvider>
<TopbarMetrics />
</TooltipProvider>
</StateProvider>,
);
}
describe("TopbarMetrics", () => {
describe("TopbarMetrics feature fallback", () => {
beforeEach(() => {
responsiveState.isMobile = true;
featureState.appStatusBar = false;
subscribeMock.mockClear();
});
afterEach(() => {
cleanup();
});
afterEach(cleanup);
it("renders a compact metrics pill on mobile when enabled", () => {
renderMetrics(initialState(true));
it("keeps existing metrics visible while the app status bar is disabled", () => {
renderMetrics();
const metrics = screen.getByTestId("mobile-topbar-metrics");
expect(metrics).toBeTruthy();
expect(metrics.className.split(/\s+/)).not.toContain("border");
expect(metrics.className.split(/\s+/)).not.toContain("border-border");
expect(screen.getByTestId("topbar-metrics")).toBeTruthy();
expect(screen.getByLabelText("CPU 42%")).toBeTruthy();
expect(screen.getByLabelText("Memory 68%")).toBeTruthy();
expect(screen.queryByLabelText("Disk 12%")).toBeNull();
expect(subscribeMock).toHaveBeenCalledWith(true);
});
it("prefers the active executor source in compact task topbars", () => {
renderMetrics(initialState(true), "session-1");
it("yields metrics ownership to the app status bar when enabled", () => {
featureState.appStatusBar = true;
renderMetrics();
expect(screen.getByLabelText("Executor metrics")).toBeTruthy();
expect(screen.getByLabelText("CPU 17%")).toBeTruthy();
expect(screen.getByLabelText("Memory 33%")).toBeTruthy();
expect(screen.queryByLabelText("CPU 42%")).toBeNull();
});
it("does not subscribe or render when topbar metrics are disabled", () => {
renderMetrics(initialState(false));
expect(screen.queryByTestId("mobile-topbar-metrics")).toBeNull();
expect(subscribeMock).toHaveBeenCalledWith(false);
expect(screen.queryByTestId("topbar-metrics")).toBeNull();
expect(subscribeMock).not.toHaveBeenCalled();
});
});
@@ -1,270 +1,29 @@
"use client";
import {
IconActivity,
IconCpu,
IconDatabase,
IconDeviceDesktopAnalytics,
IconFlame,
IconGauge,
IconDisc,
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";
import { useFeature } from "@/hooks/domains/features/use-feature";
import { StatusSurfaceMetrics } from "./status-surface-metrics";
type TopbarMetricsProps = {
activeSessionId?: string | null;
size?: "sm" | "lg";
};
export function TopbarMetrics({ activeSessionId, size = "lg" }: TopbarMetricsProps) {
const enabled = useAppStore((s) => s.userSettings.systemMetricsDisplay.showInTopbar);
const snapshot = useAppStore((s) => s.system.metrics);
const { usesDesktopWorkbench } = useResponsiveBreakpoint();
const shouldRender = enabled;
useSystemMetricsSubscription(shouldRender);
/** Preserves pre-status-bar metrics until the new surface is enabled. */
export function TopbarMetrics({ size = "lg" }: TopbarMetricsProps) {
const statusBarEnabled = useFeature("appStatusBar");
const metricsEnabled = useAppStore(
(state) => state.userSettings.systemMetricsDisplay.showInTopbar,
);
if (statusBarEnabled || !metricsEnabled) return null;
if (!shouldRender) return null;
const sources = selectSources(snapshot?.sources ?? [], activeSessionId);
if (sources.length === 0) {
return (
<div
className={`flex ${metricsHeight(size)} items-center gap-1 rounded px-2 text-xs text-muted-foreground md:border md:border-border`}
data-testid="topbar-metrics"
>
<IconActivity className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Metrics</span>
</div>
);
}
if (!usesDesktopWorkbench) {
return (
<CompactSourceMetrics
source={selectCompactSource(sources, activeSessionId)}
updatedAt={snapshot?.timestamp}
size={size}
/>
);
}
return (
<div
className="hidden max-w-[42vw] items-center gap-1 overflow-hidden md:flex"
className={`flex items-center overflow-hidden ${size === "sm" ? "h-7" : "h-8"}`}
data-testid="topbar-metrics"
>
{sources.map((source) => (
<SourceMetrics
key={source.id}
source={source}
updatedAt={snapshot?.timestamp}
showSource={sources.length > 1}
size={size}
/>
))}
<StatusSurfaceMetrics presentation="bar" density="compact" drawerOpen />
</div>
);
}
function selectSources(sources: SystemMetricsSource[], activeSessionId?: string | null) {
if (!activeSessionId) return sources.slice(0, 2);
const backend = sources.find((source) => source.kind === "backend");
const execution = sources.find((source) => source.session_id === activeSessionId);
return [backend, execution].filter(Boolean) as SystemMetricsSource[];
}
function selectCompactSource(sources: SystemMetricsSource[], activeSessionId?: string | null) {
return sources.find((source) => source.session_id === activeSessionId) ?? sources[0];
}
function SourceMetrics({
source,
updatedAt,
showSource,
size,
}: {
source: SystemMetricsSource;
updatedAt?: string;
showSource: boolean;
size: NonNullable<TopbarMetricsProps["size"]>;
}) {
const metrics = source.metrics.slice(0, 4);
return (
<div
className={`flex ${metricsHeight(size)} max-w-[220px] items-center gap-1 overflow-hidden rounded border border-border px-1.5 text-xs`}
>
{showSource ? <SourceBadge source={source} updatedAt={updatedAt} /> : null}
{metrics.length > 0 ? (
metrics.map((metric) => (
<MetricChip key={metric.id} metric={metric} source={source} updatedAt={updatedAt} />
))
) : (
<span className="px-1 text-muted-foreground">-</span>
)}
</div>
);
}
function CompactSourceMetrics({
source,
updatedAt,
size,
}: {
source: SystemMetricsSource;
updatedAt?: string;
size: NonNullable<TopbarMetricsProps["size"]>;
}) {
const metrics = source.metrics.slice(0, 2);
return (
<div
className={`flex ${metricsHeight(size)} max-w-[34vw] items-center gap-1 overflow-hidden rounded px-1.5 text-xs`}
data-testid="mobile-topbar-metrics"
>
<SourceBadge source={source} updatedAt={updatedAt} />
{metrics.length > 0 ? (
metrics.map((metric) => (
<MetricChip key={metric.id} metric={metric} source={source} updatedAt={updatedAt} />
))
) : (
<span className="px-1 text-muted-foreground">-</span>
)}
</div>
);
}
function metricsHeight(size: NonNullable<TopbarMetricsProps["size"]>) {
return size === "sm" ? "h-7" : "h-8";
}
function SourceBadge({ source, updatedAt }: { source: SystemMetricsSource; updatedAt?: string }) {
const isHost = source.kind === "backend";
const label = isHost ? "Host" : "Executor";
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground"
aria-label={`${label} metrics`}
>
{isHost ? (
<IconServer className="h-3.5 w-3.5" />
) : (
<IconDeviceDesktopAnalytics className="h-3.5 w-3.5" />
)}
</span>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<div className="font-medium">{label}</div>
<div className="text-xs text-muted-foreground">{source.label}</div>
<div className="text-xs text-muted-foreground">{lastUpdatedText(updatedAt)}</div>
</div>
</TooltipContent>
</Tooltip>
);
}
function MetricChip({
metric,
source,
updatedAt,
}: {
metric: SystemMetricSample;
source: SystemMetricsSource;
updatedAt?: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`flex h-5 shrink-0 items-center gap-0.5 rounded px-1 tabular-nums ${metricColor(metric)}`}
aria-label={`${metricLabel(metric.id)} ${formatMetric(metric)}`}
>
{metricIcon(metric.id)}
<span>{formatMetric(metric)}</span>
</span>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1">
<div className="font-medium">{metricLabel(metric.id)}</div>
<div className="text-xs text-muted-foreground">
{source.kind === "backend" ? "Host" : "Executor"}: {source.label}
</div>
<div className="text-xs tabular-nums">{formatMetric(metric)}</div>
{metric.error ? (
<div className="text-xs text-muted-foreground">{metric.error}</div>
) : null}
<div className="text-xs text-muted-foreground">{lastUpdatedText(updatedAt)}</div>
</div>
</TooltipContent>
</Tooltip>
);
}
function metricLabel(id: string) {
switch (id) {
case "cpu_percent":
return "CPU";
case "memory_percent":
return "Memory";
case "disk_percent":
return "Disk";
case "cpu_temp":
return "CPU temperature";
case "io_load":
return "Load avg";
default:
return id;
}
}
function metricIcon(id: string) {
switch (id) {
case "cpu_percent":
return <IconCpu className="h-3.5 w-3.5" />;
case "memory_percent":
return <IconDatabase className="h-3.5 w-3.5" />;
case "disk_percent":
return <IconDisc className="h-3.5 w-3.5" />;
case "cpu_temp":
return <IconFlame className="h-3.5 w-3.5" />;
case "io_load":
return <IconGauge className="h-3.5 w-3.5" />;
default:
return <IconActivity className="h-3.5 w-3.5" />;
}
}
function formatMetric(metric: SystemMetricSample) {
if (typeof metric.value !== "number") return "-";
const value = metric.unit === "%" ? Math.round(metric.value) : Math.round(metric.value * 10) / 10;
return `${value}${metric.unit ?? ""}`;
}
function metricColor(metric: SystemMetricSample) {
if (!metric.available) return "text-muted-foreground";
const thresholdValue = metricThresholdValue(metric);
if (thresholdValue === null) return "text-muted-foreground";
if (thresholdValue > 95) return "text-destructive";
if (thresholdValue >= 80) return "text-yellow-500 dark:text-yellow-400";
return "text-muted-foreground";
}
function metricThresholdValue(metric: SystemMetricSample) {
if (typeof metric.value !== "number") return null;
if (metric.unit === "%" || metric.id === "cpu_temp") return metric.value;
return null;
}
function lastUpdatedText(updatedAt?: string) {
if (!updatedAt) return "Last update unknown";
const date = new Date(updatedAt);
if (Number.isNaN(date.getTime())) return "Last update unknown";
return `Updated ${formatDistanceToNow(date, { addSuffix: true })}`;
}
@@ -4,10 +4,18 @@ import { SessionMobileBottomNav } from "./session-mobile-bottom-nav";
afterEach(cleanup);
describe("SessionMobileBottomNav GitLab review", () => {
describe("SessionMobileBottomNav", () => {
it("offers a touch-sized review route for linked merge requests", () => {
const onPanelChange = vi.fn();
render(<SessionMobileBottomNav activePanel="chat" onPanelChange={onPanelChange} hasReview />);
render(
<SessionMobileBottomNav
activePanel="chat"
onPanelChange={onPanelChange}
hasReview
showStatus
onOpenStatus={vi.fn()}
/>,
);
const review = screen.getByRole("button", { name: "Review" });
expect(review.className).toContain("min-h-11");
@@ -16,7 +24,46 @@ describe("SessionMobileBottomNav GitLab review", () => {
});
it("does not consume navigation space without a linked merge request", () => {
render(<SessionMobileBottomNav activePanel="chat" onPanelChange={vi.fn()} />);
render(
<SessionMobileBottomNav
activePanel="chat"
onPanelChange={vi.fn()}
showStatus
onOpenStatus={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "Review" })).toBeNull();
});
it("opens Status as an action without changing the selected mobile panel", () => {
const onPanelChange = vi.fn();
const onOpenStatus = vi.fn();
render(
<SessionMobileBottomNav
activePanel="chat"
onPanelChange={onPanelChange}
showStatus
onOpenStatus={onOpenStatus}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Status" }));
expect(onOpenStatus).toHaveBeenCalledOnce();
expect(onPanelChange).not.toHaveBeenCalled();
});
it("does not reserve navigation space when Status is disabled", () => {
render(
<SessionMobileBottomNav
activePanel="chat"
onPanelChange={vi.fn()}
showStatus={false}
onOpenStatus={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "Status" })).toBeNull();
});
});
@@ -8,6 +8,7 @@ import {
IconFolder,
IconTerminal2,
IconGitMerge,
IconActivity,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Badge } from "@kandev/ui/badge";
@@ -19,14 +20,15 @@ type SessionMobileBottomNavProps = {
planBadge?: boolean;
changesBadge?: number;
hasReview?: boolean;
showStatus: boolean;
onOpenStatus: () => void;
};
type NavItem = {
id: MobileSessionPanel;
label: string;
icon: React.ReactNode;
badge?: React.ReactNode;
};
} & ({ panel: MobileSessionPanel; onClick?: never } | { panel?: never; onClick: () => void });
export function SessionMobileBottomNav({
activePanel,
@@ -34,16 +36,18 @@ export function SessionMobileBottomNav({
planBadge = false,
changesBadge = 0,
hasReview = false,
showStatus,
onOpenStatus,
}: SessionMobileBottomNavProps) {
const items: NavItem[] = useMemo(
() => [
{
id: "chat",
panel: "chat",
label: "Chat",
icon: <IconMessage className="h-5 w-5" />,
},
{
id: "plan",
panel: "plan",
label: "Plan",
icon: <IconListCheck className="h-5 w-5" />,
badge: planBadge ? (
@@ -51,7 +55,7 @@ export function SessionMobileBottomNav({
) : undefined,
},
{
id: "changes",
panel: "changes",
label: "Changes",
icon: <IconGitBranch className="h-5 w-5" />,
badge:
@@ -65,20 +69,35 @@ export function SessionMobileBottomNav({
) : undefined,
},
{
id: "files",
panel: "files",
label: "Files",
icon: <IconFolder className="h-5 w-5" />,
},
...(hasReview
? [{ id: "review" as const, label: "Review", icon: <IconGitMerge className="h-5 w-5" /> }]
? [
{
panel: "review" as const,
label: "Review",
icon: <IconGitMerge className="h-5 w-5" />,
},
]
: []),
{
id: "terminal",
panel: "terminal",
label: "Terminal",
icon: <IconTerminal2 className="h-5 w-5" />,
},
...(showStatus
? [
{
label: "Status",
icon: <IconActivity className="h-5 w-5" />,
onClick: onOpenStatus,
},
]
: []),
],
[planBadge, changesBadge, hasReview],
[planBadge, changesBadge, hasReview, showStatus, onOpenStatus],
);
return (
@@ -88,12 +107,12 @@ export function SessionMobileBottomNav({
>
{items.map((item) => (
<button
key={item.id}
key={item.label}
type="button"
onClick={() => onPanelChange(item.id)}
onClick={item.onClick ?? (() => onPanelChange(item.panel))}
className={cn(
"flex min-h-11 flex-col items-center justify-center py-2 px-3 gap-0.5 min-w-0 flex-1 transition-colors",
activePanel === item.id
activePanel === item.panel
? "text-primary"
: "text-muted-foreground hover:text-foreground",
)}
@@ -18,6 +18,7 @@ import { SessionPanelContent } from "@kandev/ui/pannel-session";
import { useSessionLayoutState } from "@/hooks/use-session-layout-state";
import { useVisualViewportOffset } from "@/hooks/use-visual-viewport-offset";
import { useToast } from "@/components/toast-provider";
import { useAppStatusDrawer } from "@/components/app-status-bar/app-status-surface-provider";
import { useAppStore } from "@/components/state-provider";
import { fetchAndOpenFile } from "../file-browser-hooks";
import type { MobileSessionPanel } from "@/lib/state/slices/ui/types";
@@ -385,9 +386,51 @@ function useMobileMRSelection(
return { mrs, selectedMR, handlePanelChange };
}
type SessionMobileFooterProps = {
sessionId: string | null;
activePanel: MobileSessionPanel;
onPanelChange: (panel: MobileSessionPanel) => void;
planBadge: boolean;
changesBadge: number;
hasReview: boolean;
showStatus: boolean;
onOpenStatus: () => void;
};
function SessionMobileFooter({
sessionId,
activePanel,
onPanelChange,
planBadge,
changesBadge,
hasReview,
showStatus,
onOpenStatus,
}: SessionMobileFooterProps) {
return (
<>
<MobileTerminalKeybar
sessionId={sessionId}
visible={activePanel === "terminal"}
baseBottomOffset={BOTTOM_NAV_HEIGHT}
/>
<SessionMobileBottomNav
activePanel={activePanel}
onPanelChange={onPanelChange}
planBadge={planBadge}
changesBadge={changesBadge}
hasReview={hasReview}
showStatus={showStatus}
onOpenStatus={onOpenStatus}
/>
</>
);
}
export const SessionMobileLayout = memo(function SessionMobileLayout(
props: SessionMobileLayoutProps,
) {
const { enabled: statusDrawerEnabled, openStatusDrawer } = useAppStatusDrawer();
const {
activeTaskId,
effectiveSessionId,
@@ -404,10 +447,8 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
handleMenuClick,
setMobileSessionTaskSwitcherOpen,
} = useSessionLayoutState({ sessionId: props.sessionId });
const { selectedFile, handleOpenFileFromChat, handleOpenFile, handlePanelChangeAndClearSheet } =
useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
const mobileMR = useMobileMRSelection(
activeTaskId,
effectiveSessionId,
@@ -444,19 +485,15 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
mrKey={mobileMR.selectedMR ? mrTaskKey(mobileMR.selectedMR) : undefined}
/>
<MobileTerminalKeybar
<SessionMobileFooter
sessionId={effectiveSessionId ?? null}
visible={effectiveMobilePanel === "terminal"}
baseBottomOffset={BOTTOM_NAV_HEIGHT}
/>
{/* Fixed Bottom Navigation */}
<SessionMobileBottomNav
activePanel={effectiveMobilePanel}
onPanelChange={mobileMR.handlePanelChange}
planBadge={hasUnseenPlanUpdate}
changesBadge={totalChangesCount}
hasReview={mobileMR.mrs.length > 0}
showStatus={statusDrawerEnabled}
onOpenStatus={openStatusDrawer}
/>
{/* Task Switcher Sheet */}
@@ -135,7 +135,7 @@ export function useMergedAgentState(
function TaskLoadingState() {
return (
<div
className="flex h-screen w-full items-center justify-center bg-background px-4"
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
data-testid="task-loading-state"
>
<div className="flex min-h-24 min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
@@ -149,7 +149,7 @@ function TaskLoadingState() {
function TaskLoadErrorState() {
return (
<div
className="flex h-screen w-full items-center justify-center bg-background px-4"
className="flex h-full min-h-0 w-full items-center justify-center bg-background px-4"
data-testid="task-load-error-state"
>
<div className="flex min-h-24 max-w-sm min-w-0 flex-col items-center justify-center gap-3 text-center text-sm text-muted-foreground">
+1 -1
View File
@@ -268,7 +268,7 @@ export function TaskPageInner({
taskTitle={taskProps.taskTitle}
displayBranch={merged.worktreeBranch}
>
<div className="h-screen w-full flex flex-col bg-background overflow-hidden">
<div className="flex h-full min-h-0 w-full flex-col overflow-hidden bg-background">
<SessionCommands
sessionId={effectiveSessionId}
baseBranch={taskProps.baseBranch}
@@ -9,8 +9,8 @@ import type { TaskSession } from "@/lib/types/http";
/**
* Props forwarded to every plugin component registered for the `chat-top-bar`
* slot (`registry.registerComponent("chat-top-bar", Component)`). This is the
* session top bar's right-hand cluster, beside the CPU/DB metrics and the
* document / editors / debug controls — the place for at-a-glance status a
* session top bar's right-hand cluster, beside the document / editors / debug
* controls — the place for at-a-glance status a
* plugin wants to surface for the current task.
*
* A task can hold several sessions; the top bar is bound to one at a time
@@ -37,7 +37,7 @@ const EMPTY_SESSIONS: TaskSession[] = [];
/**
* Plugin extension point in the session top bar, rendered alongside the
* first-party controls (CPU/DB metrics, document/editor menus, debug toggle).
* first-party controls (document/editor menus and debug toggle).
* Renders every plugin component registered for the `chat-top-bar` slot (each
* isolated behind its own error boundary via `PluginSlot`) and forwards the
* current task, workspace, and all of its session ids as `slotProps`.
+1 -1
View File
@@ -19,8 +19,8 @@ import { PortForwardButton } from "@/components/task/port-forward-dialog";
import { ExecutorSettingsButton } from "@/components/task/executor-settings-button";
import { TaskUnarchiveButton } from "@/components/task/task-unarchive-button";
import { WorkflowStepper, type WorkflowStepperStep } from "@/components/task/workflow-stepper";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import { TaskTopBarPluginActions } from "@/components/task/task-top-bar-plugin-actions";
import { TopbarMetrics } from "@/components/system-metrics/topbar-metrics";
import { isDebugUI } from "@/lib/config";
type TaskTopBarProps = {
+1 -1
View File
@@ -133,7 +133,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
function ToastList({ toasts }: { toasts: Toast[] }) {
return (
<div
className="fixed bottom-4 right-4 z-50 flex w-[360px] flex-col-reverse gap-2"
className="fixed bottom-[calc(1rem+var(--app-status-bar-height))] right-4 z-50 flex w-[360px] flex-col-reverse gap-2"
data-testid="toast-container"
aria-live="polite"
aria-relevant="additions text"
+3
View File
@@ -49,6 +49,9 @@ export class SessionPage {
}
// Chat status bar locators
appStatusBar() {
return this.page.getByTestId("app-status-bar");
}
chatStatusBar() {
return this.page.getByTestId("chat-status-bar");
}
@@ -11,21 +11,21 @@ test.describe("Mobile kanban view", () => {
});
});
test("metrics match the height of the mobile topbar actions", async ({ testPage, apiClient }) => {
test("Status drawer replaces a persistent phone metrics footer", async ({
testPage,
apiClient,
}) => {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: { show_in_topbar: true },
});
const mobile = new MobileKanbanPage(testPage);
await mobile.goto();
const metrics = testPage.getByTestId("mobile-topbar-metrics");
await expect(metrics).toBeVisible();
await expect(mobile.mobileSearchToggle).toBeVisible();
const metricsBox = await metrics.boundingBox();
const actionBox = await mobile.mobileSearchToggle.boundingBox();
if (!metricsBox || !actionBox) throw new Error("topbar action has no bounding box");
expect(metricsBox.height).toBe(actionBox.height);
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
await testPage.getByRole("button", { name: "Open menu" }).click();
await testPage.getByTestId("mobile-home-status-button").click();
await expect(testPage.getByTestId("app-status-drawer")).toBeVisible();
await expect(testPage.getByTestId("app-status-metrics")).toBeVisible();
});
test("renders focused mobile layout with step navigation", async ({
@@ -0,0 +1,71 @@
import { expect, test } from "../../fixtures/test-base";
test.describe("App status bar", () => {
test("uses one 24px in-flow footer across sidebar and route content", async ({ testPage }) => {
await testPage.goto("/");
const bar = testPage.getByTestId("app-status-bar");
await expect(bar).toBeVisible();
await expect(testPage.getByTestId("app-sidebar")).toBeVisible();
const [barBox, viewport] = await Promise.all([
bar.boundingBox(),
testPage.evaluate(() => ({ width: window.innerWidth, height: window.innerHeight })),
]);
if (!barBox) throw new Error("app status bar has no bounding box");
expect(barBox.height).toBe(24);
expect(Math.abs(barBox.y + barBox.height - viewport.height)).toBeLessThanOrEqual(1);
expect(Math.abs(barBox.x)).toBeLessThanOrEqual(1);
expect(Math.abs(barBox.width - viewport.width)).toBeLessThanOrEqual(1);
await expect
.poll(() => bar.evaluate((element) => getComputedStyle(element).fontFamily))
.toMatch(/^"?Geist"?/);
const connectionDot = bar
.locator('[data-status-item-id="builtin:connection"] [aria-hidden="true"]')
.first();
const dotBox = await connectionDot.boundingBox();
if (!dotBox) throw new Error("connection dot has no bounding box");
expect(Math.abs(dotBox.y + dotBox.height / 2 - (barBox.y + 12))).toBeLessThanOrEqual(0.5);
await expect
.poll(() =>
bar.evaluate((element) => ({
separatorHeight: getComputedStyle(element, "::before").height,
contentHeight: getComputedStyle(element).height,
})),
)
.toEqual({ separatorHeight: "1px", contentHeight: "24px" });
});
test("persists a modifier-mouse move across the spacer", async ({ testPage }) => {
await testPage.goto("/");
const bar = testPage.getByTestId("app-status-bar");
const connection = bar.locator('[data-status-item-id="builtin:connection"]');
const [sourceBox, barBox] = await Promise.all([connection.boundingBox(), bar.boundingBox()]);
if (!sourceBox || !barBox) throw new Error("status bar drag geometry unavailable");
const saved = testPage.waitForResponse(
(response) =>
response.request().method() === "PATCH" && response.url().endsWith("/api/v1/user/settings"),
);
await testPage.keyboard.down("Control");
await testPage.mouse.move(
sourceBox.x + sourceBox.width / 2,
sourceBox.y + sourceBox.height / 2,
);
await testPage.mouse.down();
await testPage.mouse.move(barBox.x + barBox.width - 8, barBox.y + barBox.height / 2, {
steps: 8,
});
await testPage.mouse.up();
await testPage.keyboard.up("Control");
expect((await saved).ok()).toBe(true);
await expect(connection).toHaveAttribute("data-status-side", "right");
await testPage.reload();
await expect(
testPage.getByTestId("app-status-bar").locator('[data-status-item-id="builtin:connection"]'),
).toHaveAttribute("data-status-side", "right");
});
});
@@ -23,7 +23,7 @@ async function heightOf(page: Page, testId: string): Promise<number> {
return box.height;
}
async function enableTopbarMetrics(apiClient: ApiClient): Promise<void> {
async function enableStatusMetrics(apiClient: ApiClient): Promise<void> {
await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: { show_in_topbar: true },
});
@@ -77,12 +77,12 @@ test.describe("Sidebar header / top bar alignment", () => {
expect(Math.abs(headerBottom - topbarBottom)).toBeLessThanOrEqual(1);
});
test("task metrics match the height of the task action controls", async ({
test("task metrics render inside the fixed-height app status bar", async ({
testPage,
apiClient,
seedData,
}) => {
await enableTopbarMetrics(apiClient);
await enableStatusMetrics(apiClient);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Metrics Alignment Task",
@@ -100,25 +100,19 @@ test.describe("Sidebar header / top bar alignment", () => {
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(testPage.getByTestId("topbar-metrics")).toBeVisible();
await expect(testPage.getByTestId("layout-preset-trigger")).toBeVisible();
expect(await heightOf(testPage, "topbar-metrics")).toBe(
await heightOf(testPage, "layout-preset-trigger"),
);
await expect(testPage.getByTestId("app-status-metrics")).toBeVisible();
expect(await heightOf(testPage, "app-status-bar")).toBe(24);
});
test("Kanban metrics match the height of the Kanban action controls", async ({
test("Kanban metrics render inside the fixed-height app status bar", async ({
testPage,
apiClient,
}) => {
await enableTopbarMetrics(apiClient);
await enableStatusMetrics(apiClient);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await expect(testPage.getByTestId("topbar-metrics")).toBeVisible();
await expect(testPage.getByTestId("view-toggle-kanban")).toBeVisible();
expect(await heightOf(testPage, "topbar-metrics")).toBe(
await heightOf(testPage, "view-toggle-kanban"),
);
await expect(testPage.getByTestId("app-status-metrics")).toBeVisible();
expect(await heightOf(testPage, "app-status-bar")).toBe(24);
});
});
@@ -0,0 +1,120 @@
import path from "node:path";
import type { Page } from "@playwright/test";
import { expect, test } from "../../fixtures/test-base";
const PLUGIN_ID = "kandev-plugin-e2e";
const PACKAGE_PATH = path.resolve(
__dirname,
"../../../../../apps/backend/.build/kandev-plugin-e2e-1.0.0.tar.gz",
);
async function installFixture(page: Page) {
await page.goto("/settings/plugins");
await page.getByTestId("install-plugin-trigger").click();
await page.getByTestId("install-plugin-tab-upload").click();
await page.getByTestId("install-plugin-file-input").setInputFiles(PACKAGE_PATH);
await page.getByTestId("install-plugin-upload-submit").click();
await expect(page.getByTestId(`plugin-row-${PLUGIN_ID}`)).toBeVisible({ timeout: 15_000 });
}
test.describe("Mobile Status drawer", () => {
test.afterEach(async ({ apiClient }) => {
await apiClient.rawRequest("DELETE", `/api/plugins/${PLUGIN_ID}`).catch(() => undefined);
});
test("opens native Status paths without a persistent phone footer", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
await installFixture(testPage);
const leftOrderingId = `plugin:${PLUGIN_ID}:app-status-bar-left:0`;
const rightOrderingId = `plugin:${PLUGIN_ID}:app-status-bar-right:0`;
const orderResponse = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: { show_in_topbar: true },
app_status_bar_order: {
left_item_ids: [rightOrderingId, "builtin:metrics"],
right_item_ids: ["builtin:connection", leftOrderingId],
},
});
expect(orderResponse.ok).toBe(true);
await testPage.goto("/");
await testPage.reload();
await testPage.getByRole("button", { name: "Open menu" }).click();
const statusTrigger = testPage.getByTestId("mobile-home-status-button");
await expect(statusTrigger).toBeVisible();
const triggerBox = await statusTrigger.boundingBox();
expect(triggerBox?.height).toBeGreaterThanOrEqual(44);
await statusTrigger.click();
const drawer = testPage.getByTestId("app-status-drawer");
await expect(drawer).toBeVisible();
await expect(drawer.getByTestId("app-status-connection")).toBeVisible();
await expect(testPage.locator("#hello-status-left")).toContainText("mobile-drawer no-task");
await expect(testPage.getByTestId("app-status-bar")).toHaveCount(0);
const statusRows = drawer.locator("[data-status-item-id]");
await expect(statusRows).toHaveCount(4);
expect(
await statusRows.evaluateAll((rows) => rows.map((row) => row.dataset.statusItemId)),
).toEqual([rightOrderingId, "builtin:metrics", "builtin:connection", leftOrderingId]);
for (const row of await statusRows.all()) {
expect((await row.boundingBox())?.height).toBeGreaterThanOrEqual(44);
}
let orderPatchCount = 0;
testPage.on("request", (request) => {
if (request.method() === "PATCH" && request.url().endsWith("/api/v1/user/settings")) {
orderPatchCount += 1;
}
});
const [firstRowBox, lastRowBox] = await Promise.all([
statusRows.first().boundingBox(),
statusRows.last().boundingBox(),
]);
if (!firstRowBox || !lastRowBox) throw new Error("mobile status row geometry unavailable");
await testPage.keyboard.down("Control");
await testPage.mouse.move(
firstRowBox.x + firstRowBox.width / 2,
firstRowBox.y + firstRowBox.height / 2,
);
await testPage.mouse.down();
await testPage.mouse.move(
lastRowBox.x + lastRowBox.width / 2,
lastRowBox.y + lastRowBox.height / 2,
{ steps: 6 },
);
await testPage.mouse.up();
await testPage.keyboard.up("Control");
expect(orderPatchCount).toBe(0);
expect(
await statusRows.evaluateAll((rows) => rows.map((row) => row.dataset.statusItemId)),
).toEqual([rightOrderingId, "builtin:metrics", "builtin:connection", leftOrderingId]);
expect(await drawer.locator("[class*='overflow-y-auto']").count()).toBe(1);
expect(await testPage.evaluate(() => document.documentElement.scrollWidth)).toBe(
await testPage.evaluate(() => document.documentElement.clientWidth),
);
// Chromium may consume Escape immediately after the synthetic modifier drag.
await testPage
.locator('[data-slot="drawer-overlay"][data-state="open"]')
.click({ position: { x: 4, y: 4 } });
await expect(drawer).toBeHidden();
const task = await apiClient.createTask(seedData.workspaceId, "Mobile status task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
await testPage.goto(`/t/${task.id}`);
await testPage.getByRole("button", { name: "Status" }).click();
await expect(testPage.locator("#hello-status-left")).toContainText(`mobile-drawer ${task.id}`);
await testPage.keyboard.press("Escape");
await testPage.goto("/stats");
const pageTopbarStatus = testPage.getByTestId("app-status-drawer-trigger");
await expect(pageTopbarStatus).toBeVisible();
await pageTopbarStatus.click();
await expect(drawer).toBeVisible();
await testPage.keyboard.press("Escape");
await expect(pageTopbarStatus).toBeFocused();
});
});
@@ -142,11 +142,46 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
const navItem = testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`);
await expect(navItem).toBeVisible({ timeout: 15_000 });
await expect(navItem).toHaveText("Hello E2E");
await expect(testPage.locator("#hello-status-left")).toHaveText("Hello status bar no-task");
await expect(testPage.locator("#hello-status-right")).toHaveText("Hello status bar no-task");
// --- 2b. main-top-bar slot renders on the default app top bar (Home) ---
await expect(testPage.locator("#hello-main-top-bar")).toBeVisible();
await expect(testPage.locator("#hello-main-top-bar")).toHaveText("Hello kanban");
const movedOrderingId = `plugin:${PLUGIN_ID}:app-status-bar-left:0`;
const movedContribution = testPage.locator(`[data-status-item-id="${movedOrderingId}"]`);
const [movedBox, statusBarBox] = await Promise.all([
movedContribution.boundingBox(),
testPage.getByTestId("app-status-bar").boundingBox(),
]);
if (!movedBox || !statusBarBox) throw new Error("plugin status drag geometry unavailable");
const orderSaved = testPage.waitForResponse(
(response) =>
response.request().method() === "PATCH" && response.url().endsWith("/api/v1/user/settings"),
);
await testPage.keyboard.down("Meta");
await testPage.mouse.move(movedBox.x + movedBox.width / 2, movedBox.y + movedBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(
statusBarBox.x + statusBarBox.width - 8,
statusBarBox.y + statusBarBox.height / 2,
{ steps: 8 },
);
await testPage.mouse.up();
await testPage.keyboard.up("Meta");
expect((await orderSaved).ok()).toBe(true);
expect(await testPage.evaluate(() => window.getSelection()?.toString() ?? "")).toBe("");
await expect(movedContribution).toHaveAttribute("data-status-side", "right");
await backend.restart();
await testPage.reload();
await expect(testPage.locator(`[data-status-item-id="${movedOrderingId}"]`)).toHaveAttribute(
"data-status-side",
"right",
{ timeout: 15_000 },
);
await navItem.click();
await expect(testPage).toHaveURL(new RegExp(`${PLUGIN_ROUTE}$`));
const pluginPage = testPage.locator("#hello-plugin-page");
@@ -163,6 +198,7 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await session.waitForLoad();
await expect(session.sidebar).toBeVisible({ timeout: 10_000 });
await expect(testPage.locator("#hello-sidebar")).toBeVisible();
await expect(testPage.locator("#hello-status-left")).toContainText(seedTask.id);
// --- 4. Back on the plugin's own page (mounted, no navigation from here
// on): create a task and prove BOTH the live WS path (counter) and the
@@ -198,15 +234,26 @@ test.describe("Plugins — gRPC plugin install/load/live-update/uninstall", () =
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible();
await pluginRow.getByRole("button", { name: "Disable" }).click();
await expect(pluginRow.getByText("Disabled", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(testPage.locator("#hello-status-left")).toHaveCount(0);
await expect(testPage.locator("#hello-status-right")).toHaveCount(0);
await testPage.goto("/");
await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toHaveCount(0);
await expect(testPage.locator("#hello-status-left")).toHaveCount(0);
await expect(testPage.locator("#hello-status-right")).toHaveCount(0);
// --- 6. Re-enable: nav item reappears live (no reload needed) ---
await testPage.goto("/settings/plugins");
await pluginRow.getByRole("button", { name: "Enable" }).click();
await expect(pluginRow.getByText("Active", { exact: true })).toBeVisible({ timeout: 10_000 });
await expect(testPage.locator(`[data-status-item-id="${movedOrderingId}"]`)).toHaveAttribute(
"data-status-side",
"right",
{ timeout: 15_000 },
);
await testPage.goto("/");
await expect(testPage.getByTestId(`plugin-nav-item-${NAV_ITEM_ID}`)).toBeVisible();
await expect(testPage.locator("#hello-status-left")).toHaveText("Hello status bar no-task");
await expect(testPage.locator("#hello-status-right")).toHaveText("Hello status bar no-task");
await testPage.goto("/settings/plugins");
@@ -17,6 +17,11 @@ test.describe("System Licenses page", () => {
const fullCountText = (await count.innerText()).trim();
expect(fullCountText.length).toBeGreaterThan(0);
await testPage.getByTestId("system-licenses-filter").fill("Orca status-bar reference");
await expect(testPage.getByTestId("system-license-row")).toContainText(
"Orca status-bar reference",
);
// Filter to "react" — expect at least one row and a different count value.
const filter = testPage.getByTestId("system-licenses-filter");
await filter.fill("react");
File diff suppressed because one or more lines are too long
@@ -85,6 +85,7 @@ function makeUnloadedSettings(): UserSettingsState {
terminalFontSize: null,
changesPanelLayout: "tree",
systemMetricsDisplay: { showInTopbar: false },
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
voiceMode: {
enabled: true,
engine: "auto",
+8 -1
View File
@@ -69,11 +69,18 @@ function carryForwardCoreSettings(current: DisplaySettings) {
tasksListSort: current.tasksListSort ?? DEFAULT_TASKS_LIST_SORT,
tasksListGroup: current.tasksListGroup ?? DEFAULT_TASKS_LIST_GROUP,
changesPanelLayout: current.changesPanelLayout ?? "tree",
systemMetricsDisplay: current.systemMetricsDisplay ?? { showInTopbar: false },
...carryForwardAppStatusSettings(current),
voiceMode: current.voiceMode ?? { ...DEFAULT_VOICE_MODE_STATE },
};
}
function carryForwardAppStatusSettings(current: DisplaySettings) {
return {
systemMetricsDisplay: current.systemMetricsDisplay ?? { showInTopbar: false },
appStatusBarOrder: current.appStatusBarOrder ?? { leftItemIds: [], rightItemIds: [] },
};
}
function carryForwardTaskActionSettings(current: DisplaySettings) {
return {
confirmTaskArchive: current.confirmTaskArchive ?? true,
+57
View File
@@ -3,6 +3,7 @@ import { pluginRegistry } from "./registry";
const TASK_SIDEBAR_SLOT = "task-sidebar";
const TASK_CREATED_ACTION = "task.created";
const APP_STATUS_LEFT_SLOT = "app-status-bar-left";
function cleanup(...pluginIds: string[]) {
pluginIds.forEach((id) => pluginRegistry.unregisterPlugin(id));
@@ -68,6 +69,62 @@ describe("pluginRegistry", () => {
expect(pluginRegistry.getSlotComponents("settings-nav")).toEqual([]);
});
it("returns slot registrations with stable owner and registration identity", () => {
const scopedA = pluginRegistry.forPlugin("plugin-a");
function Sidebar() {
return null;
}
scopedA.registerComponent(TASK_SIDEBAR_SLOT, Sidebar);
const [registration] = pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT);
expect(registration).toEqual({
registrationId: expect.any(String),
orderingId: expect.any(String),
pluginId: "plugin-a",
Component: Sidebar,
});
scopedA.registerComponent(TASK_SIDEBAR_SLOT, () => null);
expect(pluginRegistry.getSlotRegistrations(TASK_SIDEBAR_SLOT)[0]?.registrationId).toBe(
registration?.registrationId,
);
});
it("restores deterministic ordering identities after plugin re-enable", () => {
function First() {
return null;
}
function Second() {
return null;
}
const scoped = pluginRegistry.forPlugin("plugin-a");
scoped.registerComponent(APP_STATUS_LEFT_SLOT, First);
scoped.registerComponent(APP_STATUS_LEFT_SLOT, Second);
const before = pluginRegistry
.getSlotRegistrations(APP_STATUS_LEFT_SLOT)
.map((registration) => registration.orderingId);
pluginRegistry.unregisterPlugin("plugin-a");
const reenabled = pluginRegistry.forPlugin("plugin-a");
reenabled.registerComponent(APP_STATUS_LEFT_SLOT, First);
reenabled.registerComponent(APP_STATUS_LEFT_SLOT, Second);
expect(pluginRegistry.getSlotRegistrations(APP_STATUS_LEFT_SLOT)).toMatchObject([
{ orderingId: before[0], pluginId: "plugin-a", Component: First },
{ orderingId: before[1], pluginId: "plugin-a", Component: Second },
]);
expect(before[0]).not.toBe(before[1]);
});
});
describe("pluginRegistry — lifecycle", () => {
afterEach(() => {
cleanup("plugin-a", "plugin-b");
});
it("registers a WS handler and only returns it for the matching action", () => {
const scoped = pluginRegistry.forPlugin("plugin-a");
const handler = () => {};
+38 -2
View File
@@ -37,10 +37,20 @@ export interface PluginRouteRegistration extends RouteRegistration {
}
interface SlotRegistration {
registrationId: string;
orderingId: string;
slot: string;
Component: SlotComponent;
}
/** Slot component plus its stable registry identity and owning plugin. */
export interface PluginSlotRegistration {
registrationId: string;
orderingId: string;
pluginId: string;
Component: SlotComponent;
}
interface WsHandlerRegistration {
action: string;
handler: WsHandler;
@@ -56,6 +66,7 @@ class PluginRegistryStore {
private navItems: Owned<NavItem>[] = [];
private slotComponents: Owned<SlotRegistration>[] = [];
private wsHandlers: Owned<WsHandlerRegistration>[] = [];
private nextSlotRegistrationId = 0;
/** Display names from the boot payload, used for derived page-chrome titles. */
private pluginNames = new Map<string, string>();
private listeners = new Set<() => void>();
@@ -91,7 +102,18 @@ class PluginRegistryStore {
}
registerComponent(pluginId: string, slot: string, Component: SlotComponent): void {
this.slotComponents.push({ pluginId, value: { slot, Component } });
const ordinal = this.slotComponents.filter(
(entry) => entry.pluginId === pluginId && entry.value.slot === slot,
).length;
this.slotComponents.push({
pluginId,
value: {
registrationId: `slot-registration-${this.nextSlotRegistrationId++}`,
orderingId: pluginSlotOrderingId(pluginId, slot, ordinal),
slot,
Component,
},
});
this.notify();
}
@@ -130,9 +152,19 @@ class PluginRegistryStore {
}
getSlotComponents(slot: string): SlotComponent[] {
return this.getSlotRegistrations(slot).map((registration) => registration.Component);
}
/** Stable, plugin-owned slot registrations for host render boundaries. */
getSlotRegistrations(slot: string): PluginSlotRegistration[] {
return this.slotComponents
.filter((entry) => entry.value.slot === slot)
.map((entry) => entry.value.Component);
.map((entry) => ({
registrationId: entry.value.registrationId,
orderingId: entry.value.orderingId,
pluginId: entry.pluginId,
Component: entry.value.Component,
}));
}
/**
@@ -183,6 +215,10 @@ class PluginRegistryStore {
}
}
function pluginSlotOrderingId(pluginId: string, slot: string, ordinal: number): string {
return `plugin:${encodeURIComponent(pluginId)}:${encodeURIComponent(slot)}:${ordinal}`;
}
export const pluginRegistry = new PluginRegistryStore();
/** Snapshot hook: re-renders the caller whenever any plugin registration changes. */
+22 -2
View File
@@ -79,7 +79,9 @@ export interface PluginRouteOptions {
* sessionIds }`), "main-top-bar" (status/actions in the default app top bar on
* the Home / Kanban / Tasks views, beside the CPU/DB metrics and the
* view/display controls — the app-wide, task-agnostic counterpart to
* "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage }`), and
* "chat-top-bar"; receives `{ workspaceId, workspaceLabel, currentPage }`),
* "app-status-bar-left" / "app-status-bar-right" (receives
* `AppStatusBarSlotProps` as `slotProps`), and
* "plugin-settings" (inline UI on a plugin's own settings
* page, Settings > Plugins > <plugin>, at the top above the settings form —
* receives `{ pluginId: string; status: PluginStatus }` as
@@ -90,6 +92,20 @@ export interface PluginRouteOptions {
*/
export type PluginSlotName = string;
/**
* Context the host passes to every app status item. `placement` is its
* registration/default side; a user's saved order may render it on the other side.
*/
export type AppStatusBarSlotProps = {
placement: "left" | "right";
presentation: "bar" | "mobile-drawer";
density: "full" | "compact";
pathname: string;
activeWorkspaceId: string | null;
activeTaskId: string | null;
activeSessionId: string | null;
};
/** Component registered for a named slot; receives host-provided `slotProps`. */
export type SlotComponent = ReactType.ComponentType<{ slotProps?: unknown }>;
@@ -150,7 +166,11 @@ export interface PluginRegistry {
registerNavItem(item: NavItem): void;
/** Route under `/settings/plugins/{id}/...`, rendered inside the settings shell. */
registerSettingsRoute(path: string, Component: ReactType.ComponentType): void;
/** Named slot injection, rendered by `<PluginSlot name .../>`. */
/**
* Named slot injection, rendered by `<PluginSlot name .../>`. The
* `app-status-bar-left` and `app-status-bar-right` receive
* `AppStatusBarSlotProps` from the host.
*/
registerComponent(slot: PluginSlotName, Component: SlotComponent): void;
/** WS action handler, bridged into the existing `lib/ws` dispatch. */
registerWsHandler(action: string, handler: WsHandler): void;
+21
View File
@@ -10,6 +10,20 @@ import { workspaceId as toWorkspaceId } from "@/lib/types/ids";
const UPDATED_AT = "2026-01-01T00:00:00Z";
describe("buildCoreFields", () => {
it("maps portable app status bar order", () => {
const settings = {
app_status_bar_order: {
left_item_ids: ["builtin:connection", "plugin:left"],
right_item_ids: ["builtin:metrics"],
},
} as unknown as Parameters<typeof buildCoreFields>[0];
expect(buildCoreFields(settings).appStatusBarOrder).toEqual({
leftItemIds: ["builtin:connection", "plugin:left"],
rightItemIds: ["builtin:metrics"],
});
});
it("maps terminal_font_family to terminalFontFamily", () => {
const settings = {
workspace_id: toWorkspaceId(""),
@@ -117,6 +131,13 @@ describe("buildTerminalFields via buildCoreFields", () => {
});
describe("mapUserSettingsResponse", () => {
it("defaults portable app status bar order to empty arrays", () => {
expect(mapUserSettingsResponse(null).appStatusBarOrder).toEqual({
leftItemIds: [],
rightItemIds: [],
});
});
it("defaults missing and unknown MCP task agent profile preferences", () => {
const missing = mapUserSettingsResponse(null);
const unknown = mapUserSettingsResponse({
+9
View File
@@ -31,6 +31,13 @@ export function parseSystemMetricsDisplay(value: UserSettingsData["system_metric
return { showInTopbar: value?.show_in_topbar ?? false };
}
export function parseAppStatusBarOrder(value: UserSettingsData["app_status_bar_order"]) {
return {
leftItemIds: value?.left_item_ids ?? [],
rightItemIds: value?.right_item_ids ?? [],
};
}
/**
* Maps the backend's snake_case VoiceMode payload into the camelCase shape
* the store and UI use. Missing or partial payloads fall back to the defaults
@@ -139,6 +146,7 @@ export function buildCoreFields(s: UserSettingsData) {
githubSavedPresets: s.github_saved_presets,
githubDefaultQueryPresets: s.github_default_query_presets,
gitlabSavedPresets: s.gitlab_saved_presets,
appStatusBarOrder: parseAppStatusBarOrder(s.app_status_bar_order),
...buildTerminalFields(s),
...buildSystemMetricsDisplayFields(s),
...buildVoiceModeFields(s),
@@ -195,6 +203,7 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) {
terminalFontFamily: null,
terminalFontSize: null,
changesPanelLayout: "tree" as const,
appStatusBarOrder: parseAppStatusBarOrder(undefined),
...buildSystemMetricsDisplayFields(undefined),
voiceMode: { ...DEFAULT_VOICE_MODE_STATE },
...buildLspFields(undefined),
@@ -22,15 +22,18 @@ describe("features slice", () => {
}
expect(store.getState().features.office).toBe(false);
expect(store.getState().features.plugins).toBe(false);
expect(defaultFeaturesState.features).toHaveProperty("appStatusBar", false);
});
it("setFeatures replaces the whole flag map", () => {
const store = makeStore();
store.getState().setFeatures({ office: true, plugins: true });
store.getState().setFeatures({ office: true, plugins: true, appStatusBar: true });
expect(store.getState().features.office).toBe(true);
expect(store.getState().features.plugins).toBe(true);
store.getState().setFeatures({ office: false, plugins: false });
expect(store.getState().features.appStatusBar).toBe(true);
store.getState().setFeatures({ office: false, plugins: false, appStatusBar: false });
expect(store.getState().features.office).toBe(false);
expect(store.getState().features.plugins).toBe(false);
expect(store.getState().features.appStatusBar).toBe(false);
});
});
@@ -8,6 +8,7 @@ export const defaultFeaturesState: FeaturesSliceState = {
features: {
office: false,
plugins: false,
appStatusBar: false,
},
};
@@ -7,6 +7,7 @@
export type FeatureFlags = {
office: boolean;
plugins: boolean;
appStatusBar: boolean;
};
export type FeatureName = keyof FeatureFlags;
@@ -65,6 +65,7 @@ export const defaultSettingsState: SettingsSliceState = {
terminalFontSize: null,
changesPanelLayout: "tree",
systemMetricsDisplay: { showInTopbar: false },
appStatusBarOrder: { leftItemIds: [], rightItemIds: [] },
voiceMode: { ...DEFAULT_VOICE_MODE_STATE },
loaded: false,
},
@@ -178,10 +178,16 @@ export type UserSettingsState = {
terminalFontSize: number | null;
changesPanelLayout: "flat" | "tree";
systemMetricsDisplay: { showInTopbar: boolean };
appStatusBarOrder: AppStatusBarOrderState;
voiceMode: VoiceModeState;
loaded: boolean;
};
export type AppStatusBarOrderState = {
leftItemIds: string[];
rightItemIds: string[];
};
export type TaskCreateLastUsedState = {
repositoryId: string | null;
branch: string | null;
+2
View File
@@ -14,6 +14,7 @@ import type {
SidebarViewDraftApi,
SidebarTaskPrefsApi,
TaskCreateLastUsedApi,
AppStatusBarOrderApi,
MCPTaskAgentProfileDefault,
TaskPendingAction,
TaskSessionState,
@@ -364,6 +365,7 @@ export type UserSettingsUpdatedPayload = {
terminal_link_behavior?: string;
changes_panel_layout?: "flat" | "tree";
system_metrics_display?: { show_in_topbar?: boolean };
app_status_bar_order?: AppStatusBarOrderApi;
voice_mode?: import("@/lib/types/http-voice").VoiceModeSettings;
updated_at?: string;
};
+7
View File
@@ -40,6 +40,11 @@ export type TaskCreateLastUsedApi = {
executor_profile_id?: string;
};
export type AppStatusBarOrderApi = {
left_item_ids?: string[];
right_item_ids?: string[];
};
export type UserSettings = {
user_id: string;
workspace_id: WorkspaceId;
@@ -80,6 +85,7 @@ export type UserSettings = {
terminal_font_size?: number;
changes_panel_layout?: "flat" | "tree";
system_metrics_display?: { show_in_topbar?: boolean };
app_status_bar_order?: AppStatusBarOrderApi;
voice_mode?: VoiceModeSettings;
updated_at: string;
};
@@ -127,5 +133,6 @@ export type UserSettingsUpdatePayload = {
terminal_font_size?: number;
changes_panel_layout?: "flat" | "tree";
system_metrics_display?: { show_in_topbar?: boolean };
app_status_bar_order?: AppStatusBarOrderApi;
voice_mode?: VoiceModeSettings;
};
+1
View File
@@ -18,6 +18,7 @@ export type {
SidebarViewDraftApi,
SidebarTaskPrefsApi,
TaskCreateLastUsedApi,
AppStatusBarOrderApi,
MCPTaskAgentProfileDefault,
UserSettings,
UserSettingsResponse,

Some files were not shown because too many files have changed in this diff Show More