@tangle-network/ui 11.51.8 → 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.
- package/CHANGELOG.md +6 -0
- package/README.md +6 -0
- package/dist/chat/chat-message.js +2 -7
- package/dist/chat/user-message.js +3 -5
- package/dist/files/file-card.js +1 -1
- package/dist/files/file-tree.d.ts +0 -6
- package/dist/files/file-tree.js +2 -6
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/primitives/hover-card.d.ts +22 -13
- package/dist/primitives/hover-card.js +149 -40
- package/dist/primitives/popover.d.ts +3 -1
- package/dist/primitives/popover.js +15 -12
- package/dist/primitives/upload-progress.d.ts +0 -6
- package/dist/primitives/upload-progress.js +2 -6
- package/dist/run/run-group.js +2 -2
- package/dist/run/run-item-primitives.js +2 -2
- package/dist/run/run-row-shell.js +2 -2
- package/dist/utils/format.d.ts +14 -12
- package/dist/utils/format.js +50 -30
- package/dist/utils.d.ts +2 -2
- package/dist/utils.js +2 -2
- package/package.json +11 -2
- package/src/chat/chat-message.tsx +2 -7
- package/src/chat/user-message.tsx +3 -5
- package/src/files/file-tree.tsx +2 -7
- package/src/primitives/hover-card.tsx +64 -74
- package/src/primitives/popover.tsx +3 -3
- package/src/primitives/upload-progress.tsx +2 -7
- package/src/utils/format.ts +50 -33
- package/src/utils/index.ts +1 -1
- package/src/utils/format.test.ts +0 -58
package/dist/utils/format.d.ts
CHANGED
|
@@ -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 };
|
package/dist/utils/format.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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
|
-
{
|
|
139
|
+
{formatUtcTime(timestamp)}
|
|
142
140
|
</span>
|
|
143
141
|
) : null}
|
|
144
142
|
<BubbleText text={text} lines={lines} />
|
package/src/files/file-tree.tsx
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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">{
|
|
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 && (
|
package/src/utils/format.ts
CHANGED
|
@@ -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 };
|
package/src/utils/index.ts
CHANGED
package/src/utils/format.test.ts
DELETED
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { formatBytes, formatUptime, plainTextFromMarkdown } from "./format";
|
|
3
|
-
|
|
4
|
-
describe("formatUptime", () => {
|
|
5
|
-
it("renders seconds for short durations", () => {
|
|
6
|
-
expect(formatUptime(0)).toBe("0s");
|
|
7
|
-
expect(formatUptime(7_500)).toBe("7s");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("renders minutes and seconds under an hour", () => {
|
|
11
|
-
expect(formatUptime(90_000)).toBe("1m 30s");
|
|
12
|
-
expect(formatUptime(59 * 60_000 + 12_000)).toBe("59m 12s");
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("renders hours and minutes under a day", () => {
|
|
16
|
-
expect(formatUptime(3_600_000)).toBe("1h 0m");
|
|
17
|
-
expect(formatUptime(5 * 3_600_000 + 30 * 60_000)).toBe("5h 30m");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("renders days and hours for multi-day durations", () => {
|
|
21
|
-
expect(formatUptime(86_400_000)).toBe("1d 0h");
|
|
22
|
-
expect(formatUptime(86_400_000 * 3 + 3_600_000 * 4)).toBe("3d 4h");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("handles invalid input", () => {
|
|
26
|
-
expect(formatUptime(Number.NaN)).toBe("—");
|
|
27
|
-
expect(formatUptime(-1)).toBe("—");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
describe("formatBytes", () => {
|
|
32
|
-
it("renders bytes, KB, MB, GB progressively", () => {
|
|
33
|
-
expect(formatBytes(512)).toBe("512 B");
|
|
34
|
-
expect(formatBytes(2_048)).toBe("2.0 KB");
|
|
35
|
-
expect(formatBytes(5 * 1024 * 1024)).toBe("5.0 MB");
|
|
36
|
-
expect(formatBytes(2.5 * 1024 * 1024 * 1024)).toBe("2.50 GB");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("handles invalid input", () => {
|
|
40
|
-
expect(formatBytes(Number.NaN)).toBe("—");
|
|
41
|
-
expect(formatBytes(-1)).toBe("—");
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("plainTextFromMarkdown", () => {
|
|
46
|
-
it("drops emphasis, headings, list markers, code marks and link syntax", () => {
|
|
47
|
-
expect(plainTextFromMarkdown("**Ensuring workspace configuration** I need to check")).toBe("Ensuring workspace configuration I need to check");
|
|
48
|
-
expect(plainTextFromMarkdown("## Plan\n- item one\n- *soft* point")).toBe("Plan\nitem one\nsoft point");
|
|
49
|
-
expect(plainTextFromMarkdown("see [docs](https://example.com) and _this_")).toBe("see docs and this");
|
|
50
|
-
expect(plainTextFromMarkdown("**bold** and __also__ ~~gone~~")).toBe("bold and also gone");
|
|
51
|
-
expect(plainTextFromMarkdown("1. First\n2) Second")).toBe("First\nSecond");
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("keeps characters that are not markdown marks", () => {
|
|
55
|
-
expect(plainTextFromMarkdown("read `src/a_b.ts` and snake_case_name")).toBe("read src/a_b.ts and snake_case_name");
|
|
56
|
-
expect(plainTextFromMarkdown("2 * 3 * 4 = 24")).toBe("2 * 3 * 4 = 24");
|
|
57
|
-
});
|
|
58
|
-
});
|