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", () => {