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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
|
||||
+499
-218
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = () => {};
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user