From bbcc42427fff45da50c58d310fa33c1c6e249749 Mon Sep 17 00:00:00 2001 From: Yaowei Zheng Date: Mon, 27 Jul 2026 23:45:23 +0800 Subject: [PATCH] fix(web): keep running-state rows to one line on mobile (#76) Co-authored-by: Claude Fable 5 --- packages/web/e2e/chat.spec.mjs | 19 ++- packages/web/e2e/layout.spec.mjs | 132 +++++++++++++++++ .../src/features/chat/approval-buttons.tsx | 4 + packages/web/src/features/chat/chat-page.tsx | 15 +- .../web/src/features/chat/message-item.tsx | 15 +- .../web/src/features/chat/task-stats-line.tsx | 136 ++++++++++++------ .../web/src/features/chat/tool-call-card.tsx | 43 ++++-- packages/web/src/features/chat/work-group.tsx | 24 +++- packages/web/src/lib/format.ts | 17 ++- packages/web/test/format.test.ts | 18 +++ 10 files changed, 344 insertions(+), 79 deletions(-) diff --git a/packages/web/e2e/chat.spec.mjs b/packages/web/e2e/chat.spec.mjs index b93e71f..afcfc9f 100644 --- a/packages/web/e2e/chat.spec.mjs +++ b/packages/web/e2e/chat.spec.mjs @@ -89,11 +89,26 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page }) await toolCard.click(); await expect(toolCard).toHaveAttribute("aria-expanded", "true"); + // Desktop keeps the one-line pending preview (truncate); only phones wrap the command in + // full while pending (asserted at 390 in layout.spec). + expect( + await page + .getByText("$ ls -la") + .first() + .evaluate((el) => getComputedStyle(el).whiteSpace), + "pending preview stays one line at desktop", + ).toBe("nowrap"); + await page.getByRole("button", { name: "允许" }).click(); // Final assistant answer (turn 2 from mock). await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible(); + // The decision is carried ONLY by the tool card's left status icon (title/aria-label) — no + // visible decision text at any breakpoint (the right-side pill was removed per review). + await expect(page.locator('[aria-label="已批准 · 手动"]')).toBeVisible(); + await expect(page.getByText(/已批准/).filter({ visible: true })).toHaveCount(0); + // Chat links always open in a new tab and never navigate the SPA away — including bare URLs // that remark-gfm autolinks (the mock reply carries one inside a CJK sentence). const replyLink = page.locator(".md-body a", { hasText: "example.com" }).first(); @@ -133,7 +148,9 @@ test("chat + tool approval + stats/cost/copy + traces + files", async ({ page }) // prove nothing about this line. const copyBtn = page.getByRole("button", { name: "复制回复" }).first(); const statsLine = copyBtn.locator("xpath=.."); - await expect(statsLine.locator("text=/\\$0\\.\\d+/")).toBeVisible(); // cost chip (pricing set) + // Cost chip (pricing set). visible-filtered: the chip renders a desktop value plus a + // display:none compact twin for phones, and both match the money pattern. + await expect(statsLine.locator("text=/\\$0\\.\\d+/").filter({ visible: true })).toBeVisible(); // Hidden by default but SPACE-RESERVED (opacity-0, not hidden). Park the cursor first: after // clicking 允许 ("Allow") the pointer sits where that button was and the re-render can drop a diff --git a/packages/web/e2e/layout.spec.mjs b/packages/web/e2e/layout.spec.mjs index 5d3dfa5..a6c67f9 100644 --- a/packages/web/e2e/layout.spec.mjs +++ b/packages/web/e2e/layout.spec.mjs @@ -480,6 +480,138 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page }) running.clientWidth, ); await expect(page.locator('button[aria-label="Stop"]')).toBeVisible(); + + // Running + pending approval at 390x844: every running-state row must stay one line below + // sm — the page must not scroll sideways, the work-group header must not wrap to a second + // line, and the Allow/Deny action buttons read as text at every breakpoint (per review: + // buttons the user presses must be words, iconic shorthand is for passive indicators only). + await page.setViewportSize({ width: 390, height: 844 }); + await page.waitForTimeout(200); + const d390 = await docWidths(page); + expect(d390.scrollWidth, "running @390 no horizontal overflow").toBeLessThanOrEqual( + d390.clientWidth, + ); + // Single line = ~33px (py-2 + one text line); a wrapped header would measure ~48px+. + const workHeader = page.locator("button[aria-expanded]").filter({ hasText: "Running" }).first(); + expect( + await workHeader.evaluate((el) => el.clientHeight), + "work-group header stays single-line @390", + ).toBeLessThanOrEqual(40); + await expect(page.getByRole("button", { name: /^Allow$/ })).toBeVisible(); + await expect(page.getByRole("button", { name: /^Deny$/ })).toBeVisible(); + // The words themselves are rendered — not a glyph with an aria-label. + await expect(page.getByRole("button", { name: /^Allow$/ })).toHaveText("Allow", { + useInnerText: true, + }); + await expect(page.getByRole("button", { name: /^Deny$/ })).toHaveText("Deny", { + useInnerText: true, + }); + expect(await textOverlapCount(page), "running @390 no overlapping text").toBe(0); + + // While PENDING the one-line rule yields on purpose: the user must read the whole command + // before deciding, so below sm the preview wraps in full (pre-wrap, no truncation, the block + // may grow) instead of clipping. + const pendingPreview = page.getByText("$ ls -la").first(); + await expect(pendingPreview, "full command shown while pending @390").toBeVisible(); + const pv = await pendingPreview.evaluate((el) => ({ + whiteSpace: getComputedStyle(el).whiteSpace, + clipped: el.scrollWidth > el.clientWidth + 1, + })); + expect(pv.whiteSpace, "pending preview wraps below sm").toBe("pre-wrap"); + expect(pv.clipped, "pending preview not clipped @390").toBe(false); + + // Approve and let the turn finish: the per-reply stats footer must keep to its one fixed + // line at 390 (it used to wrap its chips onto a clipped second row that painted over the + // content below). On phones the row is slimmed to FIT: TPS is dropped and cost/elapsed use + // compact decimals, the hidden-scrollbar sideways scroll remaining only as a fallback; the + // copy button sits outside the scroll area so it can't scroll out of reach. + await page.getByRole("button", { name: /^Allow$/ }).click(); + await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible(); + const footer = page.getByRole("button", { name: "Copy reply" }).first().locator("xpath=.."); + // Below sm the footer is ALWAYS visible at rest (no hover on touch screens — hover-gated + // opacity meant phones could never see the stats at all); ≥sm keeps the hover reveal, which + // chat.spec asserts at desktop width. + await page.mouse.move(0, 0); + await expect(footer, "stats footer visible at rest @390").toHaveCSS("opacity", "1"); + // The USER message's footer (time + copy, bubble bottom-right) gets the same treatment: + // always visible below sm, since touch has no hover to reveal it with. + const userCopy = page.getByRole("button", { name: "Copy message" }).first(); + await expect(userCopy, "user copy button visible at rest @390").toBeVisible(); + await expect(userCopy.locator("xpath=.."), "user footer visible at rest @390").toHaveCSS( + "opacity", + "1", + ); + // Chips at 390: input/output/elapsed shown (no pricing configured -> no cost chip); TPS is + // deliberately dropped below sm to keep the row inside the width. + for (const chip of ["Input tokens", "Output tokens", "Elapsed"]) { + await expect(footer.locator(`[title="${chip}"]`), `${chip} chip present @390`).toBeVisible(); + } + await expect(footer.locator('[title="Output TPS"]'), "TPS chip in DOM").toHaveCount(1); + await expect(footer.locator('[title="Output TPS"]'), "TPS chip hidden @390").toBeHidden(); + // With TPS dropped and compact decimals the common case FITS at 390 — no sideways scroll + // needed (the scroll container remains only as a fallback for extreme values). + const statsSpan = footer.locator("span").first(); + const fit = await statsSpan.evaluate((el) => ({ sw: el.scrollWidth, cw: el.clientWidth })); + expect(fit.sw, "stats row fits @390 without scrolling").toBeLessThanOrEqual(fit.cw + 1); + const footerH = await footer.evaluate((el) => ({ + client: el.clientHeight, + scroll: el.scrollHeight, + })); + expect(footerH.scroll, "stats footer stays single-line @390").toBeLessThanOrEqual( + footerH.client + 1, + ); + const copyBox = await page.getByRole("button", { name: "Copy reply" }).first().boundingBox(); + expect(copyBox.x, "copy button not pushed off-screen left @390").toBeGreaterThanOrEqual(0); + expect(copyBox.x + copyBox.width, "copy button pinned on-screen @390").toBeLessThanOrEqual(390); + // Inner scroll containers are fine; the page itself must not gain a sideways scroll. + const dDone = await docWidths(page); + expect(dDone.scrollWidth, "finished @390 no horizontal overflow").toBeLessThanOrEqual( + dDone.clientWidth, + ); + + // The decision lives ONLY in the tool card's left status icon: "Approved · manual" on the + // icon's title/aria-label, zero visible decision text on the row (the right-side indicator + // was removed per review), and the header stays one line. + await page.locator("button[aria-expanded]").filter({ hasText: "Done" }).last().click(); + const decided = page.locator('[aria-label="Approved · manual"]').first(); + await expect(decided, "status icon carries the decision @390").toBeVisible(); + const toolHeader = page.locator("button[aria-expanded]", { has: decided }).first(); + await expect( + toolHeader.getByText(/Approved|Denied/).filter({ visible: true }), + "no visible decision text on the card", + ).toHaveCount(0); + expect( + await toolHeader.evaluate((el) => el.clientHeight), + "tool-card header stays single-line @390", + ).toBeLessThanOrEqual(40); + expect(await textOverlapCount(page), "finished @390 no overlapping text").toBe(0); + + // A DENIED call must state its outcome once, not twice: the deny path itself reports + // stop_reason "aborted", so the left status icon alone carries "Denied · manual" + // (title/aria-label) and the redundant "aborted" badge is dropped. Fresh session: the mock + // answers with plain text once any tool_result exists in the history. + const sess2 = await ( + await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, { + data: { provider: "custom", modelId: "claude-4-8", approvalMode: "always-ask" }, + }) + ).json(); + await page.goto(`${BASE}/chat/${sess2.session.sessionId}`); + await page.getByPlaceholder(/Type a message/).fill("Help me check the directory"); + await page.locator('button[aria-label="Send"]').click(); + await page.getByRole("button", { name: /^Deny$/ }).click(); + await expect(page.getByText("Command finished; the result looks as expected.")).toBeVisible(); + await page.locator("button[aria-expanded]").filter({ hasText: "Done" }).last().click(); + const denied = page.locator('[aria-label="Denied · manual"]').first(); + await expect(denied, "status icon carries the denial @390").toBeVisible(); + const deniedHeader = page.locator("button[aria-expanded]", { has: denied }).first(); + await expect( + deniedHeader.getByText("aborted"), + "no duplicate aborted badge on a denied call", + ).toHaveCount(0); + expect( + await deniedHeader.evaluate((el) => el.clientHeight), + "denied card header stays single-line @390", + ).toBeLessThanOrEqual(40); }); test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => { diff --git a/packages/web/src/features/chat/approval-buttons.tsx b/packages/web/src/features/chat/approval-buttons.tsx index be4e89f..5269a8a 100644 --- a/packages/web/src/features/chat/approval-buttons.tsx +++ b/packages/web/src/features/chat/approval-buttons.tsx @@ -23,6 +23,10 @@ export function ApprovalButtons({ } }; + // Text at every breakpoint (per review): action buttons the user presses must read as words — + // "允许/Allow" and "拒绝/Deny" — never as bare glyphs; iconic shorthand is reserved for + // passive indicators. The buttons live on their own row under the (wrapping) argument + // preview, so the words cost no width the pending card doesn't already have. return (
@@ -864,6 +868,7 @@ export function ChatPage() { aria-expanded={filesPanel.open} onClick={() => filesPanel.setOpen(!filesPanel.open)} title={S.chat.openWorkspace} + aria-label={S.chat.openWorkspace} className={`flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs font-medium transition-colors duration-150 ${ filesPanel.open ? "bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200" @@ -881,7 +886,9 @@ export function ChatPage() { > - {S.chat.openWorkspace} + {/* Below sm the button is icon-only (title/aria keep the name): the label plus the + running indicator squeezed the session title to nothing on phones. */} + {S.chat.openWorkspace} {/* Details popup: Model / Workspace / created time / stats */} diff --git a/packages/web/src/features/chat/message-item.tsx b/packages/web/src/features/chat/message-item.tsx index 4e475a9..1cb177b 100644 --- a/packages/web/src/features/chat/message-item.tsx +++ b/packages/web/src/features/chat/message-item.tsx @@ -41,11 +41,14 @@ const USER_STEERING_ICON = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0"; /** - * Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0` - * rather than `hidden`) — it surfaces on hovering the message list, and because the space is - * always reserved, surfacing it never pushes content below it down (using `hidden` would cause - * every item to jitter). Keyboard users can also reveal it via `focus-within` (otherwise the copy - * button would be focusable but never visible). + * Message footer: timestamp + copy. At ≥sm it is **invisible but takes up space by default** + * (`sm:opacity-0` rather than `hidden`) — it surfaces on hovering the message list, and because + * the space is always reserved, surfacing it never pushes content below it down (using `hidden` + * would cause every item to jitter). Keyboard users can also reveal it via `focus-within` + * (otherwise the copy button would be focusable but never visible). Below sm the footer is + * always visible — hover doesn't exist on touch screens (and Tailwind v4 scopes hover: variants + * to `@media (hover: hover)`), so a hover-revealed footer would simply never appear on phones; + * same treatment as the AI reply's stats footer. * * When `text` is omitted, only the timestamp is shown, no copy button — there's no clear meaning * to copying an image message. @@ -69,7 +72,7 @@ function MessageMeta({ }; return (
diff --git a/packages/web/src/features/chat/task-stats-line.tsx b/packages/web/src/features/chat/task-stats-line.tsx index cb482c2..39b2e83 100644 --- a/packages/web/src/features/chat/task-stats-line.tsx +++ b/packages/web/src/features/chat/task-stats-line.tsx @@ -4,11 +4,15 @@ * in real time from the current Model's pricing, hidden if no pricing is configured); all five * share the same basis (this turn's usage), each expressed uniformly as icon + value (no text * labels); the reply timestamp and a copy button sit at the end (copies this turn's assistant - * text, falling back to the stats themselves when there's no text). - * The whole line is invisible but takes up space by default, surfacing only on hovering the - * reply or the line itself — matching the same convention, font size, and color as the user + * text, falling back to the stats themselves when there's no text). Below sm the row is slimmed + * to fit the width: the TPS chip is dropped and cost/elapsed lose excess decimals (compact + * formatter variants); desktop shows all five, formatted as always. + * At ≥sm the whole line is invisible but takes up space by default, surfacing only on hovering + * the reply or the line itself — matching the same convention, font size, and color as the user * message footer: the AI's footer sits bottom-left, the user's sits bottom-right, symmetric on - * both sides. + * both sides. Below sm the line is always visible: phones have no hover (Tailwind v4 even gates + * hover: variants behind `@media (hover: hover)`), so a hover-revealed footer would simply + * never appear there. * Arrow direction reads as "where the tokens go": **up arrow = input** (sent up to the model), * **down arrow = output** (returned by the model). * This line only answers "how much did this turn cost", **it doesn't break down the cache @@ -36,12 +40,36 @@ import { GlyphIcon } from "../../components/ui/glyph-icon"; import { useTheme } from "../../state/theme"; import { useLocale } from "../../state/locale"; -/** Icon + value; hover explains what this item is (the icon alone doesn't convey the exact meaning). */ -function StatChip({ icon, value, label }: { icon: string; value: string; label: string }) { +/** + * Icon + value; hover explains what this item is (the icon alone doesn't convey the exact + * meaning). `display` swaps the default `flex` for a responsive variant (the TPS chip is + * `hidden sm:flex`); `compactValue`, when it differs, replaces the value below sm — fewer + * decimals so the one-line stats row fits a phone without needing its scroll fallback. + */ +function StatChip({ + icon, + value, + compactValue, + label, + display = "flex", +}: { + icon: string; + value: string; + compactValue?: string; + label: string; + display?: string; +}) { return ( - + - {value} + {compactValue !== undefined && compactValue !== value ? ( + <> + {compactValue} + {value} + + ) : ( + value + )} ); } @@ -87,53 +115,67 @@ export function TaskStatsLine({ }); }; - // The whole line is invisible but **takes up space** by default (opacity-0, not hidden) — - // matching the user message footer's convention: only appears on hover, and because the space - // is always reserved, appearing never pushes content below it down. Font size/color also match - // that footer; the AI's footer sits bottom-left, the user's sits bottom-right, symmetric. + // ≥sm: invisible but **space-reserved** by default (sm:opacity-0, not hidden) — the user + // footer's hover-reveal convention; because the space is always reserved, appearing never + // pushes content below it down. Below sm the line is ALWAYS visible: hover doesn't exist on + // touch screens (and Tailwind v4 scopes hover: variants to `@media (hover: hover)`), so the + // hover gating made the stats unreachable on phones. + // One line always (no flex-wrap): with the fixed h-5, wrapped chips used to paint over the + // content below on phones. Below sm the row is slimmed to FIT rather than scroll: the TPS + // chip (the least decision-relevant number here) is dropped, the cost/elapsed values lose + // excess decimals, and the chip gap tightens one step — the en timestamp + priced row was + // otherwise ~7px over a 390 viewport. The sideways scroll (hidden scrollbar) stays only as + // the fallback for extreme values; at ≥sm everything fits and the scroll container is inert. + // The copy button sits outside the scrollable span, so it stays pinned at the row's end + // instead of scrolling out of reach. return ( -
- {/* Timestamp leads: it's this reply's identity (when it was said), the stat numbers are an - annotation. When this turn has no token_usage (reply was aborted), only the timestamp - and copy remain — nothing is fabricated for what wasn't measured. */} - {atMs !== undefined && {formatMessageTime(atMs, locale)}} - {stats && b && ( - <> - - - - {cost != null && ( +
+ + {/* Timestamp leads: it's this reply's identity (when it was said), the stat numbers are an + annotation. When this turn has no token_usage (reply was aborted), only the timestamp + and copy remain — nothing is fabricated for what wasn't measured. */} + {atMs !== undefined && {formatMessageTime(atMs, locale)}} + {stats && b && ( + <> - )} - - - )} + + + {cost != null && ( + + )} + + + )} + diff --git a/packages/web/src/features/chat/tool-call-card.tsx b/packages/web/src/features/chat/tool-call-card.tsx index 3c92e79..b881e58 100644 --- a/packages/web/src/features/chat/tool-call-card.tsx +++ b/packages/web/src/features/chat/tool-call-card.tsx @@ -206,13 +206,28 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen : failed ? "failed" : "done"; + // Decision wording ("Approved · manual" / "已拒绝 · 手动" …): carried ONLY by the left status + // icon's title/aria-label — per review the row shows no visible decision text at any + // breakpoint; the icon is the single source of truth for how the call was decided. + const decisionText = item.decision + ? `${item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny} · ${ + item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto + }` + : null; + // A user denial reports stop_reason "aborted" on the output it feeds back; that abort IS the + // decision, not an independent outcome — the icon reads "Denied", and no separate "aborted" + // badge repeats it. A user-abort of a RUNNING tool carries no deny decision and keeps its own + // "aborted" marker (the stop-reason branch below). + const deniedByUser = item.decision === "deny" && item.outputStopReason === "aborted"; const stateLabel = pending ? S.chat.approvalWaiting : state === "running" ? S.chat.workRunning : state === "done" - ? S.chat.workDone - : (item.outputStopReason ?? item.callStopReason); + ? (decisionText ?? S.chat.workDone) + : deniedByUser + ? (decisionText ?? undefined) + : (item.outputStopReason ?? item.callStopReason); return (
@@ -256,24 +271,21 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen ) ) : null} + {/* Below sm only the amber hourglass StatusIcon (labeled) marks the wait: the text would + crowd the one-line row out of a phone's width. */} {pending && ( - + {S.chat.approvalWaiting} )} {item.callStopReason && item.callStopReason !== "completed" && ( {item.callStopReason} )} - {item.outputStopReason && item.outputStopReason !== "completed" && ( + {/* Writing "aborted" next to the Denied pill would state the same outcome twice — see + deniedByUser above. */} + {item.outputStopReason && item.outputStopReason !== "completed" && !deniedByUser && ( {item.outputStopReason} )} - {item.decision && ( - - {item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny} - {" · "} - {item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto} - - )} {/* Expand indicator on the right */} @@ -282,11 +294,16 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen {/* Pending approval: always visible regardless of collapsed state — shows the tool name and arguments so the user knows what they're approving. */} {pending && (
-
+ {/* The user must be able to read the FULL command before deciding: below sm the + preview wraps in whole (expanded-args style: pre-wrap + break-all, no inner + scroll, the block may grow) — the one-line treatment resumes once decided, since + this pending block unmounts and only the truncating header subtitle remains. At + ≥sm the row stays one line (the desktop column is wide enough in practice). */} +
{item.name || S.chat.unknownTool} - + {preview}
diff --git a/packages/web/src/features/chat/work-group.tsx b/packages/web/src/features/chat/work-group.tsx index a85e229..e2564f2 100644 --- a/packages/web/src/features/chat/work-group.tsx +++ b/packages/web/src/features/chat/work-group.tsx @@ -104,9 +104,11 @@ export function WorkGroup({ > {active ? S.chat.workRunning : S.chat.workDone} - {/* A pure-thinking group (no tool calls) doesn't show "0 steps". */} + {/* A pure-thinking group (no tool calls) doesn't show "0 steps"; below sm the count is + dropped entirely (title on the header carries nothing extra — the header must stay + a single uncut line on phones). */} {steps > 0 && ( - + {S.chat.workGroupSteps(steps)} )} @@ -132,9 +134,21 @@ export function WorkGroup({ )} {pending && !shown && ( - - {S.chat.approvalWaiting} - + <> + {/* Below sm the pill collapses to a bare amber dot (title/aria carry the meaning): + the text pill would push the header past one line on phones. role="img", not a + live region — same non-live semantics as the text pill, so re-renders don't + chatter at screen readers. */} + + {S.chat.approvalWaiting} + + + )} diff --git a/packages/web/src/lib/format.ts b/packages/web/src/lib/format.ts index a520e65..176c41b 100644 --- a/packages/web/src/lib/format.ts +++ b/packages/web/src/lib/format.ts @@ -22,11 +22,16 @@ export function humanizeTokens(n: number): string { return `${trimZero(n / 1_000_000)}M`; } -/** Convert milliseconds to a human-readable duration: `820ms`, `2.3s`, `1m3s`. */ -export function humanizeDuration(ms: number): string { +/** + * Convert milliseconds to a human-readable duration: `820ms`, `2.3s`, `1m3s`. + * `compact` (narrow screens): seconds from 10s up drop the tenths (`12s`, not `12.7s` — at that + * scale the decimal no longer carries information worth the width); below 10s the tenth is + * significant and stays (`1.7s`), ms and minute forms are already whole-numbered. + */ +export function humanizeDuration(ms: number, opts?: { compact?: boolean }): string { if (ms < 1000) return `${Math.round(ms)}ms`; const s = ms / 1000; - if (s < 60) return `${trimZero(s)}s`; + if (s < 60) return opts?.compact && s >= 10 ? `${Math.round(s)}s` : `${trimZero(s)}s`; const m = Math.floor(s / 60); return `${m}m${Math.round(s % 60)}s`; } @@ -98,16 +103,22 @@ export function formatPercent(ratio: number | null | undefined): string { * Cost display (converted to the selected currency; prices are stored in * USD): null/undefined → `—`; 1 USD ≈ 7 CNY; decimal places scale with * magnitude (≥100 rounds to an integer, ≥1 uses two places, otherwise four). + * `compact` (narrow screens): sub-unit amounts keep 2 significant digits + * instead of 4 fixed decimals (`$0.1234` → `$0.12`, `$0.0012` stays + * `$0.0012`) — significant digits, not fixed decimals, so a nonzero cost is + * never rounded down to zero; amounts ≥ 1 are already short and unchanged. */ export function formatMoney( usd: number | null | undefined, currency: "USD" | "CNY" = "USD", + opts?: { compact?: boolean }, ): string { if (usd == null) return "—"; const symbol = currency === "CNY" ? "¥" : "$"; const v = currency === "CNY" ? usd * 7 : usd; if (v === 0) return `${symbol}0`; const abs = Math.abs(v); + if (opts?.compact && abs < 1) return `${symbol}${Number(v.toPrecision(2))}`; const digits = abs >= 100 ? 0 : abs >= 1 ? 2 : 4; return `${symbol}${v.toFixed(digits)}`; } diff --git a/packages/web/test/format.test.ts b/packages/web/test/format.test.ts index 72589df..2e36513 100644 --- a/packages/web/test/format.test.ts +++ b/packages/web/test/format.test.ts @@ -49,6 +49,14 @@ describe("humanizeDuration", () => { expect(humanizeDuration(63000)).toBe("1m3s"); expect(humanizeDuration(130000)).toBe("2m10s"); }); + + it("compact (narrow screens) drops the tenths from 10s up, keeps them below", () => { + expect(humanizeDuration(12700, { compact: true })).toBe("13s"); + expect(humanizeDuration(59400, { compact: true })).toBe("59s"); + expect(humanizeDuration(1700, { compact: true })).toBe("1.7s"); + expect(humanizeDuration(820, { compact: true })).toBe("820ms"); + expect(humanizeDuration(63000, { compact: true })).toBe("1m3s"); + }); }); describe("signedDelta", () => { @@ -77,6 +85,16 @@ describe("formatMoney", () => { expect(formatMoney(1, "CNY")).toBe("¥7.00"); expect(formatMoney(0.01, "CNY")).toBe("¥0.0700"); }); + + it("compact (narrow screens): sub-unit costs keep 2 significant digits, never rounding a nonzero cost to zero", () => { + expect(formatMoney(0.1234, "USD", { compact: true })).toBe("$0.12"); + expect(formatMoney(0.0012, "USD", { compact: true })).toBe("$0.0012"); + expect(formatMoney(0.00047, "USD", { compact: true })).toBe("$0.00047"); + expect(formatMoney(1.5, "USD", { compact: true })).toBe("$1.50"); + expect(formatMoney(150, "USD", { compact: true })).toBe("$150"); + expect(formatMoney(0, "USD", { compact: true })).toBe("$0"); + expect(formatMoney(0.01, "CNY", { compact: true })).toBe("¥0.07"); + }); }); describe("formatPercent", () => {