@tangle-network/ui 11.51.7 → 11.51.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +6 -0
  3. package/dist/chat/chat-message.js +2 -7
  4. package/dist/chat/user-message.js +3 -5
  5. package/dist/files/file-card.js +1 -1
  6. package/dist/files/file-tree.d.ts +0 -6
  7. package/dist/files/file-tree.js +2 -6
  8. package/dist/index.d.ts +2 -2
  9. package/dist/index.js +2 -2
  10. package/dist/primitives/hover-card.d.ts +22 -13
  11. package/dist/primitives/hover-card.js +149 -40
  12. package/dist/primitives/popover.d.ts +3 -1
  13. package/dist/primitives/popover.js +15 -12
  14. package/dist/primitives/upload-progress.d.ts +0 -6
  15. package/dist/primitives/upload-progress.js +2 -6
  16. package/dist/run/run-group.js +2 -2
  17. package/dist/run/run-item-primitives.js +2 -2
  18. package/dist/run/run-row-shell.js +2 -2
  19. package/dist/utils/format.d.ts +14 -12
  20. package/dist/utils/format.js +50 -30
  21. package/dist/utils.d.ts +2 -2
  22. package/dist/utils.js +2 -2
  23. package/package.json +11 -2
  24. package/src/chat/chat-message.tsx +2 -7
  25. package/src/chat/user-message.tsx +3 -5
  26. package/src/editor/source-markdown-editor.test.tsx +7 -7
  27. package/src/files/file-tree.tsx +2 -7
  28. package/src/primitives/hover-card.tsx +64 -74
  29. package/src/primitives/popover.tsx +3 -3
  30. package/src/primitives/upload-progress.tsx +2 -7
  31. package/src/utils/format.ts +50 -33
  32. package/src/utils/index.ts +1 -1
  33. package/src/editor/document-editor-pane.test.tsx +0 -60
  34. package/src/editor/editor-lazy.test.tsx +0 -102
  35. package/src/editor/editor-peers.test.ts +0 -250
  36. package/src/editor/editor-provider.test.tsx +0 -226
  37. package/src/editor/markdown-document-editor.test.tsx +0 -126
  38. package/src/editor/tiptap-editor.test.tsx +0 -228
  39. package/src/files/file-artifact-pane.test.tsx +0 -301
  40. package/src/files/file-card.test.tsx +0 -62
  41. package/src/files/file-list.test.tsx +0 -81
  42. package/src/files/pdf-loader.test.ts +0 -130
  43. package/src/files/pdf-viewer.test.tsx +0 -245
  44. package/src/files/rich-file-tree.test.tsx +0 -90
  45. package/src/markdown/api-presentation.test.tsx +0 -63
  46. package/src/markdown/code-block.test.tsx +0 -201
  47. package/src/markdown/markdown-table.test.tsx +0 -76
  48. package/src/markdown/markdown.test.tsx +0 -166
  49. package/src/openui/schema.test.tsx +0 -158
  50. package/src/primitives/button.test.tsx +0 -230
  51. package/src/primitives/card.test.tsx +0 -71
  52. package/src/primitives/chip.test.tsx +0 -200
  53. package/src/primitives/date-picker.test.tsx +0 -146
  54. package/src/primitives/dialog.test.tsx +0 -75
  55. package/src/primitives/input.test.tsx +0 -161
  56. package/src/primitives/interaction-states.test.tsx +0 -287
  57. package/src/primitives/pagination-alert.test.tsx +0 -180
  58. package/src/primitives/popover-collapsible-alert-dialog-context-menu-slider.test.tsx +0 -165
  59. package/src/primitives/radio-group.test.tsx +0 -26
  60. package/src/primitives/segmented-control.test.tsx +0 -451
  61. package/src/primitives/table.test.tsx +0 -72
  62. package/src/primitives/theme-toggle.test.tsx +0 -168
  63. package/src/primitives/toast.test.tsx +0 -130
  64. package/src/primitives/wizard.test.tsx +0 -106
  65. package/src/run/expandable-content.test.tsx +0 -52
  66. package/src/utils/format.test.ts +0 -58
@@ -1,5 +1,3 @@
1
- /** Format a duration in milliseconds to a human-readable string. */
2
- export declare function formatDuration(ms: number): string;
3
1
  /**
4
2
  * Markdown read as plain text, for a one-line preview: code fences, emphasis,
5
3
  * headings, list and quote markers, and link syntax are removed, so a preview
@@ -8,19 +6,23 @@ export declare function formatDuration(ms: number): string;
8
6
  export declare function plainTextFromMarkdown(text: string): string;
9
7
  /** Truncate text to `max` characters, appending "..." if truncated. */
10
8
  export declare function truncateText(text: string, max: number): string;
11
- /**
12
- * Format an uptime duration in milliseconds with progressive
13
- * granularity, so short-lived sandboxes don't render as "0d 0h".
14
- * - < 60s → "Ns"
15
- * - < 60m → "Nm Ss"
16
- * - < 24h → "Nh Mm"
17
- * - otherwise → "Nd Hh"
18
- */
19
- export declare function formatUptime(ms: number): string;
20
9
  /**
21
10
  * Format a byte count using binary units (KiB/MiB/GiB, surfaced as
22
11
  * "KB/MB/GB" for readability). KB and MB use one decimal below 10 and
23
12
  * round above; GB keeps two decimals below 10 so half-GB changes stay
24
13
  * visible on memory dashboards, and drops to one decimal above.
25
14
  */
26
- export declare function formatBytes(bytes: number): string;
15
+ export declare function formatBytes(bytes: number): string;
16
+ export declare function formatAxisDuration(ms: number): string;
17
+ export declare const formatCompact: (value: number | null | undefined) => string;
18
+ /** Span milliseconds, tenths below ten seconds, then seconds/minutes; days stay compact. */
19
+ export declare function formatSpanDuration(ms: number): string;
20
+ /** $0.12, $0.005, $12.40; never rounds a recorded nonzero cost to $0.00. */
21
+ export declare function formatCost(usd: number): string;
22
+ /** 9.8k, 754.4k, 1.2M with the exact count left to a title. */
23
+ export declare function formatCount(n: number): string;
24
+ /** Elapsed clock at millisecond precision, so zoomed axis ticks keep distinct labels. */
25
+ export declare function formatClock(ms: number): string;
26
+ /** Normalize labels to UTC without rewriting the source timestamp. */
27
+ export declare function formatUtcTime(value: string | Date, includeDate?: boolean): string;
28
+ export { formatSpanDuration as formatDuration, formatSpanDuration as formatUptime };
@@ -1,13 +1,4 @@
1
1
  // src/utils/format.ts
2
- /** Format a duration in milliseconds to a human-readable string. */
3
- function formatDuration(ms) {
4
- if (ms < 1e3) return "<1s";
5
- const seconds = Math.floor(ms / 1e3);
6
- if (seconds < 60) return `${seconds}s`;
7
- const minutes = Math.floor(seconds / 60);
8
- const remaining = seconds % 60;
9
- return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
10
- }
11
2
  /**
12
3
  * Markdown read as plain text, for a one-line preview: code fences, emphasis,
13
4
  * headings, list and quote markers, and link syntax are removed, so a preview
@@ -23,26 +14,6 @@ function truncateText(text, max) {
23
14
  return cleaned.slice(0, max).trim() + "...";
24
15
  }
25
16
  /**
26
- * Format an uptime duration in milliseconds with progressive
27
- * granularity, so short-lived sandboxes don't render as "0d 0h".
28
- * - < 60s → "Ns"
29
- * - < 60m → "Nm Ss"
30
- * - < 24h → "Nh Mm"
31
- * - otherwise → "Nd Hh"
32
- */
33
- function formatUptime(ms) {
34
- if (!Number.isFinite(ms) || ms < 0) return "—";
35
- const totalSeconds = Math.floor(ms / 1e3);
36
- if (totalSeconds < 60) return `${totalSeconds}s`;
37
- const minutes = Math.floor(totalSeconds / 60);
38
- const seconds = totalSeconds % 60;
39
- if (minutes < 60) return `${minutes}m ${seconds}s`;
40
- const hours = Math.floor(minutes / 60);
41
- const remMinutes = minutes % 60;
42
- if (hours < 24) return `${hours}h ${remMinutes}m`;
43
- return `${Math.floor(hours / 24)}d ${hours % 24}h`;
44
- }
45
- /**
46
17
  * Format a byte count using binary units (KiB/MiB/GiB, surfaced as
47
18
  * "KB/MB/GB" for readability). KB and MB use one decimal below 10 and
48
19
  * round above; GB keeps two decimals below 10 so half-GB changes stay
@@ -58,4 +29,53 @@ function formatBytes(bytes) {
58
29
  const gb = mb / 1024;
59
30
  return `${gb < 10 ? gb.toFixed(2) : gb.toFixed(1)} GB`;
60
31
  }
61
- export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText };
32
+ function formatAxisDuration(ms) {
33
+ if (ms >= 864e5) {
34
+ const days = Math.floor(ms / 864e5), hours = Math.floor(ms % 864e5 / 36e5);
35
+ return `${days}d${hours ? ` ${hours}h` : ""}`;
36
+ }
37
+ const s = ms / 1e3;
38
+ if (s < 60) return `${Math.round(s)} s`;
39
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
40
+ return `${(s / 3600).toFixed(1)} h`;
41
+ }
42
+ const formatCompact = (value) => value === null || value === void 0 || !Number.isFinite(value) ? "—" : new Intl.NumberFormat("en-US", {
43
+ notation: "compact",
44
+ maximumFractionDigits: 1
45
+ }).format(value);
46
+ /** Span milliseconds, tenths below ten seconds, then seconds/minutes; days stay compact. */
47
+ function formatSpanDuration(ms) {
48
+ if (!Number.isFinite(ms) || ms < 0) return "—";
49
+ const precision = ms < 1e4 ? 100 : ms < 36e5 ? 1e3 : 6e4;
50
+ return ms >= 864e5 ? formatAxisDuration(ms) : ms < 1e3 ? `${Math.round(ms)}ms` : formatClock(Math.round(ms / precision) * precision);
51
+ }
52
+ /** $0.12, $0.005, $12.40; never rounds a recorded nonzero cost to $0.00. */
53
+ function formatCost(usd) {
54
+ if (!Number.isFinite(usd) || usd < 0) return "—";
55
+ if (usd === 0) return "$0.00";
56
+ if (usd < .01) return `$${usd.toPrecision(1)}`;
57
+ return `$${usd.toFixed(2)}`;
58
+ }
59
+ /** 9.8k, 754.4k, 1.2M with the exact count left to a title. */
60
+ function formatCount(n) {
61
+ return !Number.isSafeInteger(n) || n < 0 ? "—" : formatCompact(n);
62
+ }
63
+ /** Elapsed clock at millisecond precision, so zoomed axis ticks keep distinct labels. */
64
+ function formatClock(ms) {
65
+ if (!Number.isFinite(ms) || ms < 0) return "—";
66
+ const seconds = Math.round(ms) / 1e3;
67
+ const hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds % 3600 / 60), rest = Number((seconds % 60).toFixed(3));
68
+ return [
69
+ hours ? `${hours}h` : "",
70
+ minutes ? `${minutes}m` : "",
71
+ rest ? `${rest}s` : ""
72
+ ].filter(Boolean).join(" ") || "0s";
73
+ }
74
+ /** Normalize labels to UTC without rewriting the source timestamp. */
75
+ function formatUtcTime(value, includeDate = false) {
76
+ const date = typeof value === "string" ? new Date(value) : value;
77
+ if (!Number.isFinite(date.valueOf())) return "Unknown time";
78
+ const iso = date.toISOString();
79
+ return `${includeDate ? iso.slice(0, 19).replace("T", " ") : iso.slice(11, 19)} UTC`;
80
+ }
81
+ export { formatAxisDuration, formatBytes, formatClock, formatCompact, formatCost, formatCount, formatSpanDuration as formatDuration, formatSpanDuration, formatSpanDuration as formatUptime, formatUtcTime, plainTextFromMarkdown, truncateText };
package/dist/utils.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline } from "./lib/interaction.js";
2
2
  import { copyText } from "./utils/copy-text.js";
3
- import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
3
+ import { formatAxisDuration, formatBytes, formatClock, formatCompact, formatCost, formatCount, formatDuration as formatSpanDuration, formatUtcTime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
4
4
  import { timeAgo } from "./utils/time-ago.js";
5
5
  import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
6
6
  import { cn } from "./lib/utils.js";
7
7
  import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
8
- export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
8
+ export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatAxisDuration, formatBytes, formatClock, formatCompact, formatCost, formatCount, formatSpanDuration as formatDuration, formatSpanDuration, formatSpanDuration as formatUptime, formatUtcTime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
package/dist/utils.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import { ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline } from "./lib/interaction.js";
2
2
  import { focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset } from "./lib/focus.js";
3
3
  import { cn } from "./lib/utils.js";
4
+ import { formatAxisDuration, formatBytes, formatClock, formatCompact, formatCost, formatCount, formatDuration as formatSpanDuration, formatUtcTime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
4
5
  import { TOOL_CATEGORY_ICONS, getToolCategory, getToolDisplayMetadata, getToolErrorText } from "./utils/tool-display.js";
5
- import { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from "./utils/format.js";
6
6
  import { copyText } from "./utils/copy-text.js";
7
7
  import { timeAgo } from "./utils/time-ago.js";
8
- export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatBytes, formatDuration, formatUptime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
8
+ export { TOOL_CATEGORY_ICONS, cn, copyText, focusField, focusFieldInvalid, focusFieldWithin, focusRing, focusRingInset, formatAxisDuration, formatBytes, formatClock, formatCompact, formatCost, formatCount, formatSpanDuration as formatDuration, formatSpanDuration, formatSpanDuration as formatUptime, formatUtcTime, getToolCategory, getToolDisplayMetadata, getToolErrorText, ixControl, ixControlAfter, ixControlSoft, ixField, ixRow, ixSelected, ixTabUnderline, plainTextFromMarkdown, timeAgo, truncateText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tangle-network/ui",
3
- "version": "11.51.7",
3
+ "version": "11.51.9",
4
4
  "description": "Generic React UI components for Tangle products — primitives, chat, run, files, editor, markdown.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -165,6 +165,16 @@
165
165
  "types": "./dist/hooks/use-follow-tail.d.ts",
166
166
  "import": "./dist/hooks/use-follow-tail.js",
167
167
  "default": "./dist/hooks/use-follow-tail.js"
168
+ },
169
+ "./utils/format": {
170
+ "types": "./dist/utils/format.d.ts",
171
+ "import": "./dist/utils/format.js",
172
+ "default": "./dist/utils/format.js"
173
+ },
174
+ "./primitives/hover-card": {
175
+ "types": "./dist/primitives/hover-card.d.ts",
176
+ "import": "./dist/primitives/hover-card.js",
177
+ "default": "./dist/primitives/hover-card.js"
168
178
  }
169
179
  },
170
180
  "dependencies": {
@@ -177,7 +187,6 @@
177
187
  "@radix-ui/react-context-menu": "^2.4.1",
178
188
  "@radix-ui/react-dialog": "^1.3.1",
179
189
  "@radix-ui/react-dropdown-menu": "^2.2.1",
180
- "@radix-ui/react-hover-card": "^1.2.1",
181
190
  "@radix-ui/react-label": "^2.1.17",
182
191
  "@radix-ui/react-popover": "^1.3.1",
183
192
  "@radix-ui/react-progress": "^1.2.1",
@@ -1,3 +1,4 @@
1
+ import { formatUtcTime } from "../utils/format";
1
2
  /**
2
3
  * ChatMessage — one message in the conversation.
3
4
  *
@@ -84,7 +85,7 @@ export function ChatMessage({
84
85
  )}
85
86
  {timestamp && (
86
87
  <span className="text-muted-foreground text-xs">
87
- {formatTime(timestamp)}
88
+ {formatUtcTime(timestamp)}
88
89
  </span>
89
90
  )}
90
91
  </div>
@@ -122,9 +123,3 @@ export function ChatMessage({
122
123
  );
123
124
  }
124
125
 
125
- function formatTime(date: Date): string {
126
- return date.toLocaleTimeString(undefined, {
127
- hour: "numeric",
128
- minute: "2-digit",
129
- });
130
- }
@@ -1,3 +1,4 @@
1
+ import { formatUtcTime } from "../utils/format";
1
2
  import { TRANSCRIPT_TEXT } from "./transcript-text";
2
3
  import { memo, useLayoutEffect, useRef, useState, type ReactNode } from "react";
3
4
  import type { ChatAuthor, SessionMessage } from "../types/message";
@@ -30,9 +31,6 @@ export interface UserMessageProps {
30
31
  collapsedLines?: number;
31
32
  }
32
33
 
33
- function formatTime(date: Date): string {
34
- return Number.isFinite(date.valueOf()) ? date.toISOString().slice(11, 16) + " UTC" : "Invalid Date";
35
- }
36
34
 
37
35
  function MessageActions({ children, align = "end" }: { children: ReactNode; align?: "start" | "end" }) {
38
36
  return (
@@ -104,7 +102,7 @@ function UserBubble({ text, timestamp, actions, lines }: {
104
102
  data-user-message-time=""
105
103
  className="pointer-events-none absolute right-full top-1/2 mr-3 -translate-y-1/2 whitespace-nowrap text-sm text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
106
104
  >
107
- {formatTime(timestamp)}
105
+ {formatUtcTime(timestamp)}
108
106
  </span>
109
107
  ) : null}
110
108
  <BubbleText text={text} lines={lines} />
@@ -138,7 +136,7 @@ function OtherPersonBubble({ text, timestamp, actions, author, lines }: {
138
136
  data-user-message-time=""
139
137
  className="pointer-events-none absolute left-full top-1/2 ml-3 -translate-y-1/2 whitespace-nowrap text-sm text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100"
140
138
  >
141
- {formatTime(timestamp)}
139
+ {formatUtcTime(timestamp)}
142
140
  </span>
143
141
  ) : null}
144
142
  <BubbleText text={text} lines={lines} />
@@ -1,6 +1,6 @@
1
1
  import { act, render, waitFor } from "@testing-library/react";
2
2
  import type { Editor } from "@tiptap/react";
3
- import { describe, expect, it, vi } from "vitest";
3
+ import { describe, expect, it } from "vitest";
4
4
  import { MarkdownDocumentEditor } from "./markdown-document-editor";
5
5
  import { CALL_SHEET, KITCHEN_SINK } from "./source-markdown.fixtures";
6
6
 
@@ -10,12 +10,12 @@ const PEERS_LOADED = { timeout: 15_000 };
10
10
  const LOADS_PEERS = 30_000;
11
11
 
12
12
  async function openEditor(value: string) {
13
- const onChange = vi.fn<(markdown: string) => void>();
13
+ const changes: string[] = [];
14
14
  let editor: Editor | null = null;
15
15
  const view = render(
16
16
  <MarkdownDocumentEditor
17
17
  value={value}
18
- onChange={(markdown) => onChange(markdown)}
18
+ onChange={(markdown) => changes.push(markdown)}
19
19
  onReady={(ready) => {
20
20
  editor = ready;
21
21
  }}
@@ -32,9 +32,9 @@ async function openEditor(value: string) {
32
32
  get editor() {
33
33
  return editor! as Editor;
34
34
  },
35
- onChange,
35
+ changes,
36
36
  /** The Markdown the editor last reported, or the value it opened. */
37
- saved: () => onChange.mock.calls.at(-1)?.[0] ?? value,
37
+ saved: () => changes.at(-1) ?? value,
38
38
  };
39
39
  }
40
40
 
@@ -63,13 +63,13 @@ function tableRows(markdown: string) {
63
63
 
64
64
  describe("MarkdownDocumentEditor source preservation", () => {
65
65
  it("opens every Markdown construct without reporting a change", async () => {
66
- const { editor, onChange, view } = await openEditor(KITCHEN_SINK);
66
+ const { editor, changes, view } = await openEditor(KITCHEN_SINK);
67
67
  act(() => {
68
68
  editor.commands.setTextSelection(1);
69
69
  editor.setEditable(false);
70
70
  editor.setEditable(true);
71
71
  });
72
- expect(onChange).not.toHaveBeenCalled();
72
+ expect(changes).toHaveLength(0);
73
73
  // The table is shown as a table, read-only, instead of a run-on paragraph.
74
74
  const table = view.container.querySelector('[data-md-kind="table"] table');
75
75
  expect(table?.querySelectorAll("tbody tr")).toHaveLength(1);
@@ -1,3 +1,4 @@
1
+ import { formatBytes } from "../utils/format";
1
2
  /**
2
3
  * FileTree — directory browser with file type icons.
3
4
  *
@@ -179,7 +180,7 @@ function TreeNode({ node, depth, selectedPath, onSelect, defaultExpanded }: Tree
179
180
  <span className="truncate">{node.name}</span>
180
181
  {node.size !== undefined && !isDir && (
181
182
  <span className="text-muted-foreground text-xs ml-auto tabular-nums">
182
- {formatSize(node.size)}
183
+ {formatBytes(node.size)}
183
184
  </span>
184
185
  )}
185
186
  </button>
@@ -206,12 +207,6 @@ function TreeNode({ node, depth, selectedPath, onSelect, defaultExpanded }: Tree
206
207
  );
207
208
  }
208
209
 
209
- function formatSize(bytes: number): string {
210
- if (bytes < 1024) return `${bytes}B`;
211
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}K`;
212
- return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
213
- }
214
-
215
210
  export function FileTree({
216
211
  root,
217
212
  selectedPath,
@@ -1,89 +1,79 @@
1
1
  import { cn } from "../lib/utils";
2
2
  import * as React from "react";
3
+ import { Slot } from "@radix-ui/react-slot";
4
+ import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from "./popover";
3
5
 
4
- /**
5
- * A rich hover preview: the face of a person, product, or record that appears
6
- * when you pause on its trigger. Radix HoverCard under the hood, styled on the
7
- * md3 surface ladder — the card sits on `--md3-surface-container-highest`
8
- * (L4, the overlay plane) so it reads as a floating layer above the page.
9
- *
10
- * ```tsx
11
- * <HoverCard>
12
- * <HoverCardTrigger>@noa</HoverCardTrigger>
13
- * <HoverCardContent>
14
- * <HoverCardHeader>
15
- * <HoverCardTitle>Noa</HoverCardTitle>
16
- * <HoverCardDescription>Housekeeping · es</HoverCardDescription>
17
- * </HoverCardHeader>
18
- * <p>3 rooms today · 1 pending</p>
19
- * </HoverCardContent>
20
- * </HoverCard>
21
- * ```
22
- *
23
- * The content is **lazy** — it mounts only when hovered, so a page of 50
24
- * triggers costs nothing until the person pauses on one.
25
- */
26
- import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
6
+ const HoverIntent = React.createContext({ enter: (_target: Element) => {}, leave: () => {}, restore: () => {} });
7
+ type HoverCardProps = React.ComponentProps<typeof Popover> & {
8
+ /** Existing DOM trigger for delegated canvas or virtual-list previews. */
9
+ anchor?: HTMLElement | { getBoundingClientRect: () => DOMRect; contextElement: HTMLElement } | null;
10
+ openDelay?: number;
11
+ closeDelay?: number;
12
+ };
27
13
 
28
- const HoverCard = HoverCardPrimitive.Root;
14
+ /** One hover policy and Popover's keyboard/focus behavior; delays follow the trigger's fast token. */
15
+ export function HoverCard({ anchor, open: controlled, defaultOpen = false, onOpenChange, openDelay, closeDelay, children, ...props }: HoverCardProps) {
16
+ const element = anchor && ("contextElement" in anchor ? anchor.contextElement : anchor);
17
+ const [own, setOwn] = React.useState({ anchor: element, open: defaultOpen });
18
+ const intent = React.useRef<{ timer?: ReturnType<typeof setTimeout>; target?: Element; restoring?: boolean }>({});
19
+ if (element) intent.current.target = element;
20
+ const change = React.useCallback((next: boolean) => { clearTimeout(intent.current.timer); if (controlled === undefined) setOwn({ anchor: element, open: next }); onOpenChange?.(next); }, [controlled, onOpenChange, element]);
21
+ const delay = (target: Element | null | undefined) => {
22
+ if (typeof document === "undefined") return 0;
23
+ const value = getComputedStyle(target ?? document.documentElement).getPropertyValue("--duration-fast").trim();
24
+ return (parseFloat(value) || 0) * (value.endsWith("ms") ? 1 : 1000);
25
+ };
26
+ const enter = React.useCallback((target: Element) => { if (intent.current.restoring) return; if (!target.closest("[data-hover-card]")) intent.current.target = target; clearTimeout(intent.current.timer); intent.current.timer = setTimeout(() => change(true), openDelay ?? delay(target)); }, [change, openDelay]);
27
+ const leave = React.useCallback(() => { clearTimeout(intent.current.timer); intent.current.timer = setTimeout(() => change(false), closeDelay ?? delay(element ?? intent.current.target)); }, [change, closeDelay, element]);
28
+ React.useEffect(() => {
29
+ if (!element) return () => clearTimeout(intent.current.timer);
30
+ const show = () => enter(element), blur = (event: FocusEvent) => { if (!(event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-hover-card]"))) leave(); };
31
+ element.addEventListener("pointerenter", show); element.addEventListener("pointerleave", leave); element.addEventListener("focusin", show); element.addEventListener("focusout", blur);
32
+ return () => { clearTimeout(intent.current.timer); element.removeEventListener("pointerenter", show); element.removeEventListener("pointerleave", leave); element.removeEventListener("focusin", show); element.removeEventListener("focusout", blur); };
33
+ }, [anchor, element, enter, leave]);
34
+ const restore = () => {
35
+ intent.current.restoring = true;
36
+ if (document.activeElement === document.body && intent.current.target instanceof HTMLElement) intent.current.target.focus({ preventScroll: true });
37
+ queueMicrotask(() => { intent.current.restoring = false; });
38
+ };
39
+ return <HoverIntent.Provider value={{ enter, leave, restore }}><Popover {...props} open={anchor === null ? false : controlled ?? (own.anchor === element && own.open)} onOpenChange={change}>
40
+ {anchor && <PopoverAnchor virtualRef={{ current: anchor }} />}{children}
41
+ </Popover></HoverIntent.Provider>;
42
+ }
29
43
 
30
- const HoverCardTrigger = React.forwardRef<
31
- React.ElementRef<typeof HoverCardPrimitive.Trigger>,
32
- React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
33
- >(({ className, ...props }, ref) => (
34
- <HoverCardPrimitive.Trigger
35
- ref={ref}
36
- className={cn("cursor-pointer underline-offset-4 hover:underline", className)}
37
- {...props}
38
- />
39
- ));
40
- HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
44
+ export const HoverCardTrigger = React.forwardRef<HTMLAnchorElement, React.AnchorHTMLAttributes<HTMLAnchorElement> & { asChild?: boolean }>(
45
+ ({ className, onPointerEnter, onPointerLeave, onFocus, onBlur, asChild, ...props }, ref) => {
46
+ const intent = React.useContext(HoverIntent);
47
+ const Trigger = asChild ? Slot : "a";
48
+ return <PopoverTrigger asChild><Trigger ref={ref} tabIndex={0} className={cn("cursor-pointer underline-offset-4 hover:underline", className)}
49
+ onPointerEnter={event => { onPointerEnter?.(event); if (!event.defaultPrevented) intent.enter(event.currentTarget); }}
50
+ onPointerLeave={event => { onPointerLeave?.(event); if (!event.defaultPrevented) intent.leave(); }}
51
+ onFocus={event => { onFocus?.(event); if (!event.defaultPrevented) intent.enter(event.currentTarget); }}
52
+ onBlur={event => { onBlur?.(event); if (!event.defaultPrevented && !(event.relatedTarget instanceof Element && event.relatedTarget.closest("[data-hover-card]"))) intent.leave(); }} {...props} /></PopoverTrigger>;
53
+ },
54
+ );
55
+ HoverCardTrigger.displayName = "HoverCardTrigger";
41
56
 
42
- const HoverCardContent = React.forwardRef<
43
- React.ElementRef<typeof HoverCardPrimitive.Content>,
44
- React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content> & {
45
- /** Which edge the card prefers. Defaults to bottom. */
46
- side?: "top" | "right" | "bottom" | "left";
47
- /** Pixel gap from the trigger. Defaults to 6. */
48
- alignOffset?: number;
49
- }
50
- >(({ className, side = "bottom", alignOffset = 6, ...props }, ref) => (
51
- <HoverCardPrimitive.Portal>
52
- <HoverCardPrimitive.Content
53
- ref={ref}
54
- side={side}
55
- alignOffset={alignOffset}
56
- sideOffset={6}
57
- className={cn(
58
- "z-50 w-64 rounded-lg border border-border bg-popover p-4 text-popover-foreground shadow-md outline-none",
59
- "data-[state=open]:animate-in data-[state=closed]:animate-out",
60
- "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
61
- "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
62
- className
63
- )}
64
- {...props}
65
- />
66
- </HoverCardPrimitive.Portal>
67
- ));
68
- HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
57
+ export const HoverCardContent = React.forwardRef<React.ElementRef<typeof PopoverContent>, React.ComponentPropsWithoutRef<typeof PopoverContent>>(
58
+ ({ className, onPointerEnter, onPointerLeave, onOpenAutoFocus, onCloseAutoFocus, ...props }, ref) => {
59
+ const intent = React.useContext(HoverIntent);
60
+ return <PopoverContent ref={ref} data-hover-card className={cn("w-64", className)}
61
+ onOpenAutoFocus={event => { onOpenAutoFocus?.(event); event.preventDefault(); }}
62
+ onCloseAutoFocus={event => { onCloseAutoFocus?.(event); if (!event.defaultPrevented) { event.preventDefault(); intent.restore(); } }}
63
+ onPointerEnter={event => { onPointerEnter?.(event); if (!event.defaultPrevented) intent.enter(event.currentTarget); }}
64
+ onPointerLeave={event => { onPointerLeave?.(event); if (!event.defaultPrevented) intent.leave(); }} {...props} />;
65
+ },
66
+ );
67
+ HoverCardContent.displayName = "HoverCardContent";
69
68
 
70
- function HoverCardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
69
+ export function HoverCardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
71
70
  return <div className={cn("flex flex-col gap-1 pb-2", className)} {...props} />;
72
71
  }
73
72
 
74
- function HoverCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
73
+ export function HoverCardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
75
74
  return <h4 className={cn("text-sm font-semibold leading-none", className)} {...props} />;
76
75
  }
77
76
 
78
- function HoverCardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
77
+ export function HoverCardDescription({ className, ...props }: React.HTMLAttributes<HTMLParagraphElement>) {
79
78
  return <p className={cn("text-sm text-muted-foreground", className)} {...props} />;
80
79
  }
81
-
82
- export {
83
- HoverCard,
84
- HoverCardTrigger,
85
- HoverCardContent,
86
- HoverCardHeader,
87
- HoverCardTitle,
88
- HoverCardDescription,
89
- };
@@ -21,9 +21,9 @@ const PopoverClose = PopoverPrimitive.Close;
21
21
  */
22
22
  const PopoverContent = React.forwardRef<
23
23
  React.ElementRef<typeof PopoverPrimitive.Content>,
24
- React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
25
- >(({ className, align = "center", sideOffset = 4, asChild, children, ...props }, ref) => (
26
- <PopoverPrimitive.Portal>
24
+ React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & { container?: HTMLElement | null }
25
+ >(({ className, align = "center", sideOffset = 4, asChild, children, container, ...props }, ref) => (
26
+ <PopoverPrimitive.Portal container={container}>
27
27
  <PopoverPrimitive.Content
28
28
  ref={ref}
29
29
  align={align}
@@ -1,3 +1,4 @@
1
+ import { formatBytes } from "../utils/format";
1
2
  /**
2
3
  * UploadProgress — file upload status indicators.
3
4
  *
@@ -25,12 +26,6 @@ export interface UploadProgressProps {
25
26
  className?: string;
26
27
  }
27
28
 
28
- function formatSize(bytes: number): string {
29
- if (bytes < 1024) return `${bytes}B`;
30
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`;
31
- return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
32
- }
33
-
34
29
  export function UploadProgress({ files, onRemove, onRetry, className }: UploadProgressProps) {
35
30
  if (files.length === 0) return null;
36
31
 
@@ -66,7 +61,7 @@ export function UploadProgress({ files, onRemove, onRetry, className }: UploadPr
66
61
  <div className="min-w-0 flex-1">
67
62
  <div className="flex items-center gap-2">
68
63
  <span className="truncate font-medium text-foreground">{file.name}</span>
69
- <span className="shrink-0 text-[length:var(--font-size-help,0.75rem)] text-muted-foreground">{formatSize(file.size)}</span>
64
+ <span className="shrink-0 text-[length:var(--font-size-help,0.75rem)] text-muted-foreground">{formatBytes(file.size)}</span>
70
65
  </div>
71
66
  {/* Progress bar */}
72
67
  {file.status === "uploading" && file.progress !== undefined && (
@@ -1,13 +1,3 @@
1
- /** Format a duration in milliseconds to a human-readable string. */
2
- export function formatDuration(ms: number): string {
3
- if (ms < 1000) return "<1s";
4
- const seconds = Math.floor(ms / 1000);
5
- if (seconds < 60) return `${seconds}s`;
6
- const minutes = Math.floor(seconds / 60);
7
- const remaining = seconds % 60;
8
- return remaining > 0 ? `${minutes}m ${remaining}s` : `${minutes}m`;
9
- }
10
-
11
1
  /**
12
2
  * Markdown read as plain text, for a one-line preview: code fences, emphasis,
13
3
  * headings, list and quote markers, and link syntax are removed, so a preview
@@ -32,29 +22,6 @@ export function truncateText(text: string, max: number): string {
32
22
  return cleaned.slice(0, max).trim() + "...";
33
23
  }
34
24
 
35
- /**
36
- * Format an uptime duration in milliseconds with progressive
37
- * granularity, so short-lived sandboxes don't render as "0d 0h".
38
- * - < 60s → "Ns"
39
- * - < 60m → "Nm Ss"
40
- * - < 24h → "Nh Mm"
41
- * - otherwise → "Nd Hh"
42
- */
43
- export function formatUptime(ms: number): string {
44
- if (!Number.isFinite(ms) || ms < 0) return "—";
45
- const totalSeconds = Math.floor(ms / 1000);
46
- if (totalSeconds < 60) return `${totalSeconds}s`;
47
- const minutes = Math.floor(totalSeconds / 60);
48
- const seconds = totalSeconds % 60;
49
- if (minutes < 60) return `${minutes}m ${seconds}s`;
50
- const hours = Math.floor(minutes / 60);
51
- const remMinutes = minutes % 60;
52
- if (hours < 24) return `${hours}h ${remMinutes}m`;
53
- const days = Math.floor(hours / 24);
54
- const remHours = hours % 24;
55
- return `${days}d ${remHours}h`;
56
- }
57
-
58
25
  /**
59
26
  * Format a byte count using binary units (KiB/MiB/GiB, surfaced as
60
27
  * "KB/MB/GB" for readability). KB and MB use one decimal below 10 and
@@ -71,3 +38,53 @@ export function formatBytes(bytes: number): string {
71
38
  const gb = mb / 1024;
72
39
  return `${gb < 10 ? gb.toFixed(2) : gb.toFixed(1)} GB`;
73
40
  }
41
+
42
+ export function formatAxisDuration(ms: number): string {
43
+ if (ms >= 86_400_000) { const days = Math.floor(ms / 86_400_000), hours = Math.floor(ms % 86_400_000 / 3_600_000); return `${days}d${hours ? ` ${hours}h` : ''}`; }
44
+ const s = ms / 1000;
45
+ if (s < 60) return `${Math.round(s)} s`;
46
+ if (s < 3600) return `${(s / 60).toFixed(1)} min`;
47
+ return `${(s / 3600).toFixed(1)} h`;
48
+ }
49
+
50
+ export const formatCompact = (value: number | null | undefined) =>
51
+ value === null || value === undefined || !Number.isFinite(value) ? '—' : new Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1 }).format(value)
52
+
53
+ /** Span milliseconds, tenths below ten seconds, then seconds/minutes; days stay compact. */
54
+ export function formatSpanDuration(ms: number): string {
55
+ if (!Number.isFinite(ms) || ms < 0) return '—'
56
+ const precision = ms < 10_000 ? 100 : ms < 3_600_000 ? 1000 : 60_000
57
+ return ms >= 86_400_000 ? formatAxisDuration(ms) : ms < 1000 ? `${Math.round(ms)}ms` : formatClock(Math.round(ms / precision) * precision)
58
+ }
59
+
60
+ /** $0.12, $0.005, $12.40; never rounds a recorded nonzero cost to $0.00. */
61
+ export function formatCost(usd: number): string {
62
+ if (!Number.isFinite(usd) || usd < 0) return '—'
63
+ if (usd === 0) return '$0.00'
64
+ if (usd < 0.01) return `$${usd.toPrecision(1)}`
65
+ return `$${usd.toFixed(2)}`
66
+ }
67
+
68
+ /** 9.8k, 754.4k, 1.2M with the exact count left to a title. */
69
+ export function formatCount(n: number): string {
70
+ return !Number.isSafeInteger(n) || n < 0 ? '—' : formatCompact(n)
71
+ }
72
+
73
+ /** Elapsed clock at millisecond precision, so zoomed axis ticks keep distinct labels. */
74
+ export function formatClock(ms: number): string {
75
+ if (!Number.isFinite(ms) || ms < 0) return '—'
76
+ const seconds = Math.round(ms) / 1000
77
+ const hours = Math.floor(seconds / 3600), minutes = Math.floor(seconds % 3600 / 60), rest = Number((seconds % 60).toFixed(3))
78
+ return [hours ? `${hours}h` : '', minutes ? `${minutes}m` : '', rest ? `${rest}s` : ''].filter(Boolean).join(' ') || '0s'
79
+ }
80
+
81
+ /** Normalize labels to UTC without rewriting the source timestamp. */
82
+ export function formatUtcTime(value: string | Date, includeDate = false) {
83
+ const date = typeof value === "string" ? new Date(value) : value
84
+ if (!Number.isFinite(date.valueOf())) return 'Unknown time'
85
+ const iso = date.toISOString()
86
+ return `${includeDate ? iso.slice(0, 19).replace('T', ' ') : iso.slice(11, 19)} UTC`
87
+ }
88
+
89
+ // Existing names share the canonical duration implementation.
90
+ export { formatSpanDuration as formatDuration, formatSpanDuration as formatUptime };
@@ -1,5 +1,5 @@
1
1
  export { copyText } from './copy-text';
2
- export { formatBytes, formatDuration, formatUptime, plainTextFromMarkdown, truncateText } from './format';
2
+ export * from './format';
3
3
  export { timeAgo } from './time-ago';
4
4
  export {
5
5
  getToolCategory,