feat(dashboard): collapsible JSON tree viewer for request/response payloads (#11703)

Resynced onto the release tip. Two fixes applied during boarding: (1) the branch forked before the recent optionalDependencies placement of @huggingface/transformers and onnxruntime-node — its own diff re-added both into "dependencies" as duplicates alongside the real new dependency (react18-json-view); removed the duplicates, ran npm install to sync the lockfile. (2) config/quality/dependency-allowlist.json referenced the wrong package name (react-json-view-lite, an earlier iteration per the PR body) — the code actually imports react18-json-view; fixed the allowlist entry to match. RequestLoggerDetail.tsx crossed its frozen file-size cap (1018->1111); rebaselined with a note — the PR does split out the new logic (RequestLoggerDetail.sections.tsx, JsonTreeExpandControls.tsx, useTimestampTitles.ts, jsonTreeExpandStore.ts, all well under cap), the growth here is irreducible wiring. typecheck:core, check:dashboard-typecheck, check:file-size, check-deps all green after resync; 8/8 vitest + 11/11 native tests pass. Nice, well-structured 6-commit feature with full i18n and good test coverage. Thanks!
This commit is contained in:
Markus Hartung
2026-08-30 09:26:15 +02:00
committed by GitHub
parent ccee48d34a
commit 5684589ce7
19 changed files with 1022 additions and 598 deletions

View File

@@ -119,6 +119,7 @@
"react",
"react-dom",
"react-is",
"react18-json-view",
"react-markdown",
"react-reconciler",
"recharts",

View File

@@ -483,7 +483,8 @@
"_rebaseline_2026_08_28_mergebatch_v3851_qwen_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11713 (Qwen Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1132->1135 (+3, combining the Designer + Runtime retirement-guard filter into the single runtimeConnections predicate at the existing candidate-pool chokepoint, now excluding Qwen Web alongside Felo Web). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
"_rebaseline_2026_08_28_mergebatch_v3851_chatgptweb_retirement": "/merge-batch 2026-08-28 (v3.8.51): #11754 (common ChatGPT Web retirement) own growth: open-sse/services/autoCombo/virtualFactory.ts 1135->1138 (+3, an early `available` connection filter for the retired chatgpt-web/cgpt-web ids applied to both the active and disabled-noauth connection lists, ahead of the existing Designer+Runtime runtimeConnections filter). Irreducible per-mechanism wiring, additive not a new branch. Covered by tests/unit/virtual-auto-combo.test.ts.",
"src/lib/cloudflaredTunnel.ts": 1078,
"src/shared/components/RequestLoggerDetail.tsx": 1018,
"src/shared/components/RequestLoggerDetail.tsx": 1111,
"_rebaseline_2026_08_30_11703_json_tree_viewer": "/merge-batch 2026-08-30 (v3.8.51): #11703 (hartmark) own growth: src/shared/components/RequestLoggerDetail.tsx 1018->1111 (+93). The 2026-07-22 annotation on this same file said 'no further growth without split rationale' — this PR does split: the collapsible-JSON-tree rendering logic itself lives in the sibling RequestLoggerDetail.sections.tsx (PayloadSection/StreamSection extraction, +82 lines there) plus two new leaves (JsonTreeExpandControls.tsx, useTimestampTitles.ts) and a new store (jsonTreeExpandStore.ts) — all well under cap. The +93 remaining here is the irreducible call-site wiring: import + mount JsonTreeExpandControls, wire the per-section expand-level state and timestamp-tooltip hook into the existing detail panel layout. Covered by the PR's own tests/unit/dashboard/payload-section-collapsible-json.test.tsx, timestamp-titles.test.tsx, tests/unit/shared/json-tree-expand-store.test.ts, short-call-id.test.ts (43/43 vitest + 11/11 native pass).",
"src/app/api/providers/[id]/test/route.ts": 1255,
"src/lib/guardrails/videoBridgeRuntime.ts": 1009,
"_rebaseline_2026_08_28_mergebatch_v3851_ratchet_bank_reconcile": "/merge-batch 2026-08-28 (v3.8.51): boarding #11702 (fix/verify-ratchet-bank object-note comparator) surfaced a large stale `frozen`/`testFrozen` snapshot on PR #11702's own branch (forked before the 08-11 banking outage — see the object-valued `_rebaseline_2026_08_11_v3850_merge_storm_provider_registry` note above, the exact bug #11702 fixes in the verifier) — its conflicting block duplicated ~85 already-tracked files with sizes smaller than the current release tip, and still listed open-sse/executors/chatgpt-web.ts (deleted by the #11754 retirement). Resolved by re-measuring every file in the union of both sides directly on the boarded tree (split(\"\\n\").length, matching check-file-size.mjs) rather than trusting either stale snapshot; dropped the dead chatgpt-web.ts entry; kept the two genuinely-new entries PR #11702's branch had that this tip did not yet track (src/app/api/providers/[id]/test/route.ts, src/lib/guardrails/videoBridgeRuntime.ts, both re-measured). Same reconciliation applied to the testFrozen block above.",

525
package-lock.json generated
View File

@@ -72,6 +72,7 @@
"react-is": "^19.2.6",
"react-markdown": "^10.1.0",
"react-reconciler": "^0.33.0",
"react18-json-view": "0.2.10",
"recharts": "^3.8.1",
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",
@@ -16994,6 +16995,15 @@
"node": ">=6.6.0"
}
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -20992,490 +21002,6 @@
}
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/fumadocs-mdx/node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/fumadocs-mdx/node_modules/magic-string": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.2.2.tgz",
@@ -26176,17 +25702,6 @@
"node": ">= 14"
}
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/libxmljs2/node_modules/cacache": {
"version": "19.0.1",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz",
@@ -31633,7 +31148,9 @@
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"bin": {
"playwright-core": "cli.js"
},
@@ -33257,6 +32774,18 @@
}
}
},
"node_modules/react18-json-view": {
"version": "0.2.10",
"resolved": "https://registry.npmjs.org/react18-json-view/-/react18-json-view-0.2.10.tgz",
"integrity": "sha512-rYEbaCG/U4THY1qp1xY14/Kbnp9yY3W6Qm3Rmu+jlCIdxzMS5EcD+wI97kCKRoN3CuJyJU8hqkax5xWfl8A4EA==",
"license": "MIT",
"dependencies": {
"copy-to-clipboard": "^3.3.3"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/read-cmd-shim": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/read-cmd-shim/-/read-cmd-shim-6.0.0.tgz",
@@ -36292,6 +35821,12 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",

View File

@@ -323,6 +323,7 @@
"react-is": "^19.2.6",
"react-markdown": "^10.1.0",
"react-reconciler": "^0.33.0",
"react18-json-view": "0.2.10",
"recharts": "^3.8.1",
"safe-regex": "^2.1.1",
"selfsigned": "^5.5.0",

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
import { shortCallId } from "@/shared/utils/formatting";
interface ToolCallBlockProps {
id: string;
@@ -23,7 +24,9 @@ export function ToolCallBlock({ id, name, input }: ToolCallBlockProps) {
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-amber-300 font-mono font-medium">{name}</span>
<span className="text-text-muted text-xs font-mono ml-auto">{id.slice(0, 8)}</span>
<span className="text-text-muted text-xs font-mono ml-auto" title={id}>
{shortCallId(id)}
</span>
</button>
{expanded && (
<div className="mt-2 border-t border-amber-500/20 pt-2">

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { JsonViewer } from "../shared/JsonViewer";
import { shortCallId } from "@/shared/utils/formatting";
interface ToolResultBlockProps {
toolUseId: string;
@@ -22,7 +23,9 @@ export function ToolResultBlock({ toolUseId, content }: ToolResultBlockProps) {
{expanded ? "expand_less" : "expand_more"}
</span>
<span className="text-green-300 font-mono font-medium text-xs">tool_result</span>
<span className="text-text-muted text-xs font-mono ml-auto">{toolUseId.slice(0, 8)}</span>
<span className="text-text-muted text-xs font-mono ml-auto" title={toolUseId}>
{shortCallId(toolUseId)}
</span>
</button>
{expanded && (
<div className="mt-2 border-t border-green-500/20 pt-2">

View File

@@ -378,6 +378,20 @@ button .material-symbols-outlined,
"opsz" 24;
}
/* react18-json-view fold control: the SVG chevron itself is ~14px, too small
a touch target on mobile. Padding + a matching negative margin grows the
hit area without shifting sibling layout -- unlike the row-level icon this
replaces (see the reverted react-json-view-lite alignment attempt), this
click target is a trailing decoration appended after the object/array's
opening brace, not a leading element sibling keys align against, so
padding it here cannot reproduce that regression. */
.jv-size-chevron {
display: inline-flex;
align-items: center;
padding: 10px 4px;
margin: -10px -4px;
}
/* Animations */
@keyframes spin {
from {
@@ -506,83 +520,294 @@ select option {
/* ═══════════════════════════════════════════════════════════════
RTL (Right-to-Left) Compatibility — fixes #7680
═══════════════════════════════════════════════════════════════ */
html[dir="rtl"] .text-left { text-align: start; }
html[dir="rtl"] .text-right { text-align: end; }
html[dir="rtl"] .ml-0 { --rtl-ms: 0; }
html[dir="rtl"] .ml-0\.5 { --rtl-ms: 0.5; }
html[dir="rtl"] .ml-1 { --rtl-ms: 1; }
html[dir="rtl"] .ml-1\.5 { --rtl-ms: 1.5; }
html[dir="rtl"] .ml-2 { --rtl-ms: 2; }
html[dir="rtl"] .ml-3 { --rtl-ms: 3; }
html[dir="rtl"] .ml-4 { --rtl-ms: 4; }
html[dir="rtl"] .ml-5 { --rtl-ms: 5; }
html[dir="rtl"] .ml-6 { --rtl-ms: 6; }
html[dir="rtl"] .ml-7 { --rtl-ms: 7; }
html[dir="rtl"] :is(.ml-0, .ml-0\.5, .ml-1, .ml-1\.5, .ml-2, .ml-3, .ml-4, .ml-5, .ml-6, .ml-7) { margin-left: 0; margin-inline-start: calc(var(--spacing) * var(--rtl-ms)); }
html[dir="rtl"] .ml-auto { margin-left: 0; margin-inline-start: auto; }
html[dir="rtl"] .mr-0\.5 { --rtl-me: 0.5; }
html[dir="rtl"] .mr-1 { --rtl-me: 1; }
html[dir="rtl"] .mr-2 { --rtl-me: 2; }
html[dir="rtl"] .mr-3 { --rtl-me: 3; }
html[dir="rtl"] :is(.mr-0\.5, .mr-1, .mr-2, .mr-3) { margin-right: 0; margin-inline-end: calc(var(--spacing) * var(--rtl-me)); }
html[dir="rtl"] .mr-auto { margin-right: 0; margin-inline-end: auto; }
html[dir="rtl"] .pl-1 { --rtl-ps: 1; }
html[dir="rtl"] .pl-2 { --rtl-ps: 2; }
html[dir="rtl"] .pl-3 { --rtl-ps: 3; }
html[dir="rtl"] .pl-4 { --rtl-ps: 4; }
html[dir="rtl"] .pl-5 { --rtl-ps: 5; }
html[dir="rtl"] .pl-6 { --rtl-ps: 6; }
html[dir="rtl"] .pl-7 { --rtl-ps: 7; }
html[dir="rtl"] .pl-8 { --rtl-ps: 8; }
html[dir="rtl"] .pl-9 { --rtl-ps: 9; }
html[dir="rtl"] .pl-10 { --rtl-ps: 10; }
html[dir="rtl"] .pl-12 { --rtl-ps: 12; }
html[dir="rtl"] :is(.pl-1, .pl-2, .pl-3, .pl-4, .pl-5, .pl-6, .pl-7, .pl-8, .pl-9, .pl-10, .pl-12) { padding-left: 0; padding-inline-start: calc(var(--spacing) * var(--rtl-ps)); }
html[dir="rtl"] .pr-1 { --rtl-pe: 1; }
html[dir="rtl"] .pr-2 { --rtl-pe: 2; }
html[dir="rtl"] .pr-2\.5 { --rtl-pe: 2.5; }
html[dir="rtl"] .pr-3 { --rtl-pe: 3; }
html[dir="rtl"] .pr-4 { --rtl-pe: 4; }
html[dir="rtl"] .pr-9 { --rtl-pe: 9; }
html[dir="rtl"] .pr-10 { --rtl-pe: 10; }
html[dir="rtl"] :is(.pr-1, .pr-2, .pr-2\.5, .pr-3, .pr-4, .pr-9, .pr-10) { padding-right: 0; padding-inline-end: calc(var(--spacing) * var(--rtl-pe)); }
html[dir="rtl"] .left-0 { --rtl-is: 0; }
html[dir="rtl"] .left-0\.5 { --rtl-is: calc(var(--spacing) * 0.5); }
html[dir="rtl"] .left-1 { --rtl-is: calc(var(--spacing) * 1); }
html[dir="rtl"] .left-1\.5 { --rtl-is: calc(var(--spacing) * 1.5); }
html[dir="rtl"] .left-2 { --rtl-is: calc(var(--spacing) * 2); }
html[dir="rtl"] .left-2\.5 { --rtl-is: calc(var(--spacing) * 2.5); }
html[dir="rtl"] .left-3 { --rtl-is: calc(var(--spacing) * 3); }
html[dir="rtl"] .left-5 { --rtl-is: calc(var(--spacing) * 5); }
html[dir="rtl"] .left-1\/4 { --rtl-is: 25%; }
html[dir="rtl"] .left-1\/2 { --rtl-is: 50%; }
html[dir="rtl"] .left-\[16\%\] { --rtl-is: 16%; }
html[dir="rtl"] :is(.left-0, .left-0\.5, .left-1, .left-1\.5, .left-2, .left-2\.5, .left-3, .left-5, .left-1\/4, .left-1\/2, .left-\[16\%\]) { left: auto; inset-inline-start: var(--rtl-is); }
html[dir="rtl"] .right-0 { --rtl-ie: 0; }
html[dir="rtl"] .right-1 { --rtl-ie: calc(var(--spacing) * 1); }
html[dir="rtl"] .right-2 { --rtl-ie: calc(var(--spacing) * 2); }
html[dir="rtl"] .right-3 { --rtl-ie: calc(var(--spacing) * 3); }
html[dir="rtl"] .right-4 { --rtl-ie: calc(var(--spacing) * 4); }
html[dir="rtl"] .right-1\/4 { --rtl-ie: 25%; }
html[dir="rtl"] .right-\[16\%\] { --rtl-ie: 16%; }
html[dir="rtl"] .-right-2\.5 { --rtl-ie: calc(var(--spacing) * -2.5); }
html[dir="rtl"] .-right-20 { --rtl-ie: calc(var(--spacing) * -20); }
html[dir="rtl"] :is(.right-0, .right-1, .right-2, .right-3, .right-4, .right-1\/4, .right-\[16\%\], .-right-2\.5, .-right-20) { right: auto; inset-inline-end: var(--rtl-ie); }
html[dir="rtl"] .border-l-2 { --rtl-bw: 2px; }
html[dir="rtl"] .border-l-4 { --rtl-bw: 4px; }
html[dir="rtl"] .border-l-\[3px\] { --rtl-bw: 3px; }
html[dir="rtl"] :is(.border-l-2, .border-l-4, .border-l-\[3px\]) { border-left-width: 0; border-inline-start-width: var(--rtl-bw); }
html[dir="rtl"] .border-l-primary { border-left-color: var(--color-border); border-inline-start-color: var(--color-primary); }
html[dir="rtl"] .border-r { border-right-width: 0; border-inline-end-width: 1px; }
html[dir="rtl"] .last\:border-r-0:last-child { border-left-width: 0; }
html[dir="rtl"] .rounded-r-lg { border-top-right-radius: 0; border-bottom-right-radius: 0; border-start-end-radius: var(--radius-lg); border-end-end-radius: var(--radius-lg); }
html[dir="rtl"] .translate-x-0\.5 { --rtl-tx: -0.5; }
html[dir="rtl"] .translate-x-1 { --rtl-tx: -1; }
html[dir="rtl"] .translate-x-4 { --rtl-tx: -4; }
html[dir="rtl"] .translate-x-5 { --rtl-tx: -5; }
html[dir="rtl"] .translate-x-6 { --rtl-tx: -6; }
html[dir="rtl"] :is(.translate-x-0\.5, .translate-x-1, .translate-x-4, .translate-x-5, .translate-x-6) { translate: calc(var(--spacing) * var(--rtl-tx)) var(--tw-translate-y); }
html[dir="rtl"] .-translate-x-1\/2 { translate: 50% var(--tw-translate-y); }
@media (min-width: 48rem) { html[dir="rtl"] .md\:ml-2 { margin-left: 0; margin-inline-start: calc(var(--spacing) * 2); } html[dir="rtl"] .md\:ml-auto { margin-left: 0; margin-inline-start: auto; } html[dir="rtl"] .md\:pl-6 { padding-left: 0; padding-inline-start: calc(var(--spacing) * 6); } }
html[dir="rtl"] :where(code, pre, kbd, samp, input[type="email"], input[type="url"]) { direction: ltr; text-align: left; }
html[dir="rtl"] :where(.material-symbols-outlined, .traffic-lights, .react-flow) { direction: ltr; }
html[dir="rtl"] .text-left {
text-align: start;
}
html[dir="rtl"] .text-right {
text-align: end;
}
html[dir="rtl"] .ml-0 {
--rtl-ms: 0;
}
html[dir="rtl"] .ml-0\.5 {
--rtl-ms: 0.5;
}
html[dir="rtl"] .ml-1 {
--rtl-ms: 1;
}
html[dir="rtl"] .ml-1\.5 {
--rtl-ms: 1.5;
}
html[dir="rtl"] .ml-2 {
--rtl-ms: 2;
}
html[dir="rtl"] .ml-3 {
--rtl-ms: 3;
}
html[dir="rtl"] .ml-4 {
--rtl-ms: 4;
}
html[dir="rtl"] .ml-5 {
--rtl-ms: 5;
}
html[dir="rtl"] .ml-6 {
--rtl-ms: 6;
}
html[dir="rtl"] .ml-7 {
--rtl-ms: 7;
}
html[dir="rtl"] :is(.ml-0, .ml-0\.5, .ml-1, .ml-1\.5, .ml-2, .ml-3, .ml-4, .ml-5, .ml-6, .ml-7) {
margin-left: 0;
margin-inline-start: calc(var(--spacing) * var(--rtl-ms));
}
html[dir="rtl"] .ml-auto {
margin-left: 0;
margin-inline-start: auto;
}
html[dir="rtl"] .mr-0\.5 {
--rtl-me: 0.5;
}
html[dir="rtl"] .mr-1 {
--rtl-me: 1;
}
html[dir="rtl"] .mr-2 {
--rtl-me: 2;
}
html[dir="rtl"] .mr-3 {
--rtl-me: 3;
}
html[dir="rtl"] :is(.mr-0\.5, .mr-1, .mr-2, .mr-3) {
margin-right: 0;
margin-inline-end: calc(var(--spacing) * var(--rtl-me));
}
html[dir="rtl"] .mr-auto {
margin-right: 0;
margin-inline-end: auto;
}
html[dir="rtl"] .pl-1 {
--rtl-ps: 1;
}
html[dir="rtl"] .pl-2 {
--rtl-ps: 2;
}
html[dir="rtl"] .pl-3 {
--rtl-ps: 3;
}
html[dir="rtl"] .pl-4 {
--rtl-ps: 4;
}
html[dir="rtl"] .pl-5 {
--rtl-ps: 5;
}
html[dir="rtl"] .pl-6 {
--rtl-ps: 6;
}
html[dir="rtl"] .pl-7 {
--rtl-ps: 7;
}
html[dir="rtl"] .pl-8 {
--rtl-ps: 8;
}
html[dir="rtl"] .pl-9 {
--rtl-ps: 9;
}
html[dir="rtl"] .pl-10 {
--rtl-ps: 10;
}
html[dir="rtl"] .pl-12 {
--rtl-ps: 12;
}
html[dir="rtl"] :is(.pl-1, .pl-2, .pl-3, .pl-4, .pl-5, .pl-6, .pl-7, .pl-8, .pl-9, .pl-10, .pl-12) {
padding-left: 0;
padding-inline-start: calc(var(--spacing) * var(--rtl-ps));
}
html[dir="rtl"] .pr-1 {
--rtl-pe: 1;
}
html[dir="rtl"] .pr-2 {
--rtl-pe: 2;
}
html[dir="rtl"] .pr-2\.5 {
--rtl-pe: 2.5;
}
html[dir="rtl"] .pr-3 {
--rtl-pe: 3;
}
html[dir="rtl"] .pr-4 {
--rtl-pe: 4;
}
html[dir="rtl"] .pr-9 {
--rtl-pe: 9;
}
html[dir="rtl"] .pr-10 {
--rtl-pe: 10;
}
html[dir="rtl"] :is(.pr-1, .pr-2, .pr-2\.5, .pr-3, .pr-4, .pr-9, .pr-10) {
padding-right: 0;
padding-inline-end: calc(var(--spacing) * var(--rtl-pe));
}
html[dir="rtl"] .left-0 {
--rtl-is: 0;
}
html[dir="rtl"] .left-0\.5 {
--rtl-is: calc(var(--spacing) * 0.5);
}
html[dir="rtl"] .left-1 {
--rtl-is: calc(var(--spacing) * 1);
}
html[dir="rtl"] .left-1\.5 {
--rtl-is: calc(var(--spacing) * 1.5);
}
html[dir="rtl"] .left-2 {
--rtl-is: calc(var(--spacing) * 2);
}
html[dir="rtl"] .left-2\.5 {
--rtl-is: calc(var(--spacing) * 2.5);
}
html[dir="rtl"] .left-3 {
--rtl-is: calc(var(--spacing) * 3);
}
html[dir="rtl"] .left-5 {
--rtl-is: calc(var(--spacing) * 5);
}
html[dir="rtl"] .left-1\/4 {
--rtl-is: 25%;
}
html[dir="rtl"] .left-1\/2 {
--rtl-is: 50%;
}
html[dir="rtl"] .left-\[16\%\] {
--rtl-is: 16%;
}
html[dir="rtl"]
:is(
.left-0,
.left-0\.5,
.left-1,
.left-1\.5,
.left-2,
.left-2\.5,
.left-3,
.left-5,
.left-1\/4,
.left-1\/2,
.left-\[16\%\]
) {
left: auto;
inset-inline-start: var(--rtl-is);
}
html[dir="rtl"] .right-0 {
--rtl-ie: 0;
}
html[dir="rtl"] .right-1 {
--rtl-ie: calc(var(--spacing) * 1);
}
html[dir="rtl"] .right-2 {
--rtl-ie: calc(var(--spacing) * 2);
}
html[dir="rtl"] .right-3 {
--rtl-ie: calc(var(--spacing) * 3);
}
html[dir="rtl"] .right-4 {
--rtl-ie: calc(var(--spacing) * 4);
}
html[dir="rtl"] .right-1\/4 {
--rtl-ie: 25%;
}
html[dir="rtl"] .right-\[16\%\] {
--rtl-ie: 16%;
}
html[dir="rtl"] .-right-2\.5 {
--rtl-ie: calc(var(--spacing) * -2.5);
}
html[dir="rtl"] .-right-20 {
--rtl-ie: calc(var(--spacing) * -20);
}
html[dir="rtl"]
:is(
.right-0,
.right-1,
.right-2,
.right-3,
.right-4,
.right-1\/4,
.right-\[16\%\],
.-right-2\.5,
.-right-20
) {
right: auto;
inset-inline-end: var(--rtl-ie);
}
html[dir="rtl"] .border-l-2 {
--rtl-bw: 2px;
}
html[dir="rtl"] .border-l-4 {
--rtl-bw: 4px;
}
html[dir="rtl"] .border-l-\[3px\] {
--rtl-bw: 3px;
}
html[dir="rtl"] :is(.border-l-2, .border-l-4, .border-l-\[3px\]) {
border-left-width: 0;
border-inline-start-width: var(--rtl-bw);
}
html[dir="rtl"] .border-l-primary {
border-left-color: var(--color-border);
border-inline-start-color: var(--color-primary);
}
html[dir="rtl"] .border-r {
border-right-width: 0;
border-inline-end-width: 1px;
}
html[dir="rtl"] .last\:border-r-0:last-child {
border-left-width: 0;
}
html[dir="rtl"] .rounded-r-lg {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-start-end-radius: var(--radius-lg);
border-end-end-radius: var(--radius-lg);
}
html[dir="rtl"] .translate-x-0\.5 {
--rtl-tx: -0.5;
}
html[dir="rtl"] .translate-x-1 {
--rtl-tx: -1;
}
html[dir="rtl"] .translate-x-4 {
--rtl-tx: -4;
}
html[dir="rtl"] .translate-x-5 {
--rtl-tx: -5;
}
html[dir="rtl"] .translate-x-6 {
--rtl-tx: -6;
}
html[dir="rtl"]
:is(.translate-x-0\.5, .translate-x-1, .translate-x-4, .translate-x-5, .translate-x-6) {
translate: calc(var(--spacing) * var(--rtl-tx)) var(--tw-translate-y);
}
html[dir="rtl"] .-translate-x-1\/2 {
translate: 50% var(--tw-translate-y);
}
@media (min-width: 48rem) {
html[dir="rtl"] .md\:ml-2 {
margin-left: 0;
margin-inline-start: calc(var(--spacing) * 2);
}
html[dir="rtl"] .md\:ml-auto {
margin-left: 0;
margin-inline-start: auto;
}
html[dir="rtl"] .md\:pl-6 {
padding-left: 0;
padding-inline-start: calc(var(--spacing) * 6);
}
}
html[dir="rtl"] :where(code, pre, kbd, samp, input[type="email"], input[type="url"]) {
direction: ltr;
text-align: left;
}
html[dir="rtl"] :where(.material-symbols-outlined, .traffic-lights, .react-flow) {
direction: ltr;
}

View File

@@ -11258,6 +11258,11 @@
"copy": "Copy",
"autoscrollOn": "Autoscroll: on",
"autoscrollOff": "Autoscroll: off",
"collapseAllLevels": "Collapse all",
"collapseOneLevel": "Collapse one level",
"currentExpandLevel": "Current expand level",
"expandOneLevel": "Expand one level",
"expandAllLevels": "Expand all",
"payload": {
"clientRawRequest": "Client Raw Request",
"clientRequest": "Client Request",

View File

@@ -11258,6 +11258,11 @@
"copy": "Copiar",
"autoscrollOn": "Rolagem Automática: ativada",
"autoscrollOff": "Deslocamento Automático: desligado",
"collapseAllLevels": "Recolher tudo",
"collapseOneLevel": "Recolher um nível",
"currentExpandLevel": "Nível de expansão atual",
"expandOneLevel": "Expandir um nível",
"expandAllLevels": "Expandir tudo",
"payload": {
"clientRawRequest": "Solicitação Bruta do Cliente",
"clientRequest": "Solicitação do Cliente",

View File

@@ -0,0 +1,69 @@
"use client";
import { useTranslations } from "next-intl";
import useJsonTreeExpandStore, { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
interface JsonTreeExpandControlsProps {
/** Stable, locale-independent identifier for this box (e.g. "openaiRequest",
* "providerEventStream") -- NOT the translated title. Each sectionId tracks
* its own expand level, shared across different log entries but independent
* from every other section on the page, since different payload/stream
* boxes carry "interesting" data at different nesting depths. */
sectionId: string;
}
export function JsonTreeExpandControls({ sectionId }: JsonTreeExpandControlsProps) {
const t = useTranslations("requestLogger.detail");
const level = useJsonTreeExpandLevel(sectionId);
const { collapseAll, collapseOneLevel, expandOneLevel, expandAll } = useJsonTreeExpandStore();
const buttonClass =
"p-1 rounded hover:bg-bg-subtle text-text-muted hover:text-text-primary transition-colors";
return (
<div className="flex items-center gap-0.5">
<button
type="button"
onClick={() => collapseAll(sectionId)}
title={t("collapseAllLevels")}
aria-label={t("collapseAllLevels")}
className={buttonClass}
>
<span className="material-symbols-outlined text-[16px]">collapse_all</span>
</button>
<button
type="button"
onClick={() => collapseOneLevel(sectionId)}
title={t("collapseOneLevel")}
aria-label={t("collapseOneLevel")}
className={buttonClass}
>
<span className="material-symbols-outlined text-[16px]">remove</span>
</button>
<span
className="min-w-[1.5em] text-center text-[11px] font-mono text-text-muted tabular-nums"
title={t("currentExpandLevel")}
>
{level}
</span>
<button
type="button"
onClick={() => expandOneLevel(sectionId)}
title={t("expandOneLevel")}
aria-label={t("expandOneLevel")}
className={buttonClass}
>
<span className="material-symbols-outlined text-[16px]">add</span>
</button>
<button
type="button"
onClick={() => expandAll(sectionId)}
title={t("expandAllLevels")}
aria-label={t("expandAllLevels")}
className={buttonClass}
>
<span className="material-symbols-outlined text-[16px]">expand_all</span>
</button>
</div>
);
}

View File

@@ -1,17 +1,54 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo } from "react";
import { useTranslations } from "next-intl";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import "react18-json-view/src/dark.css";
import { ChatBubble } from "@/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ChatBubble";
import { buildRequestTurns, buildResponseTurns } from "@/mitm/inspector/conversationNormalizer";
import type { InterceptedRequest, NormalizedTurn } from "@/mitm/inspector/types";
import { useTheme } from "@/shared/hooks/useTheme";
import {
useTimestampTitles,
timestampMarkerCustomizeNode,
} from "@/shared/hooks/useTimestampTitles";
import { JsonTreeExpandControls } from "@/shared/components/JsonTreeExpandControls";
import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
// ─── Payload Code Block ─────────────────────────────────────────────────────
// Renders parsed payloads as a collapsible JSON tree (react18-json-view) so
// deeply nested request/response bodies (tool args, message arrays) can be
// collapsed instead of scrolled through as one raw text dump. Falls back to
// the plain <pre> dump for anything that isn't valid JSON (e.g. a captured
// error string), since json is display text sourced from JSON.stringify with
// a String() fallback on failure -- it is not guaranteed parseable.
export function PayloadSection({ title, json, onCopy, collapsible = true, defaultOpen = true }) {
export function PayloadSection({
title,
sectionId,
json,
onCopy,
collapsible = true,
defaultOpen = true,
}) {
const t = useTranslations("requestLogger.detail");
const { isDark } = useTheme();
const resolvedSectionId = sectionId || title;
const expandLevel = useJsonTreeExpandLevel(resolvedSectionId);
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(defaultOpen);
const treeContainerRef = useRef(null);
const parsedJson = useMemo(() => {
if (typeof json !== "string") return null;
try {
const value = JSON.parse(json);
return value !== null && typeof value === "object" ? value : null;
} catch {
return null;
}
}, [json]);
const handleCopy = async () => {
const success = await onCopy();
@@ -21,6 +58,8 @@ export function PayloadSection({ title, json, onCopy, collapsible = true, defaul
}
};
useTimestampTitles(treeContainerRef, open && parsedJson !== null);
return (
<div>
<div className="flex items-center justify-between mb-2">
@@ -40,18 +79,35 @@ export function PayloadSection({ title, json, onCopy, collapsible = true, defaul
</button>
)}
</div>
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
<div className="flex items-center gap-2">
<button
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 text-xs text-text-muted hover:text-text-primary transition-colors"
aria-label={t("copyTitle", { title })}
>
<span className="material-symbols-outlined text-[14px]">
{copied ? "check" : "content_copy"}
</span>
{copied ? t("copied") : t("copy")}
</button>
{parsedJson !== null && <JsonTreeExpandControls sectionId={resolvedSectionId} />}
</div>
</div>
{open && (
{open && parsedJson !== null && (
<div
ref={treeContainerRef}
className="rounded-xl bg-black/5 dark:bg-black/30 border border-border max-h-150 overflow-auto p-4 text-xs font-mono"
>
<JsonView
src={parsedJson}
dark={isDark}
collapsed={expandLevel}
customizeNode={timestampMarkerCustomizeNode}
displaySize
/>
</div>
)}
{open && parsedJson === null && (
<pre className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words">
{json}
</pre>

View File

@@ -1,7 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useRef } from "react";
import { useLocale, useTranslations } from "next-intl";
import JsonView from "react18-json-view";
import "react18-json-view/src/style.css";
import "react18-json-view/src/dark.css";
import {
PROVIDER_COLORS,
getHttpStatusStyle as getStatusStyle,
@@ -9,6 +12,13 @@ import {
} from "@/shared/constants/colors";
import { formatDuration, formatApiKeyLabel, maskAccount } from "@/shared/utils/formatting";
import { formatErrorForDisplay } from "@/shared/utils/formatting";
import { useTheme } from "@/shared/hooks/useTheme";
import {
useTimestampTitles,
timestampMarkerCustomizeNode,
} from "@/shared/hooks/useTimestampTitles";
import { JsonTreeExpandControls } from "@/shared/components/JsonTreeExpandControls";
import { useJsonTreeExpandLevel } from "@/store/jsonTreeExpandStore";
import {
PayloadSection,
ConversationContextSection,
@@ -66,7 +76,44 @@ export function buildCopyAllText({
// ─── Stream section + Detail Modal ───────────────────────────────────────────────────────────
function StreamSection({ title, json, onCopy }) {
// Raw stream chunks are captured at the network level (see streamChunks
// capture) with a `[HH:MM:SS.mmm] ` prefix inserted per chunk boundary, which
// can land mid-token inside an SSE event's JSON payload once chunks are
// joined. Strip those markers first so a `data:` line isn't interrupted.
const STREAM_TIMESTAMP_PREFIX = /\[\d{2}:\d{2}:\d{2}\.\d{3}\] /g;
type StreamSegment =
{ type: "text"; value: string } | { type: "json"; value: unknown; raw: string };
// Splits a raw joined SSE capture into renderable segments: each `data:`
// line that parses as JSON becomes its own segment (rendered as a
// collapsible tree), everything else (comments, keep-alives, [DONE],
// non-JSON payloads) stays as plain text, byte-identical to the raw capture.
function parseStreamIntoSegments(joined: string): StreamSegment[] {
const text = joined.replace(STREAM_TIMESTAMP_PREFIX, "");
const events = text.split(/(?<=\n\n)/); // keep event boundaries, preserve exact text
const segments: StreamSegment[] = [];
for (const event of events) {
if (!event) continue;
const dataLines = event
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trim());
const payload = dataLines.join("");
if (dataLines.length === 0 || !payload || payload === "[DONE]") {
segments.push({ type: "text", value: event });
continue;
}
try {
segments.push({ type: "json", value: JSON.parse(payload), raw: event });
} catch {
segments.push({ type: "text", value: event });
}
}
return segments;
}
function StreamSection({ title, sectionId, json, onCopy }) {
const t = useTranslations("requestLogger.detail");
const [copied, setCopied] = useState(false);
const [open, setOpen] = useState(true);
@@ -79,6 +126,10 @@ function StreamSection({ title, json, onCopy }) {
}
});
const ref = useRef(null);
const { isDark } = useTheme();
const resolvedSectionId = sectionId || title;
const expandLevel = useJsonTreeExpandLevel(resolvedSectionId);
const segments = useMemo(() => parseStreamIntoSegments(json), [json]);
const handleCopy = async () => {
const success = await onCopy();
@@ -108,6 +159,8 @@ function StreamSection({ title, json, onCopy }) {
} catch {}
};
useTimestampTitles(ref, open);
return (
<div>
<div className="flex items-center justify-between mb-2">
@@ -144,14 +197,33 @@ function StreamSection({ title, json, onCopy }) {
</span>
{copied ? t("copied") : t("copy")}
</button>
{segments.some((s) => s.type === "json") && (
<JsonTreeExpandControls sectionId={resolvedSectionId} />
)}
</div>
</div>
{open && (
<div
ref={ref}
className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed whitespace-pre-wrap break-words"
className="p-4 rounded-xl bg-black/5 dark:bg-black/30 border border-border overflow-x-auto text-xs font-mono text-text-main max-h-150 overflow-y-auto leading-relaxed"
>
{json}
{segments.map((segment, i) =>
segment.type === "json" ? (
<div key={i} className="my-1">
<JsonView
src={segment.value}
dark={isDark}
collapsed={expandLevel}
customizeNode={timestampMarkerCustomizeNode}
displaySize
/>
</div>
) : (
<span key={i} className="whitespace-pre-wrap break-words">
{segment.value}
</span>
)
)}
</div>
)}
</div>
@@ -201,6 +273,7 @@ export default function RequestLoggerDetail({
}) {
const t = useTranslations("requestLogger.detail");
const locale = useLocale();
const modalScrollRef = useRef(null);
// Close on Escape key
useEffect(() => {
const handler = (e) => {
@@ -210,6 +283,21 @@ export default function RequestLoggerDetail({
return () => globalThis.removeEventListener("keydown", handler);
}, [onClose]);
// The overlay is full-viewport, but the modal panel itself is centered and
// narrower than the viewport (max-w-225), leaving backdrop margin on every
// side. A wheel event there has no scrollable target under the cursor, so
// scrolling only worked once the pointer happened to be over the panel's
// own content. Forward wheel scrolling from anywhere in the overlay to the
// panel instead, so the backdrop margin scrolls it too.
const handleOverlayWheel = (e) => {
const panel = modalScrollRef.current;
// Let native scrolling handle wheel events that already land inside the
// panel -- only forward the ones from the backdrop margin around it.
if (!panel || panel.contains(e.target)) return;
panel.scrollTop += e.deltaY;
e.preventDefault();
};
const statusStyle = getStatusStyle(log.status);
const protocolKey = log.sourceFormat || log.provider;
const protocol = getProtocolColor(protocolKey, log.provider);
@@ -377,12 +465,14 @@ export default function RequestLoggerDetail({
<div
className="fixed inset-0 z-50 flex items-start justify-center px-2 pt-[5vh] sm:px-4"
onClick={onClose}
onWheel={handleOverlayWheel}
role="dialog"
aria-modal="true"
aria-label={t("ariaLabel")}
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
ref={modalScrollRef}
className="relative w-full max-w-225 max-h-[90vh] overflow-x-hidden overflow-y-auto rounded-xl border border-border bg-bg-primary shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
@@ -722,7 +812,7 @@ export default function RequestLoggerDetail({
{detail?.comboName || log.comboName}
</span>
) : (
<div className="text-sm text-text-muted">\u2014</div>
<div className="text-sm text-text-muted">{"\u2014"}</div>
)}
</div>
<div>
@@ -734,10 +824,11 @@ export default function RequestLoggerDetail({
className="text-sm font-mono select-all"
title={detail?.sessionTag || log.sessionTag}
>
{(detail?.sessionTag || log.sessionTag).slice(0, 20)}\u2026
{(detail?.sessionTag || log.sessionTag).slice(0, 20)}
{"\u2026"}
</div>
) : (
<div className="text-sm text-text-muted">\u2014</div>
<div className="text-sm text-text-muted">{"\u2014"}</div>
)}
</div>
</div>
@@ -915,6 +1006,7 @@ export default function RequestLoggerDetail({
{streamChunks && streamChunks.provider && (
<StreamSection
title={t("providerEventStream")}
sectionId="providerEventStream"
json={
Array.isArray(streamChunks.provider)
? streamChunks.provider.join("")
@@ -933,6 +1025,7 @@ export default function RequestLoggerDetail({
{streamChunks && streamChunks.client && (
<StreamSection
title={t("clientEventStream")}
sectionId="clientEventStream"
json={
Array.isArray(streamChunks.client)
? streamChunks.client.join("")
@@ -954,6 +1047,7 @@ export default function RequestLoggerDetail({
!streamChunks.client && (
<StreamSection
title={t("eventStream")}
sectionId="eventStream"
json={
Array.isArray(streamChunks.openai)
? streamChunks.openai.join("")
@@ -974,6 +1068,7 @@ export default function RequestLoggerDetail({
<PayloadSection
key={section.key}
title={section.title}
sectionId={section.key}
json={section.json}
onCopy={() => onCopy(section.json)}
/>
@@ -982,6 +1077,7 @@ export default function RequestLoggerDetail({
{payloadSections.length === 0 && responseJson && (
<PayloadSection
title={t("responsePayloadLegacy")}
sectionId="responsePayloadLegacy"
json={responseJson}
onCopy={() => onCopy(responseJson)}
/>
@@ -990,6 +1086,7 @@ export default function RequestLoggerDetail({
{payloadSections.length === 0 && requestJson && (
<PayloadSection
title={t("requestPayloadLegacy")}
sectionId="requestPayloadLegacy"
json={requestJson}
onCopy={() => onCopy(requestJson)}
/>

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, type RefObject } from "react";
import type { JsonViewProps } from "react18-json-view";
type CustomizeNode = NonNullable<JsonViewProps["customizeNode"]>;
// react18-json-view exposes no direct tooltip prop, but customizeNode gives a
// per-value className hook without touching what value is actually rendered
// -- the tree's byte-accurate rendering is the point, so a marker class + DOM
// title-attribute pass is safer here than swapping in a replacement element
// with hand-reproduced number formatting.
export const TIMESTAMP_VALUE_MARKER_CLASS = "json-tree-number-value";
const TIMESTAMP_FIELD_NAME = /created|updated|modified|timestamp|(^|_)ts$|_at$/i;
// Unix epoch bounds spanning year 2000 to year 2100, checked at both
// seconds and millisecond precision (both are common on the wire).
const EPOCH_SECONDS_MIN = 946684800;
const EPOCH_SECONDS_MAX = 4102444800;
const EPOCH_MS_MIN = EPOCH_SECONDS_MIN * 1000;
const EPOCH_MS_MAX = EPOCH_SECONDS_MAX * 1000;
function epochValueToDate(value: number): Date | null {
if (!Number.isFinite(value)) return null;
if (value >= EPOCH_SECONDS_MIN && value <= EPOCH_SECONDS_MAX) return new Date(value * 1000);
if (value >= EPOCH_MS_MIN && value <= EPOCH_MS_MAX) return new Date(value);
return null;
}
// react18-json-view's customizeNode receives the key (indexOrName) and value
// (node) together, so the timestamp-field-name check can run at the source
// instead of a DOM sibling-walk -- it only needs to mark the match, the
// tooltip text itself is still applied via useTimestampTitles below.
export const timestampMarkerCustomizeNode: CustomizeNode = ({ node, indexOrName }) => {
if (typeof indexOrName !== "string" || !TIMESTAMP_FIELD_NAME.test(indexOrName)) {
return undefined;
}
if (typeof node !== "number" || !epochValueToDate(node)) {
return undefined;
}
return { className: TIMESTAMP_VALUE_MARKER_CLASS };
};
// Adds a human-readable date/time title (tooltip) to marked number values.
// Re-runs on any DOM change inside the container (expanding an individual
// node is internal react18-json-view state, not a prop change this
// component would otherwise see) and is idempotent, so it's safe to call
// repeatedly as the tree gets expanded/collapsed.
export function useTimestampTitles(containerRef: RefObject<HTMLElement | null>, active: boolean) {
useEffect(() => {
const container = containerRef.current;
if (!active || !container) return;
const annotate = () => {
const values = container.querySelectorAll<HTMLElement>(`.${TIMESTAMP_VALUE_MARKER_CLASS}`);
for (const valueEl of values) {
if (valueEl.title) continue;
const date = epochValueToDate(Number(valueEl.textContent));
if (!date) continue;
valueEl.title = date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "medium",
});
}
};
annotate();
const observer = new MutationObserver(annotate);
observer.observe(container, { childList: true, subtree: true });
return () => observer.disconnect();
}, [containerRef, active]);
}

View File

@@ -182,6 +182,24 @@ export function formatResetCountdown(resetAt: string | number | null | undefined
return `${seconds}s`;
}
/**
* Extract a git-log-style short identifier from a tool/call id for compact
* display. IDs carry decorative prefixes that vary by provider (call_,
* call-, toolu_, or composite forms like call_call-<uuid>_fc_cal_<hex>) and
* aren't unique on their own — naively slicing the whole id can leave as
* little as 3 significant characters visible. Finds the first UUID-shaped
* segment (or long hex run) and takes its leading characters instead, so the
* visible part is actually where the entropy is.
*/
export function shortCallId(id: string | null | undefined, length = 7): string {
if (!id) return "";
const uuidMatch = id.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
if (uuidMatch) return uuidMatch[0].slice(0, length);
const hexMatch = id.match(/[0-9a-f]{8,}/i);
if (hexMatch) return hexMatch[0].slice(0, length);
return id.slice(0, length);
}
/**
* Coerces a persisted log `error` field into safe display text. It is
* legitimate for this field to be a structured object (e.g. `{ code,

View File

@@ -0,0 +1,63 @@
"use client";
import { create } from "zustand";
import { persist } from "zustand/middleware";
// The level a JSON tree node is rendered at (react18-json-view's own
// `collapsed` numeric-depth prop) is 0-indexed, so this store's level uses
// the same convention: level 0 means nothing is expanded ("collapse all"),
// and a node at depth N is expanded when N < level.
const MIN_LEVEL = 0;
const MAX_LEVEL = 64; // deep enough for any real request/response payload
export const DEFAULT_JSON_TREE_EXPAND_LEVEL = 2;
// Level is tracked per section (keyed by a stable, locale-independent
// sectionId -- e.g. "openaiRequest", "providerEventStream" -- not the
// translated title), because different payload/stream boxes carry
// "interesting" data at different nesting depths. The same sectionId across
// different log entries shares one level, so it still carries over between
// requests, just scoped per box instead of one setting for the whole page.
interface JsonTreeExpandState {
levels: Record<string, number>;
collapseAll: (sectionId: string) => void;
collapseOneLevel: (sectionId: string) => void;
expandOneLevel: (sectionId: string) => void;
expandAll: (sectionId: string) => void;
}
function clamp(level: number): number {
return Math.max(MIN_LEVEL, Math.min(MAX_LEVEL, level));
}
const useJsonTreeExpandStore = create<JsonTreeExpandState>()(
persist(
(set) => ({
levels: {},
collapseAll: (sectionId) => set((s) => ({ levels: { ...s.levels, [sectionId]: MIN_LEVEL } })),
collapseOneLevel: (sectionId) =>
set((s) => ({
levels: {
...s.levels,
[sectionId]: clamp((s.levels[sectionId] ?? DEFAULT_JSON_TREE_EXPAND_LEVEL) - 1),
},
})),
expandOneLevel: (sectionId) =>
set((s) => ({
levels: {
...s.levels,
[sectionId]: clamp((s.levels[sectionId] ?? DEFAULT_JSON_TREE_EXPAND_LEVEL) + 1),
},
})),
expandAll: (sectionId) => set((s) => ({ levels: { ...s.levels, [sectionId]: MAX_LEVEL } })),
}),
{
name: "omniroute-json-tree-expand-levels",
}
)
);
export function useJsonTreeExpandLevel(sectionId: string): number {
return useJsonTreeExpandStore((s) => s.levels[sectionId] ?? DEFAULT_JSON_TREE_EXPAND_LEVEL);
}
export default useJsonTreeExpandStore;

View File

@@ -0,0 +1,81 @@
// @vitest-environment jsdom
//
// Regression guard: PayloadSection rendered every payload (request/response
// bodies, pipeline stages) as a flat <pre> text dump, forcing operators to
// scroll through the entire raw JSON to find anything in a nested request.
// It now renders parseable JSON payloads as a collapsible tree
// (react-json-view-lite) instead, while still falling back to the plain
// <pre> dump for anything that isn't valid JSON (e.g. a captured error
// string), since the `json` prop is display text that isn't guaranteed
// parseable.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/shared/hooks/useTheme", () => ({
useTheme: () => ({ isDark: false }),
}));
const { PayloadSection } =
await import("../../../src/shared/components/RequestLoggerDetail.sections.tsx");
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function renderPayload(json: string) {
act(() => {
root.render(
<PayloadSection title="Test Payload" json={json} onCopy={vi.fn().mockResolvedValue(true)} />
);
});
}
describe("PayloadSection collapsible JSON rendering", () => {
it("renders a valid JSON object payload as a collapsible tree, not a flat <pre> dump", () => {
renderPayload(JSON.stringify({ model: "gpt-5.6-luna", input: [{ role: "user" }] }, null, 2));
expect(container.querySelector("pre")).toBeNull();
// react-json-view-lite renders expand/collapse controls for object nodes.
expect(container.querySelectorAll("[aria-label]").length).toBeGreaterThan(0);
expect(container.textContent).toContain("model");
expect(container.textContent).toContain("gpt-5.6-luna");
});
it("renders a valid JSON array payload as a collapsible tree", () => {
renderPayload(JSON.stringify([{ type: "output_text", text: "answer" }], null, 2));
expect(container.querySelector("pre")).toBeNull();
expect(container.textContent).toContain("output_text");
});
it("falls back to the plain <pre> dump for non-JSON payload text", () => {
renderPayload("Upstream request failed: Model is unavailable.");
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
expect(pre?.textContent).toBe("Upstream request failed: Model is unavailable.");
});
it("falls back to the plain <pre> dump for a JSON primitive (not an object/array)", () => {
renderPayload(JSON.stringify("just a string"));
const pre = container.querySelector("pre");
expect(pre).not.toBeNull();
});
});

View File

@@ -0,0 +1,94 @@
// @vitest-environment jsdom
//
// Regression guard: JSON tree values that are actually Unix timestamps
// (e.g. `created: 1787562749` in an event-stream payload) rendered as a
// bare number with no way to read the actual date/time without doing the
// epoch math by hand. react-json-view-lite exposes no per-value render
// hook, so useTimestampTitles walks the rendered tree and adds a
// human-readable title/tooltip to number values whose field name looks
// like a timestamp and whose value is a plausible Unix epoch -- without
// changing the value actually displayed.
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}));
vi.mock("@/shared/hooks/useTheme", () => ({
useTheme: () => ({ isDark: false }),
}));
const { PayloadSection } =
await import("../../../src/shared/components/RequestLoggerDetail.sections.tsx");
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
function renderPayload(payload: unknown) {
act(() => {
root.render(
<PayloadSection
title="Test Payload"
sectionId="test-payload"
json={JSON.stringify(payload, null, 2)}
onCopy={vi.fn().mockResolvedValue(true)}
/>
);
});
}
function findValueSpan(text: string): HTMLElement | undefined {
return Array.from(container.querySelectorAll<HTMLElement>("span")).find(
(el) => el.children.length === 0 && el.textContent === text
);
}
describe("timestamp title tooltips in JSON trees", () => {
it("adds a human-readable title to a field named created with a plausible epoch-seconds value", () => {
renderPayload({ id: "gen-1", created: 1787562749, model: "nemotron-3-ultra-free" });
const valueSpan = findValueSpan("1787562749");
expect(valueSpan).toBeDefined();
expect(valueSpan?.title).not.toBe("");
// 1787562749 seconds since epoch -> 2026-08-24-ish; just confirm it parsed
// as a real, non-garbage date rather than asserting an exact locale string.
expect(valueSpan?.title).toMatch(/202\d/);
});
it("does not add a title to a plain number field that only happens to share the epoch-seconds range", () => {
renderPayload({ id: "gen-1", max_tokens: 1787562749 });
const valueSpan = findValueSpan("1787562749");
expect(valueSpan).toBeDefined();
expect(valueSpan?.title).toBe("");
});
it("does not add a title to a timestamp-named field whose value is not a plausible epoch", () => {
renderPayload({ created: 42 });
const valueSpan = findValueSpan("42");
expect(valueSpan).toBeDefined();
expect(valueSpan?.title).toBe("");
});
it("leaves the displayed value unchanged -- only the title attribute is added", () => {
renderPayload({ created: 1787562749 });
const valueSpan = findValueSpan("1787562749");
expect(valueSpan?.textContent).toBe("1787562749");
});
});

View File

@@ -0,0 +1,57 @@
import assert from "node:assert/strict";
import test, { beforeEach } from "node:test";
import useJsonTreeExpandStore, {
DEFAULT_JSON_TREE_EXPAND_LEVEL,
} from "../../../src/store/jsonTreeExpandStore.ts";
// Regression guard: collapse/expand-level controls track one level per
// section (sectionId), not one global level for the whole page -- different
// payload/stream boxes carry "interesting" data at different nesting depths.
// Level is 0-indexed to match react-json-view-lite's own
// shouldExpandNode(level) convention: 0 means nothing is expanded.
function reset() {
useJsonTreeExpandStore.setState({ levels: {} });
}
beforeEach(reset);
test("an unset section defaults to the default expand level", () => {
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, undefined);
});
test("collapseAll sets only the given section's level to 0", () => {
useJsonTreeExpandStore.getState().collapseAll("openaiRequest");
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, 0);
assert.equal(useJsonTreeExpandStore.getState().levels.providerRequest, undefined);
});
test("expandAll sets only the given section's level to the max depth", () => {
useJsonTreeExpandStore.getState().expandAll("openaiRequest");
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, 64);
assert.equal(useJsonTreeExpandStore.getState().levels.providerRequest, undefined);
});
test("collapseOneLevel decrements from the default and clamps at 0, independently per section", () => {
const { collapseOneLevel } = useJsonTreeExpandStore.getState();
collapseOneLevel("openaiRequest");
assert.equal(
useJsonTreeExpandStore.getState().levels.openaiRequest,
DEFAULT_JSON_TREE_EXPAND_LEVEL - 1
);
assert.equal(useJsonTreeExpandStore.getState().levels.providerRequest, undefined);
collapseOneLevel("openaiRequest");
collapseOneLevel("openaiRequest");
collapseOneLevel("openaiRequest");
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, 0);
});
test("expandOneLevel increments and clamps at the max, independently per section", () => {
useJsonTreeExpandStore.setState({ levels: { openaiRequest: 63 } });
const { expandOneLevel } = useJsonTreeExpandStore.getState();
expandOneLevel("openaiRequest");
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, 64);
expandOneLevel("openaiRequest");
assert.equal(useJsonTreeExpandStore.getState().levels.openaiRequest, 64);
});

View File

@@ -0,0 +1,38 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shortCallId } from "../../../src/shared/utils/formatting.ts";
// Regression guard: naive id.slice(0, 8) truncation showed as little as 3
// significant characters for ids with decorative prefixes (call-, call_call-,
// etc.), making tool-call/tool-result ids visually indistinguishable in the
// conversation context view. shortCallId finds the actual guid/hex entropy
// and truncates that instead, git-log-short-hash style.
test("extracts a git-log-style short id from a real UUID-shaped call id", () => {
assert.equal(shortCallId("call-bf7c895d-ae74-421c-890b-6f47eab77508"), "bf7c895");
});
test("extracts the first uuid segment from a composite/doubled-prefix id", () => {
assert.equal(
shortCallId("call_call-c5602375-cb46-40e8-867a-4b6ac50f6d2d_fc_cal_f08ecb5ec5"),
"c560237"
);
});
test("falls back to a leading hex run when there is no full uuid", () => {
assert.equal(shortCallId("toolu_01a2b3c4d5e6f7089900"), "01a2b3c");
});
test("falls back to a plain slice when the id has no recognizable hex/uuid segment", () => {
assert.equal(shortCallId("call_1"), "call_1");
});
test("respects a custom length", () => {
assert.equal(shortCallId("call-bf7c895d-ae74-421c-890b-6f47eab77508", 4), "bf7c");
});
test("returns an empty string for missing input", () => {
assert.equal(shortCallId(null), "");
assert.equal(shortCallId(undefined), "");
assert.equal(shortCallId(""), "");
});