fix(ui): group mobile system metrics (#3880)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-09-23 12:00:02 +01:00
committed by GitHub
parent 31ad30f437
commit 16ca1657ce
11 changed files with 437 additions and 73 deletions
@@ -943,9 +943,28 @@ func (m *Manager) resumePassthroughCommand(ctx context.Context, execution *Agent
// without --resume, effectively clearing the agent's conversation context.
// The workflow step prompt is delivered afterwards via stdin (autoStartPassthroughPrompt).
func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *AgentExecution) error {
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return fmt.Errorf("interactive runner not available")
}
execution.passthroughLifecycleMu.Lock()
defer execution.passthroughLifecycleMu.Unlock()
processID, err := m.replacePassthroughProcess(ctx, execution, interactiveRunner)
execution.passthroughLifecycleMu.Unlock()
if err != nil {
return err
}
// First-idle callbacks acquire the lifecycle lock. Settle startup outside
// that lock before a workflow prompt can claim the next running turn.
readyCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
defer cancel()
if err := interactiveRunner.WaitForFirstIdle(readyCtx, processID); err != nil {
return fmt.Errorf("wait for restarted passthrough readiness: %w", err)
}
return nil
}
func (m *Manager) replacePassthroughProcess(ctx context.Context, execution *AgentExecution, interactiveRunner *process.InteractiveRunner) (string, error) {
m.logger.Info("restarting passthrough process for context reset",
zap.String("execution_id", execution.ID),
zap.String("session_id", execution.SessionID),
@@ -953,19 +972,14 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
// 1. Resolve the replacement command and environment before touching the
// current PTY. A profile-secret failure must leave the active session usable.
interactiveRunner := m.GetInteractiveRunner()
if interactiveRunner == nil {
return fmt.Errorf("interactive runner not available")
}
// Build fresh command (no SessionID, no Resume, no Prompt).
pt, rt, cmd, err := m.freshPassthroughCommand(ctx, execution)
if err != nil {
return err
return "", err
}
env, err := m.buildPassthroughEnv(ctx, execution, rt.RequiredEnv)
if err != nil {
return err
return "", err
}
// 2. Stop the current PTY process. Clear PassthroughProcessID before
@@ -989,7 +1003,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
processInfo, err := interactiveRunner.Start(ctx, startReq)
if err != nil {
m.updateExecutionError(execution.ID, "failed to restart passthrough session: "+err.Error())
return fmt.Errorf("failed to restart passthrough session: %w", err)
return "", fmt.Errorf("failed to restart passthrough session: %w", err)
}
// 4. Update execution with new process ID
@@ -1013,7 +1027,7 @@ func (m *Manager) restartPassthroughProcess(ctx context.Context, execution *Agen
// 6. Publish context reset event
m.eventPublisher.PublishAgentEvent(ctx, events.AgentContextReset, execution)
return nil
return processInfo.ID, nil
}
// ResumePassthroughSession restarts a passthrough session after backend restart.
@@ -0,0 +1,81 @@
package lifecycle
import (
"context"
"runtime"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/agent/agents"
"github.com/kandev/kandev/internal/agentctl/server/process"
"github.com/kandev/kandev/internal/events"
eventbus "github.com/kandev/kandev/internal/events/bus"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
func newPassthroughResetReadyTest(t *testing.T, command string) (*Manager, *process.InteractiveRunner, *AgentExecution) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("requires POSIX shell prompt output")
}
mgr, runner, execution := newPassthroughRunnerManager(t)
const agentID = "reset-ready-test-agent"
require.NoError(t, mgr.registry.Register(&testAgent{
id: agentID, enabled: true,
StandardPassthrough: agents.StandardPassthrough{Cfg: agents.PassthroughConfig{
Supported: true, PassthroughCmd: agents.NewCommand("sh", "-c", command),
PromptPattern: "READY> $", IdleTimeout: time.Minute, SubmitSequence: "\r",
}},
}))
mgr.profileResolver = &mockPassthroughProfileResolver{agentName: agentID, cliPassthrough: true}
execution.AgentID = agentID
execution.Status = v1.AgentStatusReady
t.Cleanup(func() {
_ = runner.Stop(context.Background(), execution.PassthroughProcessID)
})
return mgr, runner, execution
}
func TestRestartPassthroughProcessSettlesStartupBeforeNextTurn(t *testing.T) {
mgr, runner, execution := newPassthroughResetReadyTest(t, "printf 'READY> '; read -r input")
var startupSettled atomic.Bool
runner.SetTurnCompleteCallback(func(sessionID, processID string) {
mgr.handlePassthroughTurnComplete(sessionID, processID)
startupSettled.Store(true)
})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
require.NoError(t, mgr.restartPassthroughProcess(ctx, execution))
require.True(t, startupSettled.Load(), "reset must settle boot readiness before admitting a prompt")
require.NoError(t, mgr.MarkPassthroughRunning(execution.SessionID))
mgr.handlePassthroughTurnComplete(execution.SessionID, execution.PassthroughProcessID)
require.Equal(t, v1.AgentStatusReady, execution.Status)
bus := mgr.eventBus.(*MockEventBus)
var readyCount int
for _, event := range bus.PublishedEvents {
if event.Type == events.AgentReady {
readyCount++
}
}
require.Equal(t, 1, readyCount, "the next turn must retain its completion event")
}
func TestRestartPassthroughProcessCancelsStartupWait(t *testing.T) {
mgr, _, execution := newPassthroughResetReadyTest(t, "read -r input")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
bus := mgr.eventBus.(*MockEventBus)
bus.OnPublish = func(subject string, _ *eventbus.Event) {
if subject == events.AgentContextReset {
cancel()
}
}
err := mgr.restartPassthroughProcess(ctx, execution)
require.ErrorIs(t, err, context.Canceled)
}
@@ -178,15 +178,13 @@ describe("StatusSurfaceMetrics", () => {
expect(screen.queryByTestId(metricsTestId)).toBeNull();
});
it("subscribes and renders rows when the phone Status drawer opens", () => {
it("subscribes and renders host metrics when the phone Status drawer opens", () => {
responsiveState.isMobile = true;
renderMetrics(true);
expect(subscribeMock).toHaveBeenCalledWith(true);
const metricsRow = screen.getByLabelText("Host metrics").parentElement;
expect(metricsRow?.className).toContain("min-h-11");
expect(metricsRow?.className).toContain("w-full");
expect(metricsRow?.className).toContain("min-w-0");
expect(screen.getByLabelText("Host metrics")).toBeTruthy();
expect(screen.getByLabelText(cpuAccessibleName)).toBeTruthy();
});
it("renders every received host metric in the phone Status drawer", () => {
@@ -18,6 +18,7 @@ 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 { cn } from "@/lib/utils";
type StatusSurfaceMetricsProps = {
presentation: "bar" | "mobile-drawer";
@@ -49,18 +50,29 @@ export function StatusSurfaceMetrics({
return (
<section
data-testid="app-status-metrics"
className="w-full min-w-0 space-y-2 py-0.5"
className="w-full min-w-0 space-y-3 rounded-lg border bg-muted/20 p-3"
aria-label={t("system:systemMetrics")}
>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{t("system:systemMetrics")}
</h3>
<div className="flex min-w-0 items-center justify-between gap-2 text-xs text-muted-foreground">
<h3 className="text-[10px] font-semibold uppercase tracking-[0.12em]">
{t("system:systemMetrics")}
</h3>
{host && !simplified ? (
<SourceBadge
source={host}
updatedAt={snapshot.timestamp}
showLabel
iconSize={iconSize}
/>
) : null}
</div>
{!host ? (
<EmptyMetrics drawer iconSize={iconSize} />
) : (
<DrawerSourceMetrics
<MetricValues
source={host}
updatedAt={snapshot?.timestamp}
updatedAt={snapshot.timestamp}
layout="grid"
simplified={simplified}
iconSize={iconSize}
/>
@@ -144,35 +156,6 @@ function BarSourceMetrics({
);
}
function DrawerSourceMetrics({
source,
updatedAt,
simplified,
iconSize,
}: {
source: SystemMetricsSource;
updatedAt?: string;
simplified: boolean;
iconSize: string;
}) {
return (
<div className="flex min-h-11 w-full min-w-0 flex-col items-stretch gap-1 px-0 text-sm">
{!simplified ? (
<div className="flex min-h-11 w-full min-w-0 items-center">
<SourceBadge source={source} updatedAt={updatedAt} showLabel iconSize={iconSize} />
</div>
) : null}
<MetricValues
source={source}
updatedAt={updatedAt}
layout="grid"
simplified={simplified}
iconSize={iconSize}
/>
</div>
);
}
function SourceBadge({
source,
updatedAt,
@@ -239,12 +222,7 @@ function MetricValues({
/>
));
if (layout === "grid") {
return (
// Keep detailed cells compact enough for two columns in the Pixel 5 drawer.
<div className="grid w-full min-w-0 grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-x-3 gap-y-1">
{values}
</div>
);
return <div className="grid w-full min-w-0 grid-cols-3 gap-x-3 gap-y-4">{values}</div>;
}
return <span className="flex min-w-0 items-center gap-3 overflow-hidden">{values}</span>;
}
@@ -267,16 +245,34 @@ function MetricValue({
const { t } = useTranslation();
const locale = useDateLocale();
const help = metric.id === "io_load" ? t("system:ioLoadHelp") : null;
const detailedTile = layout === "grid" && !simplified;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`${layout === "grid" ? "flex min-h-11 min-w-0 w-full" : "inline-flex shrink-0"} items-center gap-1.5 tabular-nums ${metricColor(metric)}`}
className={cn(
"items-center gap-1.5 tabular-nums",
layout === "grid" ? "flex min-h-11 w-full min-w-0" : "inline-flex shrink-0",
detailedTile && "flex-col items-stretch gap-1",
metricColor(metric),
)}
aria-label={`${metricLabel(t, metric.id)} ${formatMetric(metric)}`}
>
{metricIcon(metric.id, iconSize)}
{!simplified ? <MetricMeter metric={metric} /> : null}
<span className="font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]">
{detailedTile ? (
<span className="flex min-h-8 items-center gap-1 text-xs text-muted-foreground">
{metricIcon(metric.id, iconSize)}
<span className="min-w-0 break-words">{metricLabel(t, metric.id)}</span>
</span>
) : (
metricIcon(metric.id, iconSize)
)}
{!simplified ? <MetricMeter metric={metric} wide={detailedTile} /> : null}
<span
className={cn(
"font-medium tracking-[-0.015em] [font-family:var(--font-geist-mono)]",
detailedTile && "min-w-0 break-words text-lg",
)}
>
{formatMetric(metric)}
</span>
</span>
@@ -324,16 +320,19 @@ function metricIcon(id: string, iconSize: string) {
cpu_temp: IconFlame,
io_load: IconGauge,
}[id] ?? IconActivity;
return <Icon className={`${iconSize} opacity-80`} stroke={1.6} />;
return <Icon className={`${iconSize} shrink-0 opacity-80`} stroke={1.6} />;
}
function MetricMeter({ metric }: { metric: SystemMetricSample }) {
function MetricMeter({ metric, wide = false }: { metric: SystemMetricSample; wide?: boolean }) {
if (metric.unit !== "%" || typeof metric.value !== "number") return null;
const width = `${Math.max(0, Math.min(100, metric.value))}%`;
return (
<span
data-testid="system-metric-meter"
className="h-1 w-7 overflow-hidden rounded-full bg-muted-foreground/20"
className={cn(
"h-1 shrink-0 overflow-hidden rounded-full bg-muted-foreground/20",
wide ? "order-last mt-1 w-full" : "w-7",
)}
aria-hidden="true"
>
<span className="block h-full rounded-full bg-current opacity-65" style={{ width }} />
@@ -1,4 +1,10 @@
import { expect, test } from "../../fixtures/test-base";
import { waitForFiniteAnimations } from "../../helpers/animations";
import {
assertNoDocumentHorizontalOverflow,
assertNoElementHorizontalOverflow,
requireBox,
} from "../../helpers/layout-assertions";
import {
captureAppStatusBarSettings,
restoreAppStatusBarSettings,
@@ -29,7 +35,8 @@ test.describe("Mobile resource metrics display", () => {
let statusBarBaseline: AppStatusBarSettingsBaseline;
let globalMetricsBaseline: SystemMetricsGlobalSettings;
test.beforeEach(async ({ apiClient }) => {
test.beforeEach(async ({ apiClient, testPage }) => {
void testPage;
const settings = await apiClient.getUserSettings();
baseline = settings.settings.system_metrics_display as SystemMetricsDisplay;
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
@@ -58,7 +65,7 @@ test.describe("Mobile resource metrics display", () => {
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
});
test("stacks all enabled metrics inside the Status drawer", async ({ testPage, apiClient }) => {
test("aligns all enabled metrics inside the Status drawer", async ({ testPage, apiClient }) => {
const globalMetricsUpdate = await apiClient.rawRequest(
"PATCH",
"/api/v1/system/metrics/settings",
@@ -71,12 +78,13 @@ test.describe("Mobile resource metrics display", () => {
expect(displayUpdate.ok).toBe(true);
await testPage.goto("/");
await testPage.getByTestId("app-nav-trigger").click();
await testPage.getByTestId("mobile-home-status-button").click();
await testPage.getByTestId("app-nav-trigger").tap();
await testPage.getByTestId("mobile-home-status-button").tap();
const drawer = testPage.getByTestId("app-status-drawer");
const metrics = drawer.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU (?!temperature)/)).toBeVisible();
await waitForFiniteAnimations(testPage.getByRole("dialog", { name: "Status", exact: true }));
const geometry = await metrics.evaluate((element) => {
const indicators = Array.from(
element.querySelectorAll<HTMLElement>('span[aria-label]:not([aria-label="Host metrics"])'),
@@ -105,17 +113,88 @@ test.describe("Mobile resource metrics display", () => {
};
});
expect(geometry.rowCount).toBe(3);
expect(geometry.firstRowCount).toBe(2);
expect(geometry.rowCount).toBe(2);
expect(geometry.firstRowCount).toBe(3);
expect(geometry.count).toBe(5);
expect(geometry.contained).toBe(true);
expect(geometry.valuesScrollWidth).toBeLessThanOrEqual(geometry.valuesClientWidth + 1);
for (const label of ["CPU", "Memory", "Disk", "CPU temperature", "System load (1 min)"]) {
const reading = metrics.getByText(label, { exact: true });
await reading.scrollIntoViewIfNeeded();
await expect(reading).toBeInViewport();
}
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),
);
});
test("groups the default readings in one compact card in the phone menu", async ({
testPage,
apiClient,
prCapture,
}) => {
await setAppStatusBarEnabled(apiClient, false);
const globalUpdate = await apiClient.rawRequest("PATCH", "/api/v1/system/metrics/settings", {
...globalMetricsBaseline,
metrics: ALL_HOST_METRICS.slice(0, 3),
});
expect(globalUpdate.ok).toBe(true);
const displayUpdate = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: { show_in_topbar: true, simplified: false },
});
expect(displayUpdate.ok).toBe(true);
await testPage.emulateMedia({ colorScheme: "dark" });
await testPage.goto("/");
for (const width of [393, 320, 767]) {
await testPage.setViewportSize({ width, height: 851 });
const trigger = testPage.getByTestId("app-nav-trigger");
await trigger.tap();
const menu = testPage.getByTestId("app-nav-sheet");
const metrics = menu.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await metrics.scrollIntoViewIfNeeded();
await waitForFiniteAnimations(menu);
const heading = await requireBox(metrics.getByRole("heading"), "metrics heading");
const host = await requireBox(metrics.getByLabel("Host metrics"), "host badge");
expect(
Math.abs(heading.y + heading.height / 2 - host.y - host.height / 2),
).toBeLessThanOrEqual(1);
const boxes = [];
for (const label of ["CPU", "Memory", "Disk"]) {
await expect(metrics.getByText(label, { exact: true })).toBeVisible();
const reading = metrics.getByLabel(new RegExp(`^${label} `));
boxes.push(await requireBox(reading, label));
await assertNoElementHorizontalOverflow(reading, label);
}
for (const box of boxes) {
expect(box.y).toBeCloseTo(boxes[0].y, 0);
expect(box.width).toBeCloseTo(boxes[0].width, 0);
expect(box.height).toBeGreaterThanOrEqual(44);
}
await assertNoElementHorizontalOverflow(metrics);
await assertNoDocumentHorizontalOverflow(testPage);
await expect(menu.locator("nav.overflow-y-auto")).toHaveCount(1);
if (width === 393) {
if (prCapture.capturing) await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
await prCapture.screenshot("system-metrics-menu-mobile", {
caption: "CPU, memory, and disk grouped in the phone menu's built-in System metrics card",
});
}
await testPage.keyboard.press("Escape");
await expect(menu).toBeHidden();
await expect(trigger).toBeFocused();
}
await testPage.setViewportSize({ width: 768, height: 851 });
await expect(testPage.getByTestId("app-nav-trigger")).toBeHidden();
const metrics = testPage.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await expect(metrics).toHaveCSS("display", "flex");
await expect(metrics.getByRole("heading")).toHaveCount(0);
await assertNoDocumentHorizontalOverflow(testPage);
});
test("renders simplified metrics in the Status drawer", async ({ testPage }) => {
await testPage.goto("/settings");
await testPage.getByTestId("settings-index").getByRole("link", { name: "Appearance" }).click();
@@ -1,4 +1,5 @@
import { expect, test } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow, requireBox } from "../../helpers/layout-assertions";
import {
captureAppStatusBarSettings,
restoreAppStatusBarSettings,
@@ -19,7 +20,8 @@ test.describe("Resource metrics display", () => {
let baseline: SystemMetricsDisplay;
let statusBarBaseline: AppStatusBarSettingsBaseline;
test.beforeEach(async ({ apiClient }) => {
test.beforeEach(async ({ apiClient, testPage }) => {
void testPage;
const settings = await apiClient.getUserSettings();
baseline = settings.settings.system_metrics_display as SystemMetricsDisplay;
statusBarBaseline = await captureAppStatusBarSettings(apiClient);
@@ -33,6 +35,31 @@ test.describe("Resource metrics display", () => {
await restoreAppStatusBarSettings(apiClient, statusBarBaseline);
});
test("keeps detailed desktop metrics in a compact inline status bar", async ({
testPage,
apiClient,
prCapture,
}) => {
const update = await apiClient.rawRequest("PATCH", "/api/v1/user/settings", {
system_metrics_display: { show_in_topbar: true, simplified: false },
});
expect(update.ok).toBe(true);
await testPage.emulateMedia({ colorScheme: "dark" });
await testPage.goto("/");
const metrics = testPage.getByTestId("app-status-metrics");
await expect(metrics.getByLabel(/^CPU /)).toBeVisible();
await expect(metrics.getByRole("heading")).toHaveCount(0);
const host = await requireBox(metrics.getByLabel("Host metrics"), "host badge");
const cpu = await requireBox(metrics.getByLabel(/^CPU /), "CPU reading");
expect(Math.abs(host.y + host.height / 2 - cpu.y - cpu.height / 2)).toBeLessThanOrEqual(1);
expect((await requireBox(metrics, "desktop metrics")).height).toBeLessThanOrEqual(24);
await assertNoDocumentHorizontalOverflow(testPage);
if (prCapture.capturing) await expect(testPage.getByTestId("toast-message")).toHaveCount(0);
await prCapture.screenshot("system-metrics-desktop", {
caption: "Desktop retains the compact inline host metrics in the status bar",
});
});
test("renders simplified metrics in the status bar", async ({ testPage }) => {
await testPage.goto("/settings/preferences/appearance");
const showMetrics = testPage.getByRole("switch", { name: "Show host metrics in status bar" });
@@ -0,0 +1,67 @@
---
created: 2026-09-23
status: complete
requirements:
- REQ-UI-APP-STATUS-BAR-001
system_design:
- ../../specs/ui/system-design/app-status-bar.md
legacy_specs: []
---
# Implementation Plan: Mobile System Metrics Card
## Overview
Group built-in phone metrics into one compact card. The previous composition gave Host its own full-height row and spread three default readings over two rows. The user authorized implementation and PR publication in this session without a planning handoff.
## Scope and approach
Update `StatusSurfaceMetrics` only: move the host badge beside the heading, render three equal metric columns, and give detailed readings visible translated labels and meters below their values. Retain all enabled metrics, existing formatting, thresholds, tooltips, subscription gates, and simplified presentation. Plugin contributions and backend collection are out of scope.
## Mobile design contract
The existing Menu fallback and Status drawer remain the entry points. The nearest exemplars are `AppNavSheet` and `AppStatusDrawer`: inset drawers, fixed headings, safe-area clearance, and one internal scroller. This passive summary belongs inline in those temporary surfaces. It introduces no new action or navigation layer. Metric cells retain at least 44 px height and wrap labels within their columns; the shared store and subscription remain authoritative.
## ASCII UI preview
UI-01: Phone Menu or Status, detailed metrics loaded (AC-UI-APP-STATUS-BAR-001.2, .6, .7):
```text
+-------------------------------------+
| SYSTEM METRICS Host |
| CPU Memory Disk |
| 3% 52% 72% |
| [meter] [meter] [meter] |
+-------------------------------------+
```
Three equal columns, a shared heading line, and labels above values are structural. Values and spacing are illustrative. Additional metrics continue on another row; simplified mode omits the host, labels, and meters. The parent drawer owns scrolling.
UI-02: Desktop remains inline:
```text
| Host [CPU meter] 3% [Memory meter] 52% [Disk meter] 72% |
```
## Tests and work orders
- Existing `components/system-metrics` unit tests retain subscription, loading, source filtering, and simplified-mode coverage.
- `mobile-resource-metrics-display.spec.ts` proves the five-metric grid, default readings in Menu at 320/393/767 px, the 768 px desktop fallback, containment, scrolling, and simplified settings persistence.
- `resource-metrics-display.spec.ts` proves compact desktop alignment and simplified settings persistence.
- [x] [Task 01: Group mobile system metrics](task-01-group-mobile-system-metrics.md).
## Verification results
RED: the production-build phone regression failed on expected two rows versus the existing three rows for five readings. GREEN: 11 system-metrics unit tests, 3 mobile Playwright tests, and 2 desktop Playwright tests passed against the rebuilt SPA. The final mobile run also verifies that every enabled label can be scrolled into the viewport. Targeted ESLint, Prettier, typecheck, i18n ratchet, specification validation, and public-doc validation passed. Fresh isolated phone and desktop screenshots were captured and inspected.
## Risks
Long translated labels must wrap within the narrow columns. Desktop density limits, unavailable values, and simplified metrics must retain their existing behavior.
## PR validation follow-up
PR #3880 review identified two documentation corrections: state explicitly that the phone Menu fallback applies when Show status bar is off, and link this plan from the status-bar design. Both are addressed.
CI also exposed a terminal context-reset race in `terminal-agent.spec.ts`. The same assertion reproduced without retries using the CI runtime image and backend artifacts. A fresh PTY's startup-ready callback could race the next running-state publication and suppress that turn's completion. The lifecycle reset now waits for first idle outside the passthrough lifecycle lock before returning. This is an internal ordering correction to the existing workflow reset contract; it does not change metrics collection or plugin behavior.
Two focused lifecycle regressions failed before the fix. All five reset tests passed with `-race -count=20`; the complete lifecycle package passed with `-race`, and changed-code Go lint reported zero issues. Terminal browser validation and the subsequent CI check are recorded in the task results.
@@ -0,0 +1,88 @@
---
id: "01-group-mobile-system-metrics"
title: "Group mobile system metrics"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-UI-APP-STATUS-BAR-001
acceptance_criteria:
- AC-UI-APP-STATUS-BAR-001.2
- AC-UI-APP-STATUS-BAR-001.6
- AC-UI-APP-STATUS-BAR-001.7
system_design:
- ../../specs/ui/system-design/app-status-bar.md
---
# Task 01: Group Mobile System Metrics
## Scope
Implement the [plan](plan.md) using the [status-bar requirements](../../specs/ui/requirements/app-status-bar.md) and [system design](../../specs/ui/system-design/app-status-bar.md). Own the built-in metrics component, its existing tests, both resource-metrics Playwright specs, and the corresponding operations/layout documentation. Exclude plugin UI, collection, settings persistence, and navigation changes. No dependencies or delegation.
## Acceptance
1. Phone Menu and Status show one coherent group: Host alongside the heading, three equally sized columns, detailed labels and values, and meters below the values. Every enabled reading remains visible.
2. Narrow phones and breakpoint transitions retain containment, one parent scroller, focus return, and the compact desktop presentation.
3. Loading/subscription behavior, simplified mode, thresholds, formatting, and source filtering remain intact. Capture fresh isolated mobile and desktop screenshots for the PR.
## ASCII UI preview
UI-01 from the [full preview](plan.md#ascii-ui-preview), mapped to AC-UI-APP-STATUS-BAR-001.2, .6, .7:
```text
+-------------------------------------+
| SYSTEM METRICS Host |
| CPU Memory Disk |
| 3% 52% 72% |
| [meter] [meter] [meter] |
+-------------------------------------+
```
Desktop retains UI-02's inline bar. The phone card shares the existing drawer scroller. The drawing defines grouping and reading order, not exact pixels.
## Verification
From `apps/web`:
```sh
pnpm exec vitest run components/system-metrics
pnpm exec eslint components/system-metrics/status-surface-metrics.tsx components/system-metrics/status-surface-metrics.test.tsx e2e/tests/settings/mobile-resource-metrics-display.spec.ts e2e/tests/settings/resource-metrics-display.spec.ts
pnpm run typecheck
pnpm run i18n:ratchet
CAPTURE_PR_ASSETS=true pnpm e2e:run --host --project mobile-chrome e2e/tests/settings/mobile-resource-metrics-display.spec.ts --retries=0
CAPTURE_PR_ASSETS=true pnpm e2e:run --host --no-build e2e/tests/settings/resource-metrics-display.spec.ts --retries=0
```
Preserve mobile assets before the second capture run and merge the manifests afterward. From the repo root:
```sh
python3 scripts/list-docs.py validate
python3 scripts/lint-spec-files.py --all
node --test scripts/validate-public-docs.test.mjs
node scripts/validate-public-docs.mjs
git diff --check
```
## Results
RED reproduced the old five-reading grid as three rows instead of two. All listed checks passed: 11 unit tests, 3 mobile Playwright tests, 2 desktop Playwright tests, ESLint, typecheck, i18n ratchet, specification/catalog validation, and public-doc validation (62 validator tests). The final mobile run used `--no-build` after a fresh successful production build; only the test's scroll reachability assertions changed between runs. Prettier and whitespace checks passed. Phone and desktop screenshots were captured with disposable E2E data and visually inspected against UI-01/UI-02.
### CI follow-up
The original CI failure was `terminal-agent.spec.ts`'s context-reset cascade, with report and aggregate failures following from that leaf. Two new lifecycle regressions reproduced the readiness boundary before the fix. From `apps/backend`, these checks passed:
```sh
go test -race ./internal/agent/runtime/lifecycle -run '^TestRestartPassthroughProcess' -count=20
go test -race ./internal/agent/runtime/lifecycle -count=1
golangci-lint run ./... --new-from-rev=545cd0b5991c2716e3fcfdfc6102d39e69a64167 --timeout=5m
```
`make build-backend` passed. Browser validation used the failed CI run's runtime image, `ghcr.io/kdlbs/kandev-ci@sha256:61bc3395791d25639eadc108c691863e122f2710ecd7caf6916fca188ab71c04`, with the rebuilt backend and retries disabled. From `apps/web` inside that runtime, all 20 repetitions passed:
```sh
bash e2e/scripts/run-raw-e2e.sh --project=chromium e2e/tests/terminal/terminal-agent.spec.ts --grep 'context reset relaunches PTY and delivers prompt in cascade' --repeat-each=20 --max-failures=1 --reporter=list --retries=0
```
Documentation/catalog checks also passed after the review corrections. Shard replay and remote CI completion are tracked in the task plan and PR #3880 checks.
+2
View File
@@ -744,6 +744,8 @@ After restart, verify `/ready`, **System > About**, **System > Status**, the dat
Configure sampling at **Settings > Preferences > Appearance > Resource Metrics**. Defaults are CPU, memory, and disk percentage every five seconds, backend disk path `/`, and execution-environment collection off. Valid intervals are 1–300 seconds; at least one of CPU, memory, disk, CPU temperature, or 1-minute system load remains selected. System load is the average number of tasks running or waiting for CPU during the last minute; compare it with the host's CPU core count. Enable **Simplified metrics** to show only each metric icon and value in the status bar, fallback top bar, or phone Status drawer, without the Host marker or percentage progress bars.
On phones, detailed readings appear together in a **System metrics** card, with labelled CPU, memory, and disk readings on one row. Additional enabled metrics continue below. Open **Status** when the status bar is enabled, or **Menu** when **Show status bar** is off but host metrics are enabled.
Collection starts only while at least one connected client displays metrics in the status bar, fallback top bar, or an open phone Status drawer. Phone clients subscribe only while their Status drawer is open. The built-in status surface renders the Kandev host source only. Enabling execution metrics also adds active Docker, Kubernetes, SSH, and Sprites `agentctl` sources to the metrics stream for separately owned consumers such as plugins; execution disk sampling uses `/`. A provider hook also exists for remote Docker, but creating that runtime currently returns a not-implemented error. Missing platform APIs, container permissions, an invalid disk path, a disconnected executor, macOS/Windows temperature support, or Windows load-average support produce unavailable samples rather than quotas.
These metrics are lightweight UI observability. Set alerts, retention, CPU/memory limits, and disk quotas in the host, container platform, or external monitoring stack.
@@ -226,6 +226,14 @@ Timeout changes neither that fallback's semantics nor its separate initializatio
Contention on `remoteInstanceLifecycleMu` or `streamWriteMu` is a separate wait from the unanswered request addressed here.
Tests must establish that the peer received the request before measuring its response deadline.
For a passthrough reset, lifecycle replaces the PTY under
`passthroughLifecycleMu`, then releases that lock and waits for the replacement's
first-idle callback to finish before returning. The callback needs the same
lock. This keeps startup readiness from consuming the next workflow prompt's
running state. The readiness wait honors caller cancellation and has a 60-second
ceiling, matching initial stdin-prompt readiness. Failure returns through the
existing reset-error path and prevents automatic prompt dispatch.
### Persist a visible failure
The workflow reset returns an error through an error-bearing helper.
@@ -4,7 +4,7 @@ system: ui
requirements:
- REQ-UI-APP-STATUS-BAR-001
created: 2026-07-21
updated: 2026-08-23
updated: 2026-09-23
owners:
- kandev
---
@@ -35,7 +35,7 @@ Kandev has useful app-wide state, but it is scattered through route headers. A s
- Canonical connection state and error from `state.connection.status` / `state.connection.error`, with a restrained semantic dot, the connected detail **Connected to Kandev**, accessible text, and readable failure detail.
- Existing Kandev-host CPU/memory metrics, preserving enabled-metric order, formatting, thresholds, and tooltips. Desktop and tablet retain their density limits. The phone Status drawer renders every enabled host metric because its vertical surface does not need the bar's horizontal limit. The built-in surface does not render active-task, active-session, or executor metrics.
- Resource metrics offer two persisted presentation styles. **Detailed** is the default and shows the host marker plus percentage meter bars. **Simplified** shows only each metric icon and formatted value, with no host marker or meter bars. The selected style applies consistently to the desktop/tablet bar, the pre-status-bar topbar fallback, and the phone Status drawer.
- Phone metrics use a width-aware grid instead of one horizontal strip. The detailed Host marker occupies its own leading line, then the grid chooses the number of columns that fit the available row width, uses two columns in the configured Pixel 5 viewport, and adds vertical rows as indicators increase (six indicators form two columns by three rows there). Every indicator remains inside the metrics item without clipping or horizontal scrolling.
- Phone metrics form one compact card in the Status drawer and the Menu fallback. The detailed Host marker shares the section heading line. Three equal columns keep the default CPU, memory, and disk readings together; additional metrics continue in enabled order on later rows. Detailed cells show translated labels, values, and percentage meters beneath the values. Labels wrap within their column. Simplified cells retain only their icons and values. Every indicator remains inside the metrics item without clipping or horizontal scrolling.
- `userSettings.systemMetricsDisplay.showInTopbar` remains the persisted/wire compatibility key. User-visible copy calls this the Status bar setting; no migration or API break occurs.
- Existing composer-local `ChatStatusBar` remains separate. Queue, PR, share, and next-step affordances stay with chat.
- The connection indicator, complete metrics cluster, and each plugin component registration are individual status items. Holding Cmd on macOS or Ctrl on other platforms while dragging with the mouse reorders an item horizontally across the full bar, including across its flexible spacer.
@@ -140,7 +140,7 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea
- **GIVEN** the detailed metrics style or no stored style preference, **WHEN** host metrics render in a status surface, **THEN** the host marker and percentage meter bars remain visible.
- **GIVEN** the user selects **Simplified metrics** and saves Appearance settings, **WHEN** host metrics render in the desktop/tablet status bar or pre-status-bar topbar fallback, **THEN** each enabled metric shows its icon and formatted value without a host marker or percentage meter bar, and the choice survives reload.
- **GIVEN** the simplified metrics preference, **WHEN** a phone user opens Status, **THEN** the metrics row shows the same icon-and-value presentation without a host marker or percentage meter bar.
- **GIVEN** all host metrics are enabled on a phone, **WHEN** the user opens Status, **THEN** every host metric is visible in enabled order inside a width-aware grid; the configured Pixel 5 viewport uses two columns and additional metrics continue on later rows without widening the metrics item, drawer, or document.
- **GIVEN** all host metrics are enabled on a phone, **WHEN** the user opens Status or the Menu fallback, **THEN** every host metric is visible in enabled order inside a three-column card; additional metrics continue on later rows without widening the metrics item, drawer, or document.
- **GIVEN** metrics preference disabled, or a phone Status drawer closed, **WHEN** the app runs, **THEN** no system-metrics WebSocket subscription is held by this feature.
- **GIVEN** **Show status bar** is on for a phone user, **WHEN** they choose Status from a native entry point, **THEN** the drawer shows the same built-ins and plugin regions; dismissing it restores focus and leaves no persistent status bar.
- **GIVEN** a user turns off **Show status bar** and saves Appearance, **WHEN** the save succeeds, **THEN** the bar/drawer and ordinary native Status triggers disappear immediately, remain absent after reload and on another client, and no restart is required.
@@ -168,3 +168,4 @@ Visual interaction is a clean Kandev adaptation of Orca's public status-bar idea
- [Original App status bar plan](../../../plans/app-status-bar/plan.md)
- [Status bar Appearance setting promotion plan](../../../plans/app-status-bar-appearance-setting/plan.md)
- [Mobile Status metrics grid repair plan](../../../plans/mobile-status-metrics-grid/plan.md)
- [Mobile system metrics card plan](../../../plans/mobile-system-metrics-card/plan.md)