@assistant-ui/react-devtools 1.2.18 → 1.2.20
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/dist/DevToolsModal.d.ts +2 -3
- package/dist/DevToolsModal.d.ts.map +1 -1
- package/dist/data/createInProcessClient.d.ts +3 -6
- package/dist/data/createInProcessClient.d.ts.map +1 -1
- package/dist/data/createInProcessClient.js +1 -3
- package/dist/data/createInProcessClient.js.map +1 -1
- package/dist/data/projectApi.d.ts +1 -2
- package/dist/data/projectApi.d.ts.map +1 -1
- package/dist/data/types.d.ts +6 -6
- package/dist/data/types.d.ts.map +1 -1
- package/dist/data/useDevToolsClient.d.ts +2 -3
- package/dist/data/useDevToolsClient.d.ts.map +1 -1
- package/dist/shell/DevToolsOverlay.d.ts +2 -3
- package/dist/shell/DevToolsOverlay.d.ts.map +1 -1
- package/dist/shell/DevToolsPanel.d.ts +2 -3
- package/dist/shell/DevToolsPanel.d.ts.map +1 -1
- package/dist/shell/ShadowRoot.d.ts +2 -3
- package/dist/shell/ShadowRoot.d.ts.map +1 -1
- package/dist/shell/SplitLayout.d.ts +3 -4
- package/dist/shell/SplitLayout.d.ts.map +1 -1
- package/dist/shell/registry.d.ts +4 -5
- package/dist/shell/registry.d.ts.map +1 -1
- package/dist/shell/tabs/ActivityTab.d.ts +1 -2
- package/dist/shell/tabs/ActivityTab.d.ts.map +1 -1
- package/dist/shell/tabs/ContextTab.d.ts +1 -2
- package/dist/shell/tabs/ContextTab.d.ts.map +1 -1
- package/dist/shell/tabs/RawTab.d.ts +1 -2
- package/dist/shell/tabs/RawTab.d.ts.map +1 -1
- package/dist/shell/tabs/ThreadTab.d.ts +1 -2
- package/dist/shell/tabs/ThreadTab.d.ts.map +1 -1
- package/dist/shell/tabs/navSelection.d.ts +1 -2
- package/dist/shell/tabs/navSelection.d.ts.map +1 -1
- package/dist/styles/panel.generated.d.ts +1 -2
- package/dist/styles/panel.generated.d.ts.map +1 -1
- package/dist/styles/panel.generated.js +1 -1
- package/dist/styles/panel.generated.js.map +1 -1
- package/dist/types.d.ts +1 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils/common.d.ts +13 -14
- package/dist/utils/common.d.ts.map +1 -1
- package/dist/utils/diff.d.ts +2 -3
- package/dist/utils/diff.d.ts.map +1 -1
- package/dist/utils/serialization.d.ts +5 -6
- package/dist/utils/serialization.d.ts.map +1 -1
- package/dist/utils/serialization.js +13 -4
- package/dist/utils/serialization.js.map +1 -1
- package/dist/utils/toolNormalization.d.ts +2 -3
- package/dist/utils/toolNormalization.d.ts.map +1 -1
- package/dist/utils/unserializable.d.ts +2 -3
- package/dist/utils/unserializable.d.ts.map +1 -1
- package/dist/views/activity/EventLogRow.d.ts +2 -3
- package/dist/views/activity/EventLogRow.d.ts.map +1 -1
- package/dist/views/activity/EventStreamPane.d.ts +1 -2
- package/dist/views/activity/EventStreamPane.d.ts.map +1 -1
- package/dist/views/activity/OrphansPane.d.ts +1 -2
- package/dist/views/activity/OrphansPane.d.ts.map +1 -1
- package/dist/views/activity/RunDetailPane.d.ts +1 -2
- package/dist/views/activity/RunDetailPane.d.ts.map +1 -1
- package/dist/views/activity/activityNodes.d.ts +6 -7
- package/dist/views/activity/activityNodes.d.ts.map +1 -1
- package/dist/views/attachments/AttachmentList.d.ts +1 -2
- package/dist/views/attachments/AttachmentList.d.ts.map +1 -1
- package/dist/views/attachments/MediaPartView.d.ts +3 -4
- package/dist/views/attachments/MediaPartView.d.ts.map +1 -1
- package/dist/views/attachments/formatBytes.d.ts +3 -4
- package/dist/views/attachments/formatBytes.d.ts.map +1 -1
- package/dist/views/attachments/parseAttachment.d.ts +2 -3
- package/dist/views/attachments/parseAttachment.d.ts.map +1 -1
- package/dist/views/attachments/types.d.ts +2 -3
- package/dist/views/attachments/types.d.ts.map +1 -1
- package/dist/views/context/ContextDetail.d.ts +1 -2
- package/dist/views/context/ContextDetail.d.ts.map +1 -1
- package/dist/views/context/ToolUIsView.d.ts +1 -2
- package/dist/views/context/ToolUIsView.d.ts.map +1 -1
- package/dist/views/context/contextNodes.d.ts +4 -5
- package/dist/views/context/contextNodes.d.ts.map +1 -1
- package/dist/views/mcp/McpView.d.ts +1 -2
- package/dist/views/mcp/McpView.d.ts.map +1 -1
- package/dist/views/mcp/parse.d.ts +1 -2
- package/dist/views/mcp/parse.d.ts.map +1 -1
- package/dist/views/mcp/types.d.ts +3 -4
- package/dist/views/mcp/types.d.ts.map +1 -1
- package/dist/views/message/ChainOfThought.d.ts +1 -2
- package/dist/views/message/ChainOfThought.d.ts.map +1 -1
- package/dist/views/message/MessageDetailPane.d.ts +2 -3
- package/dist/views/message/MessageDetailPane.d.ts.map +1 -1
- package/dist/views/message/MessageItem.d.ts +1 -2
- package/dist/views/message/MessageItem.d.ts.map +1 -1
- package/dist/views/message/MessageList.d.ts +1 -2
- package/dist/views/message/MessageList.d.ts.map +1 -1
- package/dist/views/message/MessageMetrics.d.ts +1 -2
- package/dist/views/message/MessageMetrics.d.ts.map +1 -1
- package/dist/views/message/MessageMetrics.js +1 -1
- package/dist/views/message/MessageMetrics.js.map +1 -1
- package/dist/views/message/MessageTimeline.d.ts +3 -4
- package/dist/views/message/MessageTimeline.d.ts.map +1 -1
- package/dist/views/message/PartDisclosure.d.ts +1 -2
- package/dist/views/message/PartDisclosure.d.ts.map +1 -1
- package/dist/views/message/PartView.d.ts +1 -2
- package/dist/views/message/PartView.d.ts.map +1 -1
- package/dist/views/message/RoleLabel.d.ts +1 -2
- package/dist/views/message/RoleLabel.d.ts.map +1 -1
- package/dist/views/message/StatusBadge.d.ts +1 -2
- package/dist/views/message/StatusBadge.d.ts.map +1 -1
- package/dist/views/message/ToolCallView.d.ts +1 -2
- package/dist/views/message/ToolCallView.d.ts.map +1 -1
- package/dist/views/message/TtftBar.d.ts +2 -3
- package/dist/views/message/TtftBar.d.ts.map +1 -1
- package/dist/views/message/TtftBar.js +2 -2
- package/dist/views/message/TtftBar.js.map +1 -1
- package/dist/views/message/messagePlainText.d.ts +1 -2
- package/dist/views/message/messagePlainText.d.ts.map +1 -1
- package/dist/views/message/parse.d.ts +4 -5
- package/dist/views/message/parse.d.ts.map +1 -1
- package/dist/views/message/types.d.ts +17 -18
- package/dist/views/message/types.d.ts.map +1 -1
- package/dist/views/model-context/ModelContextView.d.ts +4 -5
- package/dist/views/model-context/ModelContextView.d.ts.map +1 -1
- package/dist/views/nav.d.ts +4 -5
- package/dist/views/nav.d.ts.map +1 -1
- package/dist/views/raw/RawDetail.d.ts +1 -2
- package/dist/views/raw/RawDetail.d.ts.map +1 -1
- package/dist/views/raw/rawNodes.d.ts +4 -5
- package/dist/views/raw/rawNodes.d.ts.map +1 -1
- package/dist/views/raw/statePreview.d.ts +1 -2
- package/dist/views/raw/statePreview.d.ts.map +1 -1
- package/dist/views/runs/PayloadDiff.d.ts +1 -2
- package/dist/views/runs/PayloadDiff.d.ts.map +1 -1
- package/dist/views/runs/parse.d.ts +1 -2
- package/dist/views/runs/parse.d.ts.map +1 -1
- package/dist/views/runs/types.d.ts +3 -4
- package/dist/views/runs/types.d.ts.map +1 -1
- package/dist/views/scopes/ScopesView.d.ts +1 -2
- package/dist/views/scopes/ScopesView.d.ts.map +1 -1
- package/dist/views/scopes/parse.d.ts +1 -2
- package/dist/views/scopes/parse.d.ts.map +1 -1
- package/dist/views/scopes/types.d.ts +1 -2
- package/dist/views/scopes/types.d.ts.map +1 -1
- package/dist/views/thread/ComposerAttachments.d.ts +1 -2
- package/dist/views/thread/ComposerAttachments.d.ts.map +1 -1
- package/dist/views/thread/ComposerFlags.d.ts +1 -2
- package/dist/views/thread/ComposerFlags.d.ts.map +1 -1
- package/dist/views/thread/ComposerQueue.d.ts +1 -2
- package/dist/views/thread/ComposerQueue.d.ts.map +1 -1
- package/dist/views/thread/ConversationList.d.ts +2 -3
- package/dist/views/thread/ConversationList.d.ts.map +1 -1
- package/dist/views/thread/ConversationStatusDot.d.ts +3 -4
- package/dist/views/thread/ConversationStatusDot.d.ts.map +1 -1
- package/dist/views/thread/ThreadDetails.d.ts +1 -2
- package/dist/views/thread/ThreadDetails.d.ts.map +1 -1
- package/dist/views/thread/Transcript.d.ts +3 -4
- package/dist/views/thread/Transcript.d.ts.map +1 -1
- package/dist/views/thread/Transcript.js +1 -1
- package/dist/views/thread/Transcript.js.map +1 -1
- package/dist/views/thread/resolve.d.ts +2 -3
- package/dist/views/thread/resolve.d.ts.map +1 -1
- package/dist/views/thread/types.d.ts +7 -7
- package/dist/views/thread/types.d.ts.map +1 -1
- package/dist/views/thread/utils.d.ts +4 -5
- package/dist/views/thread/utils.d.ts.map +1 -1
- package/dist/views/ui/CenteredMessage.d.ts +1 -2
- package/dist/views/ui/CenteredMessage.d.ts.map +1 -1
- package/dist/views/ui/Chip.d.ts +1 -2
- package/dist/views/ui/Chip.d.ts.map +1 -1
- package/dist/views/ui/ControlButton.d.ts +1 -2
- package/dist/views/ui/ControlButton.d.ts.map +1 -1
- package/dist/views/ui/CopyButton.d.ts +2 -3
- package/dist/views/ui/CopyButton.d.ts.map +1 -1
- package/dist/views/ui/CopyButton.js +3 -3
- package/dist/views/ui/CopyButton.js.map +1 -1
- package/dist/views/ui/EmptyState.d.ts +1 -2
- package/dist/views/ui/EmptyState.d.ts.map +1 -1
- package/dist/views/ui/InfoCard.d.ts +1 -2
- package/dist/views/ui/InfoCard.d.ts.map +1 -1
- package/dist/views/ui/JSONTree.d.ts +1 -2
- package/dist/views/ui/JSONTree.d.ts.map +1 -1
- package/dist/views/ui/JSONTree.js +51 -72
- package/dist/views/ui/JSONTree.js.map +1 -1
- package/dist/views/ui/PaneHeader.d.ts +2 -3
- package/dist/views/ui/PaneHeader.d.ts.map +1 -1
- package/dist/views/ui/RailTime.d.ts +1 -2
- package/dist/views/ui/RailTime.d.ts.map +1 -1
- package/dist/views/ui/SectionLabel.d.ts +1 -2
- package/dist/views/ui/SectionLabel.d.ts.map +1 -1
- package/dist/views/ui/SelectableRow.d.ts +1 -2
- package/dist/views/ui/SelectableRow.d.ts.map +1 -1
- package/dist/views/ui/Sparkline.d.ts +1 -2
- package/dist/views/ui/Sparkline.d.ts.map +1 -1
- package/dist/views/ui/SummaryItem.d.ts +2 -3
- package/dist/views/ui/SummaryItem.d.ts.map +1 -1
- package/dist/views/ui/ToneBadge.d.ts +1 -2
- package/dist/views/ui/ToneBadge.d.ts.map +1 -1
- package/dist/views/ui/badgeTone.d.ts +7 -8
- package/dist/views/ui/badgeTone.d.ts.map +1 -1
- package/dist/views/ui/getInlineJson.d.ts +4 -0
- package/dist/views/ui/getInlineJson.d.ts.map +1 -0
- package/dist/views/ui/getInlineJson.js +22 -0
- package/dist/views/ui/getInlineJson.js.map +1 -0
- package/dist/views/ui/useCopyToClipboard.d.ts +1 -2
- package/dist/views/ui/useCopyToClipboard.d.ts.map +1 -1
- package/package.json +16 -13
- package/src/data/createInProcessClient.ts +1 -3
- package/src/styles/panel.generated.ts +1 -1
- package/src/utils/serialization.test.ts +19 -0
- package/src/utils/serialization.ts +32 -7
- package/src/views/message/MessageMetrics.tsx +2 -3
- package/src/views/message/TtftBar.test.tsx +20 -0
- package/src/views/message/TtftBar.tsx +2 -2
- package/src/views/thread/Transcript.tsx +1 -6
- package/src/views/ui/CopyButton.tsx +3 -3
- package/src/views/ui/JSONTree.interaction.test.tsx +118 -0
- package/src/views/ui/JSONTree.tsx +29 -28
- package/src/views/ui/JSONTree.work.test.tsx +60 -0
- package/src/views/ui/getInlineJson.test.ts +83 -0
- package/src/views/ui/getInlineJson.ts +38 -0
|
@@ -3,6 +3,23 @@ import type { SerializedModelContext } from "../types";
|
|
|
3
3
|
import { normalizeToolList, type NormalizedTool } from "./toolNormalization";
|
|
4
4
|
import { readProperty, UNSERIALIZABLE } from "./unserializable";
|
|
5
5
|
|
|
6
|
+
const setOwnProperty = (
|
|
7
|
+
target: Record<string, unknown>,
|
|
8
|
+
key: string,
|
|
9
|
+
value: unknown,
|
|
10
|
+
): void => {
|
|
11
|
+
if (key === "__proto__") {
|
|
12
|
+
Object.defineProperty(target, key, {
|
|
13
|
+
value,
|
|
14
|
+
enumerable: true,
|
|
15
|
+
configurable: true,
|
|
16
|
+
writable: true,
|
|
17
|
+
});
|
|
18
|
+
} else {
|
|
19
|
+
target[key] = value;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
6
23
|
export const sanitizeForMessage = (
|
|
7
24
|
value: unknown,
|
|
8
25
|
seen = new WeakSet<object>(),
|
|
@@ -54,7 +71,11 @@ export const sanitizeForMessage = (
|
|
|
54
71
|
nextSuffixByKey.set(serializedKey, 2);
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
|
|
74
|
+
setOwnProperty(
|
|
75
|
+
result,
|
|
76
|
+
serializedKey,
|
|
77
|
+
sanitizeForMessage(entry, seen),
|
|
78
|
+
);
|
|
58
79
|
}
|
|
59
80
|
return result;
|
|
60
81
|
}
|
|
@@ -81,12 +102,13 @@ export const sanitizeForMessage = (
|
|
|
81
102
|
const result: Record<string, unknown> = {};
|
|
82
103
|
for (const key of Object.keys(value)) {
|
|
83
104
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
105
|
+
setOwnProperty(
|
|
106
|
+
result,
|
|
107
|
+
key,
|
|
108
|
+
sanitizeForMessage((value as Record<string, unknown>)[key], seen),
|
|
87
109
|
);
|
|
88
110
|
} catch {
|
|
89
|
-
result
|
|
111
|
+
setOwnProperty(result, key, UNSERIALIZABLE);
|
|
90
112
|
}
|
|
91
113
|
}
|
|
92
114
|
return result;
|
|
@@ -149,10 +171,13 @@ export const redactSensitive = (value: unknown, maskAll = false): unknown => {
|
|
|
149
171
|
value as Record<string, unknown>,
|
|
150
172
|
)) {
|
|
151
173
|
const normalized = normalizeKey(key);
|
|
152
|
-
|
|
174
|
+
setOwnProperty(
|
|
175
|
+
result,
|
|
176
|
+
key,
|
|
153
177
|
maskAll || SENSITIVE_KEYS.has(normalized)
|
|
154
178
|
? REDACTED
|
|
155
|
-
: redactSensitive(entry, MASK_ALL_KEYS.has(normalized))
|
|
179
|
+
: redactSensitive(entry, MASK_ALL_KEYS.has(normalized)),
|
|
180
|
+
);
|
|
156
181
|
}
|
|
157
182
|
return result;
|
|
158
183
|
}
|
|
@@ -16,9 +16,8 @@ const inlineStats = (message: MessagePreview): string[] => {
|
|
|
16
16
|
|
|
17
17
|
if (timing && !canRenderBar(timing)) {
|
|
18
18
|
const ttft =
|
|
19
|
-
timing.firstTokenTime !== undefined
|
|
20
|
-
|
|
21
|
-
? Math.max(0, timing.firstTokenTime - timing.streamStartTime)
|
|
19
|
+
timing.firstTokenTime !== undefined
|
|
20
|
+
? Math.max(0, timing.firstTokenTime)
|
|
22
21
|
: undefined;
|
|
23
22
|
if (ttft !== undefined) stats.push(`TTFT ${formatMs(ttft)}`);
|
|
24
23
|
if (timing.totalStreamTime !== undefined) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { TtftBar } from "./TtftBar";
|
|
4
|
+
|
|
5
|
+
describe("TtftBar", () => {
|
|
6
|
+
it("reads the first token time as a duration from the stream start", () => {
|
|
7
|
+
const html = renderToStaticMarkup(
|
|
8
|
+
<TtftBar
|
|
9
|
+
timing={{
|
|
10
|
+
streamStartTime: 1_700_000_000_000,
|
|
11
|
+
firstTokenTime: 150,
|
|
12
|
+
totalStreamTime: 900,
|
|
13
|
+
}}
|
|
14
|
+
/>,
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
expect(html).toContain("TTFT 150ms");
|
|
18
|
+
expect(html).toContain("750ms stream");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -4,7 +4,7 @@ import type { MessageTimingPreview } from "./types";
|
|
|
4
4
|
/**
|
|
5
5
|
* Splits a model turn into the time-to-first-token segment (dim) and the
|
|
6
6
|
* streaming segment (solid). The only width-bearing timing form: both numbers
|
|
7
|
-
* are measured, not derived. Renders nothing unless all three
|
|
7
|
+
* are measured, not derived. Renders nothing unless all three timing values exist.
|
|
8
8
|
*/
|
|
9
9
|
export const TtftBar = ({ timing }: { timing: MessageTimingPreview }) => {
|
|
10
10
|
const { streamStartTime, firstTokenTime, totalStreamTime } = timing;
|
|
@@ -16,7 +16,7 @@ export const TtftBar = ({ timing }: { timing: MessageTimingPreview }) => {
|
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const ttft = Math.max(0, firstTokenTime
|
|
19
|
+
const ttft = Math.max(0, firstTokenTime);
|
|
20
20
|
const total = Math.max(totalStreamTime, ttft);
|
|
21
21
|
const stream = Math.max(0, total - ttft);
|
|
22
22
|
const ttftPercent = total > 0 ? (ttft / total) * 100 : 0;
|
|
@@ -169,12 +169,7 @@ const metricSeries = (messages: readonly MessagePreview[]): MetricSeries[] =>
|
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
label: "TTFT",
|
|
172
|
-
values: collect(messages, (message) =>
|
|
173
|
-
message.timing?.firstTokenTime !== undefined &&
|
|
174
|
-
message.timing?.streamStartTime !== undefined
|
|
175
|
-
? message.timing.firstTokenTime - message.timing.streamStartTime
|
|
176
|
-
: undefined,
|
|
177
|
-
),
|
|
172
|
+
values: collect(messages, (message) => message.timing?.firstTokenTime),
|
|
178
173
|
format: (value: number) => `${Math.round(value)}ms`,
|
|
179
174
|
},
|
|
180
175
|
{
|
|
@@ -35,7 +35,7 @@ export const CopyButton = ({
|
|
|
35
35
|
iconOnly = false,
|
|
36
36
|
size = "default",
|
|
37
37
|
}: {
|
|
38
|
-
value: string;
|
|
38
|
+
value: string | (() => string);
|
|
39
39
|
label?: string;
|
|
40
40
|
copiedLabel?: string;
|
|
41
41
|
className?: string | undefined;
|
|
@@ -43,7 +43,7 @@ export const CopyButton = ({
|
|
|
43
43
|
size?: "default" | "sm";
|
|
44
44
|
}) => {
|
|
45
45
|
const { isCopied, copy } = useCopyToClipboard();
|
|
46
|
-
const empty = !value.trim();
|
|
46
|
+
const empty = typeof value === "string" && !value.trim();
|
|
47
47
|
const iconPx = size === "sm" ? 10 : 12;
|
|
48
48
|
|
|
49
49
|
return (
|
|
@@ -54,7 +54,7 @@ export const CopyButton = ({
|
|
|
54
54
|
title={isCopied ? copiedLabel : label}
|
|
55
55
|
onClick={(event) => {
|
|
56
56
|
event.stopPropagation();
|
|
57
|
-
copy(value);
|
|
57
|
+
copy(typeof value === "function" ? value() : value);
|
|
58
58
|
}}
|
|
59
59
|
className={clsx(
|
|
60
60
|
"text-muted-foreground hover:text-foreground inline-flex shrink-0 items-center justify-center rounded transition-colors disabled:pointer-events-none disabled:opacity-40",
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/** @vitest-environment jsdom */
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { JSONTree } from "./JSONTree";
|
|
6
|
+
import { CopyButton } from "./CopyButton";
|
|
7
|
+
|
|
8
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
9
|
+
|
|
10
|
+
describe("JSONTree interactions", () => {
|
|
11
|
+
let container: HTMLDivElement;
|
|
12
|
+
let root: Root;
|
|
13
|
+
let writeText: ReturnType<typeof vi.fn<(text: string) => Promise<void>>>;
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
container = document.createElement("div");
|
|
16
|
+
document.body.appendChild(container);
|
|
17
|
+
root = createRoot(container);
|
|
18
|
+
writeText = vi.fn(async () => {});
|
|
19
|
+
vi.stubGlobal("navigator", { clipboard: { writeText } });
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
});
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
await act(async () => root.unmount());
|
|
24
|
+
container.remove();
|
|
25
|
+
vi.useRealTimers();
|
|
26
|
+
vi.unstubAllGlobals();
|
|
27
|
+
vi.restoreAllMocks();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const button = (text: string) => {
|
|
31
|
+
const result = Array.from(container.querySelectorAll("button")).find(
|
|
32
|
+
(node) =>
|
|
33
|
+
node.textContent?.includes(text) ||
|
|
34
|
+
node.getAttribute("aria-label") === text,
|
|
35
|
+
);
|
|
36
|
+
if (!result) throw new Error(`Missing button: ${text}`);
|
|
37
|
+
return result;
|
|
38
|
+
};
|
|
39
|
+
const click = (text: string) => act(async () => button(text).click());
|
|
40
|
+
|
|
41
|
+
it.each([false, true])(
|
|
42
|
+
"expands, reveals remaining entries and collapses in compact=%s",
|
|
43
|
+
async (compact) => {
|
|
44
|
+
const value = Array.from({ length: 103 }, (_, i) => `entry-${i}`);
|
|
45
|
+
await act(async () =>
|
|
46
|
+
root.render(<JSONTree value={value} openDepth={0} compact={compact} />),
|
|
47
|
+
);
|
|
48
|
+
expect(container.textContent).not.toContain("entry-");
|
|
49
|
+
await click("[103]");
|
|
50
|
+
expect(container.textContent).toContain("entry-99");
|
|
51
|
+
expect(container.textContent).not.toContain("entry-100");
|
|
52
|
+
await click("+3 more");
|
|
53
|
+
expect(container.textContent).toContain("entry-102");
|
|
54
|
+
await click("[103]");
|
|
55
|
+
expect(container.textContent).not.toContain("entry-");
|
|
56
|
+
await click("[103]");
|
|
57
|
+
expect(container.textContent).toContain("entry-102");
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
it("copies all hidden entries and uses the latest value without expanding", async () => {
|
|
62
|
+
let value = Array.from({ length: 103 }, (_, i) => ({ id: i }));
|
|
63
|
+
await act(async () =>
|
|
64
|
+
root.render(<JSONTree value={value} openDepth={0} />),
|
|
65
|
+
);
|
|
66
|
+
await click("Copy JSON");
|
|
67
|
+
expect(writeText).toHaveBeenLastCalledWith(JSON.stringify(value, null, 2));
|
|
68
|
+
expect(button("Copied")).toBeDefined();
|
|
69
|
+
value = [...value, { id: 103 }];
|
|
70
|
+
await act(async () =>
|
|
71
|
+
root.render(<JSONTree value={value} openDepth={0} />),
|
|
72
|
+
);
|
|
73
|
+
await click("Copied");
|
|
74
|
+
expect(writeText).toHaveBeenLastCalledWith(JSON.stringify(value, null, 2));
|
|
75
|
+
expect(container.textContent).not.toContain("id");
|
|
76
|
+
await act(async () => {
|
|
77
|
+
vi.advanceTimersByTime(2000);
|
|
78
|
+
});
|
|
79
|
+
expect(button("Copy JSON")).toBeDefined();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("keeps nested nodes collapsed until requested", async () => {
|
|
83
|
+
await act(async () =>
|
|
84
|
+
root.render(<JSONTree value={{ nested: { message: "inside" } }} />),
|
|
85
|
+
);
|
|
86
|
+
expect(container.textContent).toContain("nested");
|
|
87
|
+
expect(container.textContent).not.toContain("inside");
|
|
88
|
+
await click("nested");
|
|
89
|
+
expect(container.textContent).toContain("inside");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("keeps compact small values inline and copies formatted JSON", async () => {
|
|
93
|
+
const value = { query: "hello" };
|
|
94
|
+
await act(async () => root.render(<JSONTree value={value} compact />));
|
|
95
|
+
expect(container.querySelector("code")?.textContent).toBe(
|
|
96
|
+
JSON.stringify(value),
|
|
97
|
+
);
|
|
98
|
+
await click("Copy JSON");
|
|
99
|
+
expect(writeText).toHaveBeenCalledWith(JSON.stringify(value, null, 2));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps empty string copy controls disabled and evaluates lazy values only on click", async () => {
|
|
103
|
+
const getValue = vi.fn(() => "lazy value");
|
|
104
|
+
await act(async () =>
|
|
105
|
+
root.render(
|
|
106
|
+
<>
|
|
107
|
+
<CopyButton value=" " label="Empty" />
|
|
108
|
+
<CopyButton value={getValue} label="Lazy" />
|
|
109
|
+
</>,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
expect(button("Empty").disabled).toBe(true);
|
|
113
|
+
expect(getValue).not.toHaveBeenCalled();
|
|
114
|
+
await click("Lazy");
|
|
115
|
+
expect(getValue).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(writeText).toHaveBeenCalledWith("lazy value");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { useMemo, useState } from "react";
|
|
1
|
+
import { useCallback, useMemo, useState } from "react";
|
|
2
2
|
import clsx from "clsx";
|
|
3
3
|
import { CopyButton } from "./CopyButton";
|
|
4
|
+
import { getInlineJson } from "./getInlineJson";
|
|
4
5
|
|
|
5
6
|
const ENTRY_LIMIT = 100;
|
|
6
7
|
const INLINE_MAX = 88;
|
|
@@ -10,29 +11,20 @@ const isContainer = (value: unknown): value is object =>
|
|
|
10
11
|
|
|
11
12
|
type Entry = { key: string; value: unknown };
|
|
12
13
|
|
|
13
|
-
const entriesOf = (value:
|
|
14
|
+
const entriesOf = (value: object, keys: string[], limit: number): Entry[] => {
|
|
14
15
|
if (Array.isArray(value)) {
|
|
15
|
-
return value
|
|
16
|
+
return value
|
|
17
|
+
.slice(0, limit)
|
|
18
|
+
.map((item, index) => ({ key: String(index), value: item }));
|
|
16
19
|
}
|
|
17
|
-
return
|
|
18
|
-
(
|
|
19
|
-
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const summaryOf = (value: unknown): string => {
|
|
23
|
-
if (Array.isArray(value)) return `[${value.length}]`;
|
|
24
|
-
return `{${Object.keys(value as object).length}}`;
|
|
20
|
+
return keys
|
|
21
|
+
.slice(0, limit)
|
|
22
|
+
.map((key) => ({ key, value: (value as Record<string, unknown>)[key] }));
|
|
25
23
|
};
|
|
26
24
|
|
|
27
25
|
const serialize = (value: unknown, compact: boolean) =>
|
|
28
26
|
JSON.stringify(value, null, compact ? 0 : 2);
|
|
29
27
|
|
|
30
|
-
const isInline = (value: unknown, compact: boolean) => {
|
|
31
|
-
if (!compact) return false;
|
|
32
|
-
if (!isContainer(value)) return true;
|
|
33
|
-
return serialize(value, true).length <= INLINE_MAX;
|
|
34
|
-
};
|
|
35
|
-
|
|
36
28
|
const Leaf = ({ value }: { value: unknown }) => {
|
|
37
29
|
if (typeof value === "string") {
|
|
38
30
|
return (
|
|
@@ -78,9 +70,12 @@ const TreeNode = ({
|
|
|
78
70
|
);
|
|
79
71
|
}
|
|
80
72
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
const
|
|
73
|
+
const keys = Array.isArray(value) ? [] : Object.keys(value);
|
|
74
|
+
const count = Array.isArray(value) ? value.length : keys.length;
|
|
75
|
+
const shown = open
|
|
76
|
+
? entriesOf(value, keys, showAll ? count : ENTRY_LIMIT)
|
|
77
|
+
: [];
|
|
78
|
+
const hidden = count - shown.length;
|
|
84
79
|
|
|
85
80
|
return (
|
|
86
81
|
<div className="min-w-0">
|
|
@@ -101,7 +96,9 @@ const TreeNode = ({
|
|
|
101
96
|
›
|
|
102
97
|
</span>
|
|
103
98
|
{name !== undefined ? <span>{name}</span> : null}
|
|
104
|
-
<span className="opacity-60">
|
|
99
|
+
<span className="opacity-60">
|
|
100
|
+
{Array.isArray(value) ? `[${count}]` : `{${count}}`}
|
|
101
|
+
</span>
|
|
105
102
|
</button>
|
|
106
103
|
{open ? (
|
|
107
104
|
<div
|
|
@@ -141,7 +138,7 @@ const InlineJson = ({
|
|
|
141
138
|
compact,
|
|
142
139
|
}: {
|
|
143
140
|
serialized: string;
|
|
144
|
-
copyText: string;
|
|
141
|
+
copyText: () => string;
|
|
145
142
|
compact: boolean;
|
|
146
143
|
}) => (
|
|
147
144
|
<div className="flex min-w-0 items-start gap-0.5">
|
|
@@ -171,7 +168,7 @@ const TreeWithCopy = ({
|
|
|
171
168
|
}: {
|
|
172
169
|
value: unknown;
|
|
173
170
|
openDepth: number;
|
|
174
|
-
copyText: string;
|
|
171
|
+
copyText: () => string;
|
|
175
172
|
compact: boolean;
|
|
176
173
|
}) => (
|
|
177
174
|
<div className="flex min-w-0 items-start gap-0.5">
|
|
@@ -212,14 +209,18 @@ export const JSONTree = ({
|
|
|
212
209
|
openDepth?: number;
|
|
213
210
|
compact?: boolean;
|
|
214
211
|
}) => {
|
|
215
|
-
const isLeaf = isInline(value, compact) || !isContainer(value);
|
|
216
212
|
const serialized = useMemo(
|
|
217
|
-
() =>
|
|
218
|
-
|
|
213
|
+
() =>
|
|
214
|
+
!isContainer(value)
|
|
215
|
+
? serialize(value, compact)
|
|
216
|
+
: compact
|
|
217
|
+
? getInlineJson(value, INLINE_MAX)
|
|
218
|
+
: undefined,
|
|
219
|
+
[value, compact],
|
|
219
220
|
);
|
|
220
|
-
const copyText =
|
|
221
|
+
const copyText = useCallback(() => JSON.stringify(value, null, 2), [value]);
|
|
221
222
|
|
|
222
|
-
if (
|
|
223
|
+
if (serialized !== undefined) {
|
|
223
224
|
return (
|
|
224
225
|
<InlineJson
|
|
225
226
|
serialized={serialized}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { createRenderCounter } from "@assistant-ui/x-performance";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { JSONTree } from "./JSONTree";
|
|
5
|
+
|
|
6
|
+
afterEach(() => vi.restoreAllMocks());
|
|
7
|
+
|
|
8
|
+
function trackedValue(size: number, array: boolean) {
|
|
9
|
+
const counter = createRenderCounter();
|
|
10
|
+
const value: Record<string, unknown> | unknown[] = array ? [] : {};
|
|
11
|
+
for (let index = 0; index < size; index++) {
|
|
12
|
+
Object.defineProperty(value, String(index), {
|
|
13
|
+
enumerable: true,
|
|
14
|
+
get: () => {
|
|
15
|
+
counter.useRender("entry");
|
|
16
|
+
return index;
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
const stringify = JSON.stringify;
|
|
21
|
+
vi.spyOn(JSON, "stringify").mockImplementation((...args) => {
|
|
22
|
+
if (args[0] === value) counter.useRender("serialization");
|
|
23
|
+
return Reflect.apply(stringify, JSON, args);
|
|
24
|
+
});
|
|
25
|
+
return { value, counter };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("JSONTree work bounds", () => {
|
|
29
|
+
it.each([false, true])(
|
|
30
|
+
"does not read a collapsed array in compact=%s",
|
|
31
|
+
(compact) => {
|
|
32
|
+
const { value, counter } = trackedValue(10_000, true);
|
|
33
|
+
const html = renderToStaticMarkup(
|
|
34
|
+
<JSONTree value={value} openDepth={0} compact={compact} />,
|
|
35
|
+
);
|
|
36
|
+
expect(html).toContain("[10000]");
|
|
37
|
+
expect(counter.renders("entry")).toBe(0);
|
|
38
|
+
expect(counter.renders("serialization")).toBe(0);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it("does not read a collapsed object's values", () => {
|
|
43
|
+
const { value, counter } = trackedValue(10_000, false);
|
|
44
|
+
const html = renderToStaticMarkup(<JSONTree value={value} openDepth={0} />);
|
|
45
|
+
expect(html).toContain("{10000}");
|
|
46
|
+
expect(counter.renders("entry")).toBe(0);
|
|
47
|
+
expect(counter.renders("serialization")).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each([false, true])(
|
|
51
|
+
"reads only the first 100 entries for array=%s",
|
|
52
|
+
(array) => {
|
|
53
|
+
const { value, counter } = trackedValue(10_000, array);
|
|
54
|
+
const html = renderToStaticMarkup(<JSONTree value={value} />);
|
|
55
|
+
expect(html).toContain("9900");
|
|
56
|
+
expect(counter.renders("entry")).toBe(100);
|
|
57
|
+
expect(counter.renders("serialization")).toBe(0);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { createRenderCounter } from "@assistant-ui/x-performance";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { getInlineJson } from "./getInlineJson";
|
|
4
|
+
|
|
5
|
+
describe("getInlineJson", () => {
|
|
6
|
+
it("preserves JSON output and the exact inline length boundary", () => {
|
|
7
|
+
const values: unknown[] = [
|
|
8
|
+
null,
|
|
9
|
+
true,
|
|
10
|
+
false,
|
|
11
|
+
0,
|
|
12
|
+
NaN,
|
|
13
|
+
Infinity,
|
|
14
|
+
{},
|
|
15
|
+
[],
|
|
16
|
+
undefined,
|
|
17
|
+
];
|
|
18
|
+
for (const size of [0, 1, 20, 42, 43, 44, 85, 86, 87, 88, 89, 100]) {
|
|
19
|
+
for (const char of ["x", "\n", '"', "\\", "😀", "\ud800"]) {
|
|
20
|
+
const text = char.repeat(size);
|
|
21
|
+
values.push(text, { text }, { nested: { text } }, [text]);
|
|
22
|
+
}
|
|
23
|
+
values.push(
|
|
24
|
+
Array(size).fill(0),
|
|
25
|
+
Array(size),
|
|
26
|
+
Object.fromEntries(
|
|
27
|
+
Array.from({ length: size }, (_, i) => [String(i), undefined]),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
values.push({
|
|
32
|
+
omitted: undefined,
|
|
33
|
+
fn: () => {},
|
|
34
|
+
symbol: Symbol(),
|
|
35
|
+
kept: "yes",
|
|
36
|
+
});
|
|
37
|
+
values.push(new Date("2026-01-01T00:00:00.000Z"));
|
|
38
|
+
values.push({ toJSON: () => ({ short: true }) });
|
|
39
|
+
const customArray = Object.assign(Array(100).fill(0), {
|
|
40
|
+
toJSON: () => "short",
|
|
41
|
+
});
|
|
42
|
+
values.push(customArray);
|
|
43
|
+
for (const value of values) {
|
|
44
|
+
const serialized = JSON.stringify(value);
|
|
45
|
+
expect(getInlineJson(value, 88)).toBe(
|
|
46
|
+
serialized !== undefined && serialized.length <= 88
|
|
47
|
+
? serialized
|
|
48
|
+
: undefined,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("stops reading a large object after the preview budget is exhausted", () => {
|
|
54
|
+
const counter = createRenderCounter();
|
|
55
|
+
const value: Record<string, number> = {};
|
|
56
|
+
for (let index = 0; index < 10_000; index++) {
|
|
57
|
+
Object.defineProperty(value, `row${index}`, {
|
|
58
|
+
enumerable: true,
|
|
59
|
+
get: () => {
|
|
60
|
+
counter.useRender("entry");
|
|
61
|
+
return index;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
expect(getInlineJson(value, 88)).toBeUndefined();
|
|
66
|
+
expect(counter.renders("entry")).toBe(17);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not turn unrelated serialization errors into a large preview", () => {
|
|
70
|
+
const failure = new Error("serialization failed");
|
|
71
|
+
expect(() =>
|
|
72
|
+
getInlineJson(
|
|
73
|
+
{
|
|
74
|
+
toJSON: () => {
|
|
75
|
+
throw failure;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
88,
|
|
79
|
+
),
|
|
80
|
+
).toThrow(failure);
|
|
81
|
+
expect(() => getInlineJson({ value: 1n }, 88)).toThrow(TypeError);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
const TOO_LARGE = Symbol();
|
|
2
|
+
|
|
3
|
+
export function getInlineJson(value: unknown, limit: number) {
|
|
4
|
+
if (
|
|
5
|
+
Array.isArray(value) &&
|
|
6
|
+
!("toJSON" in value) &&
|
|
7
|
+
value.length * 2 + 1 > limit
|
|
8
|
+
) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let remaining = limit;
|
|
13
|
+
try {
|
|
14
|
+
const serialized = JSON.stringify(value, function (key, entry: unknown) {
|
|
15
|
+
if (
|
|
16
|
+
!Array.isArray(this) &&
|
|
17
|
+
(entry === undefined ||
|
|
18
|
+
typeof entry === "function" ||
|
|
19
|
+
typeof entry === "symbol")
|
|
20
|
+
) {
|
|
21
|
+
return entry;
|
|
22
|
+
}
|
|
23
|
+
// This lower bound stops large values without replacing JSON escaping or omission rules.
|
|
24
|
+
remaining -=
|
|
25
|
+
1 +
|
|
26
|
+
(Array.isArray(this) ? 0 : key.length) +
|
|
27
|
+
(typeof entry === "string" ? entry.length : 0);
|
|
28
|
+
if (remaining < 0) throw TOO_LARGE;
|
|
29
|
+
return entry;
|
|
30
|
+
});
|
|
31
|
+
return serialized !== undefined && serialized.length <= limit
|
|
32
|
+
? serialized
|
|
33
|
+
: undefined;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error === TOO_LARGE) return undefined;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|