fix(web): keep running-state rows to one line on mobile (#76)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => void decide("allow")}>
|
||||
|
||||
@@ -829,11 +829,15 @@ export function ChatPage() {
|
||||
<h1 className="flex min-w-0 text-[15px] font-semibold">
|
||||
<Truncated text={selected.title ?? S.chat.defaultSessionTitle} />
|
||||
</h1>
|
||||
{/* Running indicator (placed to the right of the title); the compacting state is shown separately by the compaction banner within the message stream, not repeated here. */}
|
||||
{/* Running indicator (placed to the right of the title); the compacting state is shown separately by the compaction banner within the message stream, not repeated here.
|
||||
Below sm only the pulsing dot remains (title carries the wording) — the text would eat the title's room on phones. */}
|
||||
{stream.taskState === "running" && (
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
title={S.chat.statusRunning}
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
|
||||
{S.chat.statusRunning}
|
||||
<span className="hidden sm:inline">{S.chat.statusRunning}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -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() {
|
||||
>
|
||||
<path d={STAT_ICONS.folder} />
|
||||
</svg>
|
||||
{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. */}
|
||||
<span className="hidden sm:inline">{S.chat.openWorkspace}</span>
|
||||
</button>
|
||||
|
||||
{/* Details popup: Model / Workspace / created time / stats */}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`flex h-5 items-center gap-2 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 ${
|
||||
className={`flex h-5 items-center gap-2 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 sm:opacity-0 ${
|
||||
align === "right" ? "justify-end" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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 (
|
||||
<span title={label} aria-label={label} className="flex items-center gap-1">
|
||||
<span title={label} aria-label={label} className={`${display} items-center gap-1`}>
|
||||
<GlyphIcon d={icon} />
|
||||
{value}
|
||||
{compactValue !== undefined && compactValue !== value ? (
|
||||
<>
|
||||
<span className="sm:hidden">{compactValue}</span>
|
||||
<span className="hidden sm:inline">{value}</span>
|
||||
</>
|
||||
) : (
|
||||
value
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="-mt-2 flex h-5 flex-wrap items-center justify-start gap-x-3 text-[11px] text-gray-400 opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 dark:text-gray-500">
|
||||
{/* 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 && <span>{formatMessageTime(atMs, locale)}</span>}
|
||||
{stats && b && (
|
||||
<>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.input}
|
||||
value={humanizeTokens(input)}
|
||||
label={S.chat.statInput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.output}
|
||||
value={humanizeTokens(b.output)}
|
||||
label={S.chat.statOutput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tps}
|
||||
value={formatTps(stats.outputTps)}
|
||||
label={S.chat.statTps}
|
||||
/>
|
||||
{cost != null && (
|
||||
<div className="-mt-2 flex h-5 items-center justify-start gap-x-2 overflow-hidden whitespace-nowrap text-[11px] text-gray-400 transition-opacity duration-150 group-hover:opacity-100 focus-within:opacity-100 sm:gap-x-3 sm:opacity-0 dark:text-gray-500">
|
||||
<span className="no-scrollbar flex min-w-0 items-center gap-x-2 overflow-x-auto sm:gap-x-3">
|
||||
{/* 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 && <span>{formatMessageTime(atMs, locale)}</span>}
|
||||
{stats && b && (
|
||||
<>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={formatMoney(cost, currency)}
|
||||
label={`${S.common.cost}(${currency})`}
|
||||
icon={STAT_ICONS.input}
|
||||
value={humanizeTokens(input)}
|
||||
label={S.chat.statInput}
|
||||
/>
|
||||
)}
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(stats.elapsedDeltaMs)}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<StatChip
|
||||
icon={STAT_ICONS.output}
|
||||
value={humanizeTokens(b.output)}
|
||||
label={S.chat.statOutput}
|
||||
/>
|
||||
<StatChip
|
||||
icon={STAT_ICONS.tps}
|
||||
value={formatTps(stats.outputTps)}
|
||||
label={S.chat.statTps}
|
||||
display="hidden sm:flex"
|
||||
/>
|
||||
{cost != null && (
|
||||
<StatChip
|
||||
icon={STAT_ICONS.cost}
|
||||
value={formatMoney(cost, currency)}
|
||||
compactValue={formatMoney(cost, currency, { compact: true })}
|
||||
label={`${S.common.cost}(${currency})`}
|
||||
/>
|
||||
)}
|
||||
<StatChip
|
||||
icon={STAT_ICONS.elapsed}
|
||||
value={humanizeDuration(stats.elapsedDeltaMs)}
|
||||
compactValue={humanizeDuration(stats.elapsedDeltaMs, { compact: true })}
|
||||
label={S.chat.statElapsed}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
title={copied ? S.common.copied : S.chat.copyReply}
|
||||
aria-label={S.chat.copyReply}
|
||||
onClick={copy}
|
||||
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||||
>
|
||||
<GlyphIcon d={copied ? STAT_ICONS.check : STAT_ICONS.copy} />
|
||||
</button>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
@@ -256,24 +271,21 @@ export function ToolCallCard({ item, ctx }: { item: ToolCallItem; ctx: StreamRen
|
||||
)
|
||||
) : null}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<span className="shrink-0 font-mono text-xs text-amber-600 dark:text-amber-400">
|
||||
<span className="hidden shrink-0 font-mono text-xs text-amber-600 sm:inline dark:text-amber-400">
|
||||
{S.chat.approvalWaiting}
|
||||
</span>
|
||||
)}
|
||||
{item.callStopReason && item.callStopReason !== "completed" && (
|
||||
<Badge tone={stopReasonTone(item.callStopReason)}>{item.callStopReason}</Badge>
|
||||
)}
|
||||
{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 && (
|
||||
<Badge tone={stopReasonTone(item.outputStopReason)}>{item.outputStopReason}</Badge>
|
||||
)}
|
||||
{item.decision && (
|
||||
<Badge tone={item.decision === "allow" ? "green" : "red"}>
|
||||
{item.decision === "allow" ? S.chat.decisionAllow : S.chat.decisionDeny}
|
||||
{" · "}
|
||||
{item.decisionSource === "manual" ? S.chat.decisionManual : S.chat.decisionAuto}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
{/* Expand indicator on the right */}
|
||||
<Chevron open={open} className="text-gray-400" />
|
||||
@@ -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 && (
|
||||
<div className="border-t border-gray-100 bg-amber-50 px-3 py-2 dark:border-gray-800 dark:bg-amber-950/30">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{/* 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). */}
|
||||
<div className="mb-2 flex items-start gap-2 sm:items-center">
|
||||
<span className="shrink-0 rounded-md bg-white px-1.5 py-0.5 font-mono text-xs font-semibold text-gray-700 dark:bg-gray-900 dark:text-gray-300">
|
||||
{item.name || S.chat.unknownTool}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="min-w-0 flex-1 whitespace-pre-wrap break-all font-mono text-xs text-gray-600 sm:truncate dark:text-gray-400">
|
||||
{preview}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -104,9 +104,11 @@ export function WorkGroup({
|
||||
>
|
||||
{active ? S.chat.workRunning : S.chat.workDone}
|
||||
</span>
|
||||
{/* 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 && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
<span className="hidden shrink-0 font-mono text-xs text-gray-400 sm:inline">
|
||||
{S.chat.workGroupSteps(steps)}
|
||||
</span>
|
||||
)}
|
||||
@@ -132,9 +134,21 @@ export function WorkGroup({
|
||||
</span>
|
||||
)}
|
||||
{pending && !shown && (
|
||||
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
{S.chat.approvalWaiting}
|
||||
</span>
|
||||
<>
|
||||
{/* 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. */}
|
||||
<span className="hidden shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 sm:inline dark:bg-amber-950/50 dark:text-amber-300">
|
||||
{S.chat.approvalWaiting}
|
||||
</span>
|
||||
<span
|
||||
role="img"
|
||||
title={S.chat.approvalWaiting}
|
||||
aria-label={S.chat.approvalWaiting}
|
||||
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500 sm:hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="min-w-0 flex-1" />
|
||||
<Chevron open={shown} className="text-gray-400" />
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user