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" });