cc-haha/desktop/src/components/trace/TraceSplitLayout.tsx
程序员阿江(Relakkes) d3d7566f0c refactor(trace): redesign trace UI with LangSmith-style two-pane layout
UI rebuild (desktop):
- TraceSession: replace 3-column layout with two panes — turn-grouped
  timeline tree (draggable splitter, search/filter, keyboard nav) and a
  section-flow detail panel (Response / Messages / System Prompt /
  Tools / Parameters / Raw), collapse state persists across spans
- Render LLM requests/responses semantically: messages as role-colored
  conversation with tool_use/tool_result pairing instead of raw JSON
  dumps; Raw fallback via CodeViewer for legacy truncated records
- TraceList: row-style list with model chips, mono metrics, hover
  actions; content-visibility rows (no virtualization, WebKit-safe)
- i18n synced across zh/en/jp/kr/zh-TW (+25/-55 keys)

Data & capture (server):
- Capture full bodies: preview cap 2048 -> 240k chars, stream cap
  256KB -> 1MB; list API trims previews to keep polling light; new
  GET /api/sessions/:id/trace/calls/:callId returns the full record
- Extract per-call token usage at read time (SSE + JSON + proxy
  wrapped); mtime-keyed read cache for the polling path
- Fix sensitive-key regex redacting *_tokens count fields, which made
  token stats always report 0

Frontend data layer:
- SSE stream reassembly (Anthropic + OpenAI chat) adapted from
  claude-tap (MIT, attribution in THIRD_PARTY_LICENSES.md), request/
  response body parsers, shared formatters, on-demand call detail
  cache; traceViewModel gains tokenUsage/isLifecycleNoise, drops
  fullRaw

Tested:
- bun run check:server (1201 pass)
- bun run check:desktop (lint + 1358 tests + build)
- Chromium walkthrough against real local traces: list, session tree,
  LLM semantic detail (new format), legacy fallback, tool detail
2026-06-11 01:02:51 +08:00

96 lines
3.3 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
const STORAGE_KEY = 'trace.treeWidth'
const DEFAULT_WIDTH = 380
const MIN_WIDTH = 280
const MAX_WIDTH = 560
function clampWidth(width: number): number {
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(width)))
}
function readStoredWidth(): number {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
const parsed = stored === null ? Number.NaN : Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? clampWidth(parsed) : DEFAULT_WIDTH
} catch {
return DEFAULT_WIDTH
}
}
function persistWidth(width: number) {
try {
window.localStorage.setItem(STORAGE_KEY, String(width))
} catch {
// localStorage unavailable (private mode); keep the in-memory width.
}
}
export function TraceSplitLayout({ tree, detail }: { tree: ReactNode; detail: ReactNode }) {
const [width, setWidth] = useState(readStoredWidth)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const widthRef = useRef(width)
widthRef.current = width
const onPointerMove = useCallback((event: MouseEvent) => {
const drag = dragRef.current
if (!drag) return
setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX)))
}, [])
const onPointerUp = useCallback(() => {
if (!dragRef.current) return
dragRef.current = null
persistWidth(widthRef.current)
document.body.style.removeProperty('cursor')
document.body.style.removeProperty('user-select')
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove])
const onDividerMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onPointerMove)
window.addEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
const onDividerDoubleClick = useCallback(() => {
setWidth(DEFAULT_WIDTH)
persistWidth(DEFAULT_WIDTH)
}, [])
useEffect(() => () => {
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
return (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row"
style={{ '--trace-tree-width': `${width}px` } as CSSProperties}
data-testid="trace-split-layout"
>
<div className="flex h-[40vh] min-h-0 shrink-0 flex-col overflow-hidden lg:h-auto lg:w-[var(--trace-tree-width)]">
{tree}
</div>
<div
role="separator"
aria-orientation="vertical"
data-testid="trace-split-divider"
onMouseDown={onDividerMouseDown}
onDoubleClick={onDividerDoubleClick}
className="group relative hidden w-px shrink-0 cursor-col-resize bg-[var(--color-border)] lg:block"
>
<div className="absolute inset-y-0 -left-[2px] w-[5px] transition-colors group-hover:bg-[var(--color-brand)]/25" />
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-t border-[var(--color-border)] lg:border-t-0">
{detail}
</div>
</div>
)
}