feat(client): cap the context browser at 3x the composition + trend cards' height

The DNA flat feed lays every message out at once, and an uncapped card would
outgrow the page. The browser column now measures the composition and trend
cards stacked beside it — per render, followed by a ResizeObserver for pane
drags and card reflows — and tops out at three times their sum, scrolling
taller content inside; the card head (title, composition bar, notices, and the
DNA feed's lens) stays pinned above the scrolling list. The /context modal
keeps its own single-card scroll and stays uncapped.
This commit is contained in:
bowenliang123 committed 2026-10-16 22:10:46 +08:00
1 parent 26cfc8052e
commit ef478dee24
5 files changed
+93 -6

No files matched your search

+1 -1
View File
@@ -145,7 +145,7 @@ Pick **Live (next request)** or any retained step, and browse what that request
- **Diff against the previous turn** — signed delta badges per category (`+N` items, `±Nk` tokens) tell you what a turn added or reclaimed at one glance. Steps older than a compaction are reconstructed from the removed-message archive — and the card says so when a step's makeup is only approximate.
- **DNA mode lays the whole context out flat** — the bar draws one band per item in the model's reading order, and below it the same items become one flat list with the newest message on top (the model reads oldest-first, so the list runs that order bottom-up): every row carries its prompt position (`#12`), category, introducing step, and token price, and expands into the same full content; the tool schemas and the system prompt close the list as two group rows.
- **DNA mode lays the whole context out flat** — the bar draws one band per item in the model's reading order, and below it the same items become one flat list with the newest message on top (the model reads oldest-first, so the list runs that order bottom-up): every row carries its prompt position (`#12`), category, introducing step, and token price, and expands into the same full content; the tool schemas and the system prompt close the list as two group rows. The card is capped at three times the height of the composition + trend cards beside it — a fully flattened session scrolls inside instead of flooding the page.
### ⚡ Context Events — when and why the window changed
+1 -1
View File
@@ -143,7 +143,7 @@ dsh plugin --profile web update dsh-context@latest
- **与上一轮对比** — 各类别的有符号差异徽标(`+N` 项、`±Nk` Token)让你一眼看出某轮新增或回收了什么。压缩前的旧步骤会从已移除消息存档中重建;若某步骤的组成仅为近似值,卡片会明确说明。
- **DNA 模式将上下文平铺展开** — 条形图按模型读取顺序逐项绘制色带,下方把同样的内容铺成一张平铺列表,最新的消息在最上(模型按从旧到新读取,列表即该顺序的倒排):每行带有其在提示词中的位置(`#12`)、类别、引入步骤和 Token 开销,展开后可见同样的完整内容;工具定义与系统提示词作为两个分组行收尾。
- **DNA 模式将上下文平铺展开** — 条形图按模型读取顺序逐项绘制色带,下方把同样的内容铺成一张平铺列表,最新的消息在最上(模型按从旧到新读取,列表即该顺序的倒排):每行带有其在提示词中的位置(`#12`)、类别、引入步骤和 Token 开销,展开后可见同样的完整内容;工具定义与系统提示词作为两个分组行收尾。卡片高度上限为旁边组成卡片 + 趋势卡片高度的 3 倍——完整平铺的会话在卡片内滚动,而不会淹没整个页面。
### ⚡ 上下文事件 — 上下文窗口何时以及为何发生变化
+41 -3
View File
@@ -55,6 +55,16 @@ function revealBrowser(root: HTMLElement | null): void {
/* v8 ignore stop */
}
/** The Context browser's height ceiling: three times the left column's stacked cards beside it (the
* current-composition card plus the trend card). The DNA flat feed lays every message out at once, and an
* uncapped card would outgrow the page; taller content scrolls inside the column. Null — uncapped — while
* no card measures (the loading frame). */
export function browserCapOf(cardHeights: readonly number[]): number | null {
let sum = 0
for (const h of cardHeights) sum += h
return sum > 0 ? Math.round(sum * 3) : null
}
/** The trend card's reserved body: the plot lane plus an empty detail panel wearing the panel's OWN classes, so the
* pending and loaded states are measured by the same CSS and cannot drift. Without it the first step's arrival grew
* the card by its whole height and shoved every card below it down the page. */
@@ -172,6 +182,30 @@ export function makeContextView(
const restoredRef = useRef<string | null>(null)
const [catFocus, setCatFocus] = useState<CatFocus | null>(null)
const clearCatFocus = useCallback(() => { setCatFocus(null) }, [])
// The browser column's height cap — 3× the composition + trend cards beside it (browserCapOf) — re-measured
// per render and followed by a ResizeObserver, since pane drags and card reflows change no prop.
const leftColRef = useRef<HTMLDivElement | null>(null)
const [browserCap, setBrowserCap] = useState<number | null>(null)
const [, setCapTick] = useState(0)
useLayoutEffect(() => {
const col = leftColRef.current
/* v8 ignore next 1 -- the loading frame renders no left column; every carded render attaches it. */
if (col === null) return
// The column renders exactly the composition + trend cards; 3× their stacked height is the cap.
const next = browserCapOf([...col.children].map(el => el.getBoundingClientRect().height))
setBrowserCap(prev => (prev === next ? prev : next))
})
/* v8 ignore start -- jsdom has neither ResizeObserver nor layout: the cap's math is pinned by browserCapOf's unit tests. */
useEffect(() => {
const col = leftColRef.current
if (col === null || typeof ResizeObserver !== 'function') return
const observer = new ResizeObserver(() => { setCapTick(tick => tick + 1) })
observer.observe(col)
for (const el of col.querySelectorAll(':scope > .lc-card')) observer.observe(el)
return () => { observer.disconnect() }
}, [data])
/* v8 ignore stop */
const onFigureClick = useCallback((figure: 'skills' | 'answers'): void => {
setCatFocus(figure === 'skills' ? { cat: 'skill' } : { cat: 'assistant', kind: 'answer' })
revealBrowser(rootRef.current)
@@ -621,9 +655,13 @@ export function makeContextView(
</div>
<div className="lc-cols lc-cols-main">
<div className="lc-col flex-1 min-w-[min(360px,100%)]">{compositionCard}{trendCard}</div>
{/* Stretches the browser card to the left column's height; the /context modal draws its own stack and stays content-sized. */}
<div className="lc-col lc-col-browser flex-1 min-w-[min(360px,100%)]">{browserCard}</div>
<div className="lc-col flex-1 min-w-[min(360px,100%)]" ref={leftColRef}>{compositionCard}{trendCard}</div>
{/* Stretches the browser card to the left column's height; the /context modal draws its own stack and stays content-sized.
Capped at 3× the left cards' height (browserCapOf): the DNA feed lays out every message, so it scrolls within. */}
<div
className="lc-col lc-col-browser flex-1 min-w-[min(360px,100%)]"
style={browserCap !== null ? { maxHeight: browserCap, overflowY: 'auto' } : undefined}
>{browserCard}</div>
</div>
<div className="lc-cols">
@@ -1,10 +1,11 @@
import { act, createElement as h } from 'react'
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import type { UseChatLike } from '../../../src/client/services'
import { DICT_EN } from '../../../src/client/i18n'
import { TestClientCtx } from '../helpers/harness'
import { click, flush, hover, mount, query, queryAll, text, unhover } from '../helpers/kit'
import { buttonByText, makeView, mountRich, projectionsFor, T0, timeline } from './contextViewHarness'
import { buttonByText, makeView, mountRich, projectionsFor, richTimeline, T0, timeline } from './contextViewHarness'
describe('ContextView — interactions', () => {
test('renders the rich tab: stats, chart bars, markers, events, nodes, browser', async () => {
@@ -22,6 +23,40 @@ describe('ContextView — interactions', () => {
await m.unmount()
})
test('the browser column caps at three times the composition + trend card heights, live', async () => {
// jsdom has no layout: stand in a measured height for the two left cards only, then grow it, so both
// the apply and the re-measure arms of the cap run.
const orig = Element.prototype.getBoundingClientRect
let cardH = 100
Element.prototype.getBoundingClientRect = function (this: Element) {
const box = orig.call(this)
const leftCard = this.classList.contains('lc-card') && this.parentElement?.classList.contains('lc-col')
&& !this.parentElement.classList.contains('lc-col-browser')
return leftCard ? { ...box, height: cardH } : box
} as never
try {
// One View instance across the update, so the second render re-measures in place.
const View = makeView(new TestClientCtx())
const el = () => h(View, {
sessionId: 'sv-cap',
useProjection: projectionsFor(richTimeline(), {
contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] },
}),
useChat: (sel => sel({ legacy: { nodes: [] } })) as UseChatLike,
})
const m = await mount(el())
const col = query<HTMLElement>(m.container, '.lc-col-browser')
assert.equal(col.style.maxHeight, '600px', '3 × (composition 100 + trend 100)')
assert.equal(col.style.overflowY, 'auto')
cardH = 150
await m.update(el())
assert.equal(col.style.maxHeight, '900px', 'the cap follows the cards on the next render')
await m.unmount()
} finally {
Element.prototype.getBoundingClientRect = orig
}
})
test('bar hover previews in the browser; hover loss on a granularity switch falls back', async () => {
const m = await mountRich('sv-hover')
const chart = query(m.container, '.lc-chart')
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import { describe, test } from 'vitest'
import { browserCapOf } from '../../../src/client/components/contextView'
describe('browserCapOf — the Context browser column\'s height ceiling', () => {
test('three times the stacked composition + trend card heights, rounded', () => {
assert.equal(browserCapOf([180.4, 321.6]), 1506)
})
test('no measurable card (the loading frame) leaves the column uncapped', () => {
assert.equal(browserCapOf([]), null)
assert.equal(browserCapOf([0, 0]), null)
})
})