diff --git a/scripts/test-client.mjs b/scripts/test-client.mjs index 1aa446a..3a7d341 100644 --- a/scripts/test-client.mjs +++ b/scripts/test-client.mjs @@ -354,4 +354,37 @@ tr = renderView() assert.equal(byClass(tr, 'lc-bar').length, 4, 'back to one bar per step') assert.equal(byClass(tr, 'lc-gran-on')[0].args[2], 'Step', 'step button active again') -console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle)') +// ---- edge fades signal reachable history beyond the viewport ---- +let scroller = byClass(tr, 'lc-chart-scroll')[0] +const fakeScroller = { scrollLeft: 200, clientWidth: 120, scrollWidth: 800 } +scroller.args[1].onScroll({ currentTarget: fakeScroller }) +tr = renderView() +assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade shown while scrolled into history') +assert.equal(byClass(tr, 'lc-chart-fade-r').length, 1, 'right fade shown while more bars follow') +fakeScroller.scrollLeft = 680 +scroller.args[1].onScroll({ currentTarget: fakeScroller }) +tr = renderView() +assert.equal(byClass(tr, 'lc-chart-fade-r').length, 0, 'right fade gone at the right end') +assert.equal(byClass(tr, 'lc-chart-fade-l').length, 1, 'left fade stays at the right end') +fakeScroller.scrollLeft = 0 +scroller.args[1].onScroll({ currentTarget: fakeScroller }) +tr = renderView() +assert.equal(byClass(tr, 'lc-chart-fade-l').length, 0, 'left fade gone at the start') + +// ---- no 80-bar cap: every request the host sends is rendered ---- +const bigRequests = [] +for (let i = 0; i < 120; i++) { + bigRequests.push({ + seq: 1000 + i, turn: 1 + Math.floor(i / 4), step: i % 4, time: 1000 * i, + system: 10, tools: 20, user: 30, inject: 5, assistant: 15, tool: 20, total: 100, + }) +} +ctxSlots[0][1]({ ...snapshot, requests: bigRequests }) +tr = renderView() +assert.equal(byClass(tr, 'lc-bar').length, 120, 'all requests render — earlier turns/steps stay reachable') +assert.equal(byClass(tr, 'lc-turn').length, 30, 'turn strip follows the full history') +ctxSlots[0][1](snapshot) // restore +tr = renderView() +assert.equal(byClass(tr, 'lc-bar').length, 4, 'snapshot restored') + +console.log('✔ chart render test passed (fixed-width bars, scroll container, turn ranges, hover linking, overview tooltip, turn strip, granularity toggle, edge fades, full history)') diff --git a/src/client.cts b/src/client.cts index 0d333ea..45c334e 100644 --- a/src/client.cts +++ b/src/client.cts @@ -142,7 +142,7 @@ const DICT_ZH: Record = { 'tools.more': '等 {n} 个', 'trend.title': '历史趋势', 'gran.step': '步骤', 'gran.turn': '轮次', - 'trend.hint': '每次模型请求一段;点击柱子查看详情,✂ 表示压缩/剪枝', + 'trend.hint': '每次模型请求一段;点击柱子查看详情,✂ 表示压缩/剪枝;左右滑动可回看更早', 'trend.empty': '发起一轮对话后,这里会展示每次模型请求的上下文构成', 'detail.step': 'T{t} · 第 {s} 步', 'detail.estTotal': '估算合计 ≈ {n}', @@ -184,7 +184,7 @@ const DICT_EN: Record = { 'tools.more': 'of {n}', 'trend.title': 'History', 'gran.step': 'Step', 'gran.turn': 'Turn', - 'trend.hint': 'one bar per model request; click a bar for details, ✂ marks compaction/prune', + 'trend.hint': 'one bar per model request; click a bar for details, ✂ marks compaction/prune; scroll sideways to see earlier', 'trend.empty': 'Send a message and each model request’s context makeup shows up here', 'detail.step': 'T{t} · step {s}', 'detail.estTotal': 'estimated ≈ {n}', @@ -408,11 +408,19 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re grp.count++ } - // Stick to the newest bars unless the user scrolled away from the end. + // Stick to the newest bars unless the user scrolled away from the end, + // and keep the edge fades in sync with the scroll position. const scrollRef = React.useRef(null) + const [edges, setEdges] = React.useState<{ left: boolean; right: boolean }>({ left: false, right: false }) + const updateEdges = (el: HTMLDivElement): void => { + const left = el.scrollLeft > 4 + const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 4 + setEdges(prev => (prev.left === left && prev.right === right) ? prev : { left, right }) + } React.useEffect(() => { const el = scrollRef.current if (el === null) return + updateEdges(el) if (el.scrollLeft + el.clientWidth >= el.scrollWidth - 24) el.scrollLeft = el.scrollWidth }) @@ -421,7 +429,14 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re h('span', { className: 'lc-axis-top' }, fmt(maxTotal)), h('span', { className: 'lc-axis-mid' }, fmt(Math.round(maxTotal / 2))), h('span', { className: 'lc-axis-bot' }, '0')), - h('div', { className: 'lc-chart-scroll', ref: scrollRef }, + h('div', { + className: 'lc-chart-scroll', + ref: scrollRef, + onScroll: (e: ReactNS.UIEvent) => { updateEdges(e.currentTarget) }, + }, + // Edge fades: visible whenever more history sits beyond the viewport, + // so the horizontal scroll affordance is obvious. + edges.left ? h('div', { className: 'lc-chart-fade lc-chart-fade-l' }) : null, // Hovering a bar previews it in the detail below; leaving the plot // clears the preview (a pinned selection, if any, takes over again). h('div', { className: 'lc-chart', onMouseLeave: () => { props.onHover(null) } }, @@ -476,7 +491,8 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re title: 'T' + grp.turn, onMouseEnter: () => { props.onHoverTurn(grp.turn) }, }, 'T' + grp.turn) - })))) + })), + edges.right ? h('div', { className: 'lc-chart-fade lc-chart-fade-r' }) : null)) } function RequestDetail(props: RequestDetailProps): ReactNS.ReactElement | null { @@ -653,7 +669,9 @@ function makeView(ctx: ClientCtx, t: Translate): (props: ContextViewProps) => Re ? h('div', { className: 'lc-empty' }, t('trend.empty')) : h('div', null, h(TrendChart, { - requests: displayRequests.slice(-80), + // The host caps the log at 160 requests; render them ALL so + // earlier turns/steps stay reachable via horizontal scroll. + requests: displayRequests, events, selectedSeq: pinnedReq ? pinnedReq.seq : null, hoveredSeq, @@ -709,10 +727,14 @@ const STYLES = [ '.lc-axis-top { top: 13px; }', '.lc-axis-mid { top: 69px; }', '.lc-axis-bot { top: 125px; }', - '.lc-chart-scroll { flex: 1; overflow-x: auto; overflow-y: hidden; min-width: 0; scrollbar-width: thin; }', - '.lc-chart-scroll::-webkit-scrollbar { height: 6px; }', - '.lc-chart-scroll::-webkit-scrollbar-thumb { background: var(--dsw-alias-border-l1); border-radius: 3px; }', + '.lc-chart-scroll { position: relative; flex: 1; overflow-x: auto; overflow-y: hidden; min-width: 0; scrollbar-width: thin; }', + '.lc-chart-scroll::-webkit-scrollbar { height: 8px; }', + '.lc-chart-scroll::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 45%, transparent); border-radius: 4px; }', + '.lc-chart-scroll::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--dsw-alias-label-secondary) 70%, transparent); }', '.lc-chart-scroll::-webkit-scrollbar-track { background: transparent; }', + '.lc-chart-fade { position: absolute; top: 0; bottom: 0; width: 26px; pointer-events: none; z-index: 2; }', + '.lc-chart-fade-l { left: 0; background: linear-gradient(to right, var(--dsw-alias-bg-layer-1), transparent); }', + '.lc-chart-fade-r { right: 0; background: linear-gradient(to left, var(--dsw-alias-bg-layer-1), transparent); }', '.lc-chart { position: relative; display: flex; align-items: flex-end; gap: 2px; height: 130px; padding-top: 18px; box-sizing: border-box; width: max-content; min-width: 100%; }', '.lc-grid { position: absolute; left: 0; right: 0; border-top: 1px dashed var(--dsw-alias-border-l1); pointer-events: none; }', '.lc-grid-top { top: 18px; }',