diff --git a/config/quality/dependency-allowlist.json b/config/quality/dependency-allowlist.json index 1a6097e13c..5b7d3e2256 100644 --- a/config/quality/dependency-allowlist.json +++ b/config/quality/dependency-allowlist.json @@ -119,6 +119,7 @@ "react", "react-dom", "react-is", + "react18-json-view", "react-markdown", "react-reconciler", "recharts", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index a602c0a80e..ca2372b50c 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -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.", diff --git a/package-lock.json b/package-lock.json index 4214aef2e4..746a2500bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index e75bea536d..0f29b3ac4b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx index 6d01759e8f..2a4acc810d 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolCallBlock.tsx @@ -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"} {name} - {id.slice(0, 8)} + + {shortCallId(id)} + {expanded && (
diff --git a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx index 1066d3590c..f6a3261e17 100644 --- a/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx +++ b/src/app/(dashboard)/dashboard/tools/traffic-inspector/components/chat/ToolResultBlock.tsx @@ -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"} tool_result - {toolUseId.slice(0, 8)} + + {shortCallId(toolUseId)} + {expanded && (
diff --git a/src/app/globals.css b/src/app/globals.css index 5a2b558236..e3d5a6b768 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9b97e3c488..b2f7281a19 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index 562f121d5a..2bbeec032f 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -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", diff --git a/src/shared/components/JsonTreeExpandControls.tsx b/src/shared/components/JsonTreeExpandControls.tsx new file mode 100644 index 0000000000..6f740fd143 --- /dev/null +++ b/src/shared/components/JsonTreeExpandControls.tsx @@ -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 ( +
+ + + + {level} + + + +
+ ); +} diff --git a/src/shared/components/RequestLoggerDetail.sections.tsx b/src/shared/components/RequestLoggerDetail.sections.tsx index d6441dedf4..3c0f10f345 100644 --- a/src/shared/components/RequestLoggerDetail.sections.tsx +++ b/src/shared/components/RequestLoggerDetail.sections.tsx @@ -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
 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 (
     
@@ -40,18 +79,35 @@ export function PayloadSection({ title, json, onCopy, collapsible = true, defaul )}
- +
+ + {parsedJson !== null && } +
- {open && ( + {open && parsedJson !== null && ( +
+ +
+ )} + {open && parsedJson === null && (
           {json}
         
diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index 2da720a730..45fd9214e6 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -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 (
@@ -144,14 +197,33 @@ function StreamSection({ title, json, onCopy }) { {copied ? t("copied") : t("copy")} + {segments.some((s) => s.type === "json") && ( + + )}
{open && (
- {json} + {segments.map((segment, i) => + segment.type === "json" ? ( +
+ +
+ ) : ( + + {segment.value} + + ) + )}
)}
@@ -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({
e.stopPropagation()} > @@ -722,7 +812,7 @@ export default function RequestLoggerDetail({ {detail?.comboName || log.comboName} ) : ( -
\u2014
+
{"\u2014"}
)}
@@ -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"}
) : ( -
\u2014
+
{"\u2014"}
)}
@@ -915,6 +1006,7 @@ export default function RequestLoggerDetail({ {streamChunks && streamChunks.provider && ( onCopy(section.json)} /> @@ -982,6 +1077,7 @@ export default function RequestLoggerDetail({ {payloadSections.length === 0 && responseJson && ( onCopy(responseJson)} /> @@ -990,6 +1086,7 @@ export default function RequestLoggerDetail({ {payloadSections.length === 0 && requestJson && ( onCopy(requestJson)} /> diff --git a/src/shared/hooks/useTimestampTitles.ts b/src/shared/hooks/useTimestampTitles.ts new file mode 100644 index 0000000000..bad4511182 --- /dev/null +++ b/src/shared/hooks/useTimestampTitles.ts @@ -0,0 +1,72 @@ +"use client"; + +import { useEffect, type RefObject } from "react"; +import type { JsonViewProps } from "react18-json-view"; + +type CustomizeNode = NonNullable; + +// 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, active: boolean) { + useEffect(() => { + const container = containerRef.current; + if (!active || !container) return; + + const annotate = () => { + const values = container.querySelectorAll(`.${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]); +} diff --git a/src/shared/utils/formatting.ts b/src/shared/utils/formatting.ts index 100321d897..bb6091c4f2 100644 --- a/src/shared/utils/formatting.ts +++ b/src/shared/utils/formatting.ts @@ -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-_fc_cal_) 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, diff --git a/src/store/jsonTreeExpandStore.ts b/src/store/jsonTreeExpandStore.ts new file mode 100644 index 0000000000..17b25bd288 --- /dev/null +++ b/src/store/jsonTreeExpandStore.ts @@ -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; + 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()( + 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; diff --git a/tests/unit/dashboard/payload-section-collapsible-json.test.tsx b/tests/unit/dashboard/payload-section-collapsible-json.test.tsx new file mode 100644 index 0000000000..3154580acc --- /dev/null +++ b/tests/unit/dashboard/payload-section-collapsible-json.test.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom +// +// Regression guard: PayloadSection rendered every payload (request/response +// bodies, pipeline stages) as a flat
 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
+// 
 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(
+      
+    );
+  });
+}
+
+describe("PayloadSection collapsible JSON rendering", () => {
+  it("renders a valid JSON object payload as a collapsible tree, not a flat 
 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 
 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 
 dump for a JSON primitive (not an object/array)", () => {
+    renderPayload(JSON.stringify("just a string"));
+
+    const pre = container.querySelector("pre");
+    expect(pre).not.toBeNull();
+  });
+});
diff --git a/tests/unit/dashboard/timestamp-titles.test.tsx b/tests/unit/dashboard/timestamp-titles.test.tsx
new file mode 100644
index 0000000000..15c6c645d2
--- /dev/null
+++ b/tests/unit/dashboard/timestamp-titles.test.tsx
@@ -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(
+      
+    );
+  });
+}
+
+function findValueSpan(text: string): HTMLElement | undefined {
+  return Array.from(container.querySelectorAll("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");
+  });
+});
diff --git a/tests/unit/shared/json-tree-expand-store.test.ts b/tests/unit/shared/json-tree-expand-store.test.ts
new file mode 100644
index 0000000000..b202e27d3f
--- /dev/null
+++ b/tests/unit/shared/json-tree-expand-store.test.ts
@@ -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);
+});
diff --git a/tests/unit/shared/short-call-id.test.ts b/tests/unit/shared/short-call-id.test.ts
new file mode 100644
index 0000000000..850f49786e
--- /dev/null
+++ b/tests/unit/shared/short-call-id.test.ts
@@ -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(""), "");
+});