diff --git a/packages/ui/src/features/autoresearch/AutoresearchObservability.test.tsx b/packages/ui/src/features/autoresearch/AutoresearchObservability.test.tsx new file mode 100644 index 0000000000..959ed74ad3 --- /dev/null +++ b/packages/ui/src/features/autoresearch/AutoresearchObservability.test.tsx @@ -0,0 +1,95 @@ +import type { AutoresearchRun } from "@posthog/core/autoresearch/schemas"; +import type { AcpMessage } from "@posthog/shared"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AutoresearchObservability } from "./AutoresearchObservability"; + +const STARTED_AT = 1_000; + +function makeRun(overrides: Partial = {}): AutoresearchRun { + return { + id: "run-1", + config: { + taskId: "task-1", + direction: "minimize", + targetValue: null, + maxIterations: 10, + implementModel: null, + measureModel: null, + implementEffort: null, + measureEffort: null, + instructions: "Reduce memory usage.", + }, + status: "running", + metricName: null, + metricUnit: null, + phase: null, + originalModel: null, + originalEffort: null, + researchFindings: [], + iterations: [], + startedAt: STARTED_AT, + endedAt: null, + endReason: null, + interruptedReason: null, + lastError: null, + ...overrides, + }; +} + +function toolCall(ts: number, kind: string): AcpMessage { + return { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + title: `tool at ${ts}`, + kind, + status: "completed", + }, + }, + }, + } as AcpMessage; +} + +function renderObservability(run: AutoresearchRun, events: AcpMessage[]) { + return render( + + + , + ); +} + +describe("AutoresearchObservability", () => { + afterEach(() => vi.useRealTimers()); + + it("keeps observed-time bars within range when a kind outlasts wall-clock", () => { + // Live run where the latest tool timestamp sits ahead of `now` (clock skew + // between agent-set event timestamps and the client clock). The activity + // analysis then attributes more elapsed time to one kind than the run's + // wall-clock duration, which used to push Progress past its max of 100. + vi.useFakeTimers(); + vi.setSystemTime(STARTED_AT + 10_000); + + renderObservability(makeRun(), [ + toolCall(STARTED_AT, "edit"), + toolCall(STARTED_AT + 25_000, "execute"), + ]); + + const bars = screen.getAllByRole("progressbar"); + expect(bars).toHaveLength(4); + for (const bar of bars) { + const now = bar.getAttribute("aria-valuenow"); + // A rejected (out-of-range) value renders no aria-valuenow at all. + expect(now).not.toBeNull(); + const value = Number(now); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(100); + } + }); +}); diff --git a/packages/ui/src/features/autoresearch/AutoresearchObservability.tsx b/packages/ui/src/features/autoresearch/AutoresearchObservability.tsx index f047768fff..a4f3bdf0ab 100644 --- a/packages/ui/src/features/autoresearch/AutoresearchObservability.tsx +++ b/packages/ui/src/features/autoresearch/AutoresearchObservability.tsx @@ -150,7 +150,12 @@ function TimeRow({ value: number; total: number; }) { - const percentage = Math.round((value / total) * 100); + // Per-kind observed time can exceed the run's wall-clock duration, so clamp + // before handing the value to Progress (whose max defaults to 100). + const percentage = + total > 0 + ? Math.min(100, Math.max(0, Math.round((value / total) * 100))) + : 0; return (
diff --git a/packages/ui/src/features/billing/UsageButton.tsx b/packages/ui/src/features/billing/UsageButton.tsx index 95b42d3001..01533babc9 100644 --- a/packages/ui/src/features/billing/UsageButton.tsx +++ b/packages/ui/src/features/billing/UsageButton.tsx @@ -59,7 +59,10 @@ export function UsageButton() { usage.sustained.used_percent >= usage.burst.used_percent ? usage.sustained : usage.burst; - const usagePercent = Math.min(Math.round(dominant.used_percent), 100); + const usagePercent = Math.min( + Math.max(Math.round(dominant.used_percent), 0), + 100, + ); const resetLabel = formatResetTime(dominant.reset_at); const handleOpenChange = (nextOpen: boolean) => { diff --git a/packages/ui/src/features/billing/UsageMeter.tsx b/packages/ui/src/features/billing/UsageMeter.tsx index 65bb71ddd9..7f9281dd10 100644 --- a/packages/ui/src/features/billing/UsageMeter.tsx +++ b/packages/ui/src/features/billing/UsageMeter.tsx @@ -10,6 +10,9 @@ interface UsageMeterProps { export function UsageMeter({ label, bucket, color }: UsageMeterProps) { const percentage = bucket.used_percent; + // used_percent can exceed 100 once a limit is blown; the text still shows the + // true figure, but Progress rejects values above its default max of 100. + const progressValue = Math.min(100, Math.max(0, percentage)); const borderColor = color === "red" ? "var(--red-7)" : "var(--gray-5)"; @@ -28,7 +31,7 @@ export function UsageMeter({ label, bucket, color }: UsageMeterProps) { {percentage.toFixed(2)}% diff --git a/packages/ui/src/features/updates/UpdateAvailableModal.tsx b/packages/ui/src/features/updates/UpdateAvailableModal.tsx index 733959a868..e5c873cdc2 100644 --- a/packages/ui/src/features/updates/UpdateAvailableModal.tsx +++ b/packages/ui/src/features/updates/UpdateAvailableModal.tsx @@ -72,7 +72,7 @@ export function UpdateAvailableModal() { enabled: isOpen || prefetchForActiveUpdate, }); - const percent = Math.round(downloadPercent ?? 0); + const percent = Math.min(100, Math.max(0, Math.round(downloadPercent ?? 0))); const sizeLabel = formatSize(downloadSizeBytes); const isDownloading = status === "downloading"; const isReady = status === "ready" || status === "installing";