mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
96 lines
3.3 KiB
TypeScript
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>
|
|
)
|
|
}
|