diff --git a/packages/web/e2e/chat.spec.mjs b/packages/web/e2e/chat.spec.mjs index 4e61ccc..b93e71f 100644 --- a/packages/web/e2e/chat.spec.mjs +++ b/packages/web/e2e/chat.spec.mjs @@ -59,6 +59,14 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page }) await expect(page.getByText("exec_command").first()).toBeVisible(); // Thinking + tool calls are wrapped in a work group; header shows running/done status. await expect(page.getByText("运行中").first()).toBeVisible(); + + // Live header statistics: the elapsed chip ticks once per second while the task runs (the + // pending approval below keeps it running), so its text must advance with no further server + // event. Scoped to the header stats container (div.hidden …): the per-reply footer reuses + // the same 用时 ("elapsed") label once the turn's stats line lands. + const headerElapsed = page.locator('div.hidden span[title="用时"]'); + const elapsedBefore = await headerElapsed.textContent(); + await expect(headerElapsed).not.toHaveText(elapsedBefore); // The user takes control of the running work group (toggle = userToggled), keeps it open, and // opens the exec_command card to watch the arguments. Both must survive the end of the turn. // diff --git a/packages/web/src/features/chat/chat-page.tsx b/packages/web/src/features/chat/chat-page.tsx index 5434ff2..df94aad 100644 --- a/packages/web/src/features/chat/chat-page.tsx +++ b/packages/web/src/features/chat/chat-page.tsx @@ -12,6 +12,7 @@ * created. The Session list and the new-chat entry point live in the global sidebar. */ import { useCallback, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; import { useNavigate, useParams } from "react-router"; import type { AgentSummary, @@ -27,9 +28,18 @@ import { ApiError } from "../../api/client"; import { S } from "../../lib/strings"; import { apiErrorText } from "../../lib/api-error"; import { useDocumentTitle } from "../../lib/use-document-title"; -import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format"; +import { + formatDateTime, + formatMoney, + humanizeDuration, + humanizeDurationLive, + humanizeTokens, +} from "../../lib/format"; import { latestConversation } from "../../lib/session-grouping"; import { approvalKey, isModelAuthDead } from "../../lib/omni/stream-model"; +import type { StreamModel } from "../../lib/omni/stream-model"; +import { bucketCostUsd, liveSessionElapsedMs } from "../../lib/omni/task-stats"; +import type { BucketPricing, TaskStatsTracker } from "../../lib/omni/task-stats"; import { useTheme } from "../../state/theme"; import { useProject } from "../../state/project"; import { useSessions } from "../../state/sessions"; @@ -65,7 +75,7 @@ const STAT_ICONS = { } as const; /** Iconized stat item: a symbol + a value, with the title giving the full meaning. */ -function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) { +function StatChip({ icon, value, label }: { icon: string; value: ReactNode; label: string }) { return ( Date.now()); + useEffect(() => { + if (!taskOpen) return; + // Load-bearing, not redundant: `now` still holds whatever the state last saw (mount time, + // or the final tick of a previous Task), and the first interval callback is a full second + // away. The first live render after a Task starts must not compute from that stale clock, + // so re-anchor immediately on entering the running state. + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [taskOpen]); + if (!taskOpen) return <>{humanizeDuration(stats.sessionElapsedMs)}; + return <>{humanizeDurationLive(liveSessionElapsedMs(stats, taskOpen, taskStartLocalMs, now))}; +} + +/** The header's three statistics — the chip row and the info dropdown render these verbatim. */ +interface HeaderStats { + tokensText: string; + /** Formatted session cost; null = nothing to show (no recorded figure and no live estimate). */ + costText: string | null; + /** Server-reported "some usage had no pricing" flag from the last idle refetch (the chip's `*`). */ + costUncosted: boolean; + elapsedNode: ReactNode; +} + +/** + * Computes the header statistics, live while a Task runs: + * - Tokens: session cumulative (main + subagents), already advancing per completed request; + * - Cost: the last idle-refetched session cost, plus — while a Task is open and the session + * Model has pricing — a live estimate converted from this Task's usage buckets. Subagents + * may run on different models, but the estimate applies the main Model's pricing to all + * live buckets: the estimate may be slightly off mid-task, and the idle getUsage refetch + * reconciles to the server-recorded value (kept deliberately simple). Without pricing the + * live addition is skipped (bucketCostUsd returns null, mirroring taskCost's uncosted + * signal) and the value stays exactly as when idle. costText stays null until there is an + * actual figure — no recorded cost plus a still-zero estimate renders nothing, not $0.00; + * - Elapsed: ticking cumulative while running, settled cumulative when idle (SessionElapsed). + */ +function headerStats( + model: StreamModel, + sessionCost: number | null, + costUncosted: boolean, + pricing: BucketPricing | undefined, + currency: "USD" | "CNY", +): HeaderStats { + const stats = model.stats; + const liveCost = model.taskOpen + ? bucketCostUsd( + { + cacheRead: stats.taskCacheRead, + cacheWrite: stats.taskCacheWrite, + output: stats.taskOutput, + }, + pricing, + ) + : null; + // Only take the live path once it has something to say — a positive estimate, or a recorded + // session cost to keep showing from the Task's first instant. On a brand-new session, + // sessionCost is still null and liveCost is 0 until the first token_usage lands; blindly + // summing would flash a formatted $0.00 the moment the Task starts — exactly the "cost is + // zero or something's broken" reading the chip's render conditional exists to avoid. + const costUsd = + liveCost != null && (liveCost > 0 || sessionCost != null) + ? (sessionCost ?? 0) + liveCost + : sessionCost; + return { + tokensText: humanizeTokens(stats.sessionTotal + stats.subagentTotal), + costText: costUsd != null ? formatMoney(costUsd, currency) : null, + costUncosted, + elapsedNode: ( + + ), + }; +} + /** * Route id for a draft chat (`/chat/new`): the Session hasn't been persisted yet — the user may * still want to change the model or configure a key first. The actual Session is only created @@ -611,16 +716,7 @@ export function ChatPage() { // happen mid-turn, and if only running were checked, the trailing group would flash // "finished running" during compaction before flipping back to "running". taskRunning: stream.taskState !== "idle", - taskCost: (stats) => { - if (!modelPricing) return null; - const b = stats.tokensByBucket; - return ( - (b.cacheRead * modelPricing.cacheRead + - b.cacheWrite * modelPricing.cacheWrite + - b.output * modelPricing.output) / - 1e6 - ); - }, + taskCost: (stats) => bucketCostUsd(stats.tokensByBucket, modelPricing), // Reconnect countdown controls (live waiting state only): retry-now skips the // remaining backoff server-side (benign no-op on timing races), give-up is the // ordinary session abort — the engine's abort-during-backoff path ends the turn. @@ -649,7 +745,9 @@ export function ChatPage() { ); } - const totalTokens = stream.model.stats.sessionTotal + stream.model.stats.subagentTotal; + // Header statistics (chip row + info dropdown), live while a Task runs; recomputed every + // stream version bump, so the in-place-mutated model stats always read fresh. + const hs = headerStats(stream.model, sessionCost, costUncosted, modelPricing, currency); const modelInfo = models?.models.find((m) => sameModelRef(m, activeModelRef)); const contextWindow = modelInfo?.contextWindow; // Assumed supported by default: only models explicitly marked vision=false show a blocking hint when adding images. @@ -742,24 +840,20 @@ export function ChatPage() {
{/* When there's no cost (the Model has no pricing configured), don't render this stat at all, rather than showing a "—" — that would take up space while saying nothing, only making people think the cost is zero or something's broken. */} - {sessionCost != null && ( + {hs.costText != null && ( )} - +
{/* Files panel toggle: docks on the right of the chat instead of replacing it full-screen (use-files-panel.ts). */} @@ -848,10 +942,9 @@ export function ChatPage() {

{/* Same as above: if there's no cost, the whole item is omitted, not left as "Cost —". */}

- {S.chat.statTokens} {humanizeTokens(totalTokens)} - {sessionCost != null && - ` · ${S.common.cost} ${formatMoney(sessionCost, currency)}`}{" "} - · {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)} + {S.chat.statTokens} {hs.tokensText} + {hs.costText != null && ` · ${S.common.cost} ${hs.costText}`} ·{" "} + {S.chat.statElapsed} {hs.elapsedNode}

diff --git a/packages/web/src/lib/omni/task-stats.ts b/packages/web/src/lib/omni/task-stats.ts index d92195c..0293bab 100644 --- a/packages/web/src/lib/omni/task-stats.ts +++ b/packages/web/src/lib/omni/task-stats.ts @@ -42,6 +42,32 @@ export interface TokenBucketCounts { output: number; } +/** Three pricing buckets in USD per million tokens (mirrors the server's ModelPricingDto shape). */ +export interface BucketPricing { + cacheRead: number; + cacheWrite: number; + output: number; +} + +/** + * Converts a three-bucket Token count to USD at per-million-token pricing; null when no pricing + * is configured (uncosted — callers omit the figure rather than fabricating a $0). Shared by the + * per-reply stats row's turn cost and the header's live session cost, so the two conversions can + * never drift apart. + */ +export function bucketCostUsd( + buckets: TokenBucketCounts, + pricing: BucketPricing | null | undefined, +): number | null { + if (!pricing) return null; + return ( + (buckets.cacheRead * pricing.cacheRead + + buckets.cacheWrite * pricing.cacheWrite + + buckets.output * pricing.output) / + 1e6 + ); +} + /** Stats tracker: Session-level cumulative counts that persist across Tasks + this-Task delta counts. */ export interface TaskStatsTracker { /** Current context occupancy = total from the most recent main-session request (not updated by compaction requests). */ @@ -287,6 +313,25 @@ export function endTask(t: TaskStatsTracker, elapsedMs: number): TaskStats | nul return stats; } +/** + * Session elapsed time for live display (the header chip): the settled cross-Task cumulative + * plus the currently running Task's wall clock so far. While no Task is open this is exactly + * `sessionElapsedMs`, so the idle rendering equals the settled value; the running addition + * counts from the model's task-start local clock (`taskStartLocalMs`), clamped so a clock + * anomaly never makes the sum go backwards. At task end {@link endTask} folds the Task's + * elapsed into `sessionElapsedMs` in the same model update that flips `taskOpen` off, so the + * live addition never double-counts across the boundary. + */ +export function liveSessionElapsedMs( + t: TaskStatsTracker, + taskOpen: boolean, + taskStartLocalMs: number | null, + nowMs: number, +): number { + const running = taskOpen && taskStartLocalMs != null ? Math.max(0, nowMs - taskStartLocalMs) : 0; + return t.sessionElapsedMs + running; +} + /** Localized labels for {@link formatTaskStats} (supplied by the view layer's active dictionary). */ export interface TaskStatsLabels { /** Row prefix, e.g. "Stats" / "统计信息". */ diff --git a/packages/web/test/task-stats.test.ts b/packages/web/test/task-stats.test.ts index ed2e7b4..0be8620 100644 --- a/packages/web/test/task-stats.test.ts +++ b/packages/web/test/task-stats.test.ts @@ -7,11 +7,13 @@ import type { TokenUsagePayload } from "@prismshadow/penguin-core/omnimessage"; import { addLlmDuration, beginCompaction, + bucketCostUsd, commitPendingCompaction, createTaskStatsTracker, endCompaction, endTask, formatTaskStats, + liveSessionElapsedMs, resetTaskCounters, trackMainUsage, trackSubagentUsage, @@ -194,6 +196,67 @@ describe("TaskStatsTracker", () => { }); }); +describe("liveSessionElapsedMs", () => { + it("idle (no open Task): exactly the settled cumulative — the header renders the same value as before", () => { + const t = createTaskStatsTracker(); + t.sessionElapsedMs = 2300; + expect(liveSessionElapsedMs(t, false, 1000, 99_999)).toBe(2300); + // Open but without a recorded start clock: nothing to add either. + expect(liveSessionElapsedMs(t, true, null, 99_999)).toBe(2300); + }); + + it("running: settled cumulative + wall clock since the Task started, never going backwards", () => { + const t = createTaskStatsTracker(); + t.sessionElapsedMs = 2000; + expect(liveSessionElapsedMs(t, true, 5000, 8000)).toBe(5000); // 2000 + 3000 + // A clock anomaly (now before the recorded start) adds nothing instead of subtracting. + expect(liveSessionElapsedMs(t, true, 5000, 4000)).toBe(2000); + }); + + it("no double count across the Task boundary: endTask folds the Task in as taskOpen flips off", () => { + const t = createTaskStatsTracker(); + // Task 1 settled earlier. + endTask(t, 1000); + // Task 2 runs: started at 10_000, now 14_000 -> 1000 settled + 4000 live. + expect(liveSessionElapsedMs(t, true, 10_000, 14_000)).toBe(5000); + // Task 2 ends: the same model update folds its elapsed into the cumulative AND flips + // taskOpen off — the live view continues from the settled value without re-adding. + endTask(t, 4000); + expect(liveSessionElapsedMs(t, false, 10_000, 15_000)).toBe(5000); + }); +}); + +describe("bucketCostUsd", () => { + it("converts the three buckets at per-million-token pricing", () => { + expect( + bucketCostUsd( + { cacheRead: 2_000_000, cacheWrite: 1_000_000, output: 500_000 }, + { cacheRead: 0.5, cacheWrite: 2, output: 10 }, + ), + ).toBe(8); // 2M×$0.5/M + 1M×$2/M + 0.5M×$10/M = 1 + 2 + 5 + }); + + it("no pricing -> null (uncosted; callers keep the value as-is instead of fabricating $0)", () => { + const buckets = { cacheRead: 100, cacheWrite: 100, output: 100 }; + expect(bucketCostUsd(buckets, undefined)).toBeNull(); + expect(bucketCostUsd(buckets, null)).toBeNull(); + }); + + it("the live Task buckets equal the settled stats row's buckets (the mid-task estimate lands on the final turn cost)", () => { + const t = createTaskStatsTracker(); + trackMainUsage(t, req(300, 100, 200)); + trackSubagentUsage(t, req(50, 0, 3)); + const pricing = { cacheRead: 1, cacheWrite: 2, output: 3 }; + const live = bucketCostUsd( + { cacheRead: t.taskCacheRead, cacheWrite: t.taskCacheWrite, output: t.taskOutput }, + pricing, + ); + const s = endTask(t, 100); + expect(live).not.toBeNull(); + expect(live).toBe(bucketCostUsd(s!.tokensByBucket, pricing)); + }); +}); + describe("formatTaskStats", () => { const EN_LABELS = { stats: "Stats",