feat(client): open the Context tab from the chat stats line

The harness stats row (the composer dock's own `stats` entry) gains a
hover underline and opens the Context tab on click or Enter/Space. The
plugin mounts an invisible sibling entry on the officially plugin-facing
`conversation.composer.dock` seat; its anchor spots the single stats row
beside it inside the slot anchor, decorates it in place, and re-spots it
on dock churn (the row mounts only after the first stats exist, and
tooltip bubbles come and go beside it). A drag-selection click is
ignored so copying the numbers never switches tabs.

Every shape without a spotless row degrades to a silent feature-off:
a missing dock seat, ambiguous candidates (other plugins' entries,
crash faces, non-HTML markup), and rows that throw mid-wiring — and
unmount strips every mark and listener. The seat is pinned in the
compat matrix (SLOT_SEAMS) and the bundle smoke counts the new entry.
This commit is contained in:
bowenliang123 committed 2026-09-04 18:27:01 +08:00
1 parent 33068ee1c9
commit fbf32ecc67
8 files changed
+496 -4

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
/**
* The chat page's stats line — the harness's own StatsLine entry on the
* `conversation.composer.dock` seat — becomes a jump affordance: hovering
* underlines it, a click (or Enter/Space) opens the Context tab. The plugin
* wires this from a sibling dock entry that renders no UI of its own: an
* invisible anchor mounts inside the slot's `[data-slot]` wrapper and spots
* the stats row beside it, and a MutationObserver re-spots it on dock churn
* (the row mounts only once the first stats exist, tooltip bubbles come and
* go beside it, and switching conversations remounts the pair together).
*
* EVERY step degrades silently: a host without the dock seat never mounts
* the entry; a dock without exactly one spotless candidate row (another
* plugin's entry, a crash face, an unexpected layout) decorates nothing; a
* row that throws mid-wiring stays unwired; a drag-selection click is
* ignored. The affordance is simply absent — nothing here can take the chat
* page down.
*/
import type * as ReactNS from 'react'
import { React, h } from '../react'
import { activateContextTab } from '../viewFocus'
import type { ViewKit } from '../viewkit'
/** The plugin's invisible dock anchor, and the class it brands the stats row with. */
const ANCHOR_CLASS = 'lc-stats-jump'
const ROW_CLASS = 'lc-stats-jump-row'
/**
* The stats row beside `anchor`: the single element child that is neither
* the anchor nor a tooltip bubble. Children are untrusted input — one that
* throws on attribute access is skipped whole, and two live candidates at
* once (another plugin's dock entry, a crash face) answer null so nothing
* is ever decorated on a guess.
*/
export function statsRowOf(anchor: Element): Element | null {
const parent = anchor.parentElement
if (parent === null) return null
let row: Element | null = null
for (const child of parent.children) {
try {
if (child === anchor || child.getAttribute('role') === 'tooltip') continue
} catch { continue }
if (row !== null) return null
row = child
}
return row
}
/** The listeners and strip for one wired row. */
interface Wired {
row: Element
off(): void
}
/**
* Wire the dock beside `anchor`: decorate today's stats row and re-spot it
* on dock churn. Returns the teardown that disconnects the observer and
* strips every mark the wiring left on the row.
*/
export function wireDock(anchor: Element | null, label: string, activate: () => void): () => void {
if (anchor === null) return () => {}
let wired: Wired | null = null
const unwire = (): void => {
wired?.off()
wired = null
}
const scan = (): void => {
try {
const row = statsRowOf(anchor)
if (wired?.row === row) return
unwire()
// The typed key listener below needs a real HTML element; an exotic
// candidate (SVG, foreign markup) is not the stats row — feature off.
if (row === null || !(row instanceof HTMLElement)) return
row.classList.add(ROW_CLASS)
row.setAttribute('role', 'button')
row.setAttribute('tabindex', '0')
row.setAttribute('aria-label', label)
const onKey = (e: KeyboardEvent): void => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
activate()
}
const onClick = (): void => {
// A drag that started on the numbers must not switch tabs on release.
const selection = anchor.ownerDocument.getSelection()
if (selection !== null && !selection.isCollapsed) return
activate()
}
row.addEventListener('click', onClick)
row.addEventListener('keydown', onKey)
wired = {
row,
off(): void {
row.removeEventListener('click', onClick)
row.removeEventListener('keydown', onKey)
row.classList.remove(ROW_CLASS)
row.removeAttribute('role')
row.removeAttribute('tabindex')
row.removeAttribute('aria-label')
},
}
} catch { /* a hostile dock or row throws mid-wiring: stay unwired */ }
}
scan()
const parent = anchor.parentElement
if (parent === null) return unwire
const observer = new MutationObserver(scan)
observer.observe(parent, { childList: true })
return () => {
observer.disconnect()
unwire()
}
}
/** The dock entry component: the invisible anchor plus the wiring around it. */
export function makeStatsJump(kit: ViewKit): () => ReactNS.ReactElement {
const { t } = kit
return function StatsJump(): ReactNS.ReactElement {
const anchorRef = React.useRef<Element | null>(null)
const label = t('jump.statsLine')
React.useEffect(() => wireDock(anchorRef.current, label, () => { activateContextTab(t('tab')) }), [label])
return h('span', { ref: anchorRef, className: ANCHOR_CLASS, hidden: true, 'aria-hidden': 'true' })
}
}
+2
View File
@@ -76,6 +76,7 @@ export const DICT_ZH: Record<string, string> = {
'brief.noInputs': '(无新增)',
'brief.locate': '在上下文浏览器中查看',
'jump.title': '在上下文标签页中查看此轮',
'jump.statsLine': '在上下文标签页中查看上下文构成',
'brief.turnTip': '这一轮开场时用户发送的消息;回合内的每一步都会回显同一条',
'brief.inputTip': '上一步回复之后、本次请求发出前新进入上下文的内容——通常是上一步所调工具的结果;每轮的第一步无新增',
'brief.replyTip': '这一步模型返回的内容(文本回复或工具调用);此步发起的调用,其结果会出现在下一柱的「输入」行',
@@ -291,6 +292,7 @@ export const DICT_EN: Record<string, string> = {
'brief.noInputs': '(nothing new)',
'brief.locate': 'Reveal in Context Browser',
'jump.title': 'View this turn in the Context tab',
'jump.statsLine': 'View the context composition in the Context tab',
'brief.turnTip': 'The user message that opened this turn — every step of the turn recalls it',
'brief.inputTip': 'What entered after the previous reply and before this request went out — usually the previous tool-call results; a turn opener has nothing new',
'brief.replyTip': 'What the model returned on this step (a text reply or tool calls) — results of calls made here appear in the In row of the next bar',
+16
View File
@@ -30,6 +30,7 @@ import type { ClientCtx } from './services'
import { createContextSettings, type SettingsField, type SettingsScopeBinderFace } from './settings'
import { makeContextView } from './components/contextView'
import { makeContextJumpButton } from './components/contextJump'
import { makeStatsJump } from './components/statsJump'
import { watchHistoryFaces } from './historyPage'
import { makeViewKit } from './viewkit'
@@ -99,6 +100,21 @@ function apply(ctx: ClientCtx): void {
)
})
// Stats-line jump: the harness's chat stats row (the composer dock's own
// `stats` entry) gains a hover underline and opens this tab on click. The
// plugin contributes a sibling dock entry that only wires the row beside
// it (see statsJump.tsx) — a host whose dock lacks the row keeps the
// affordance silently absent.
const StatsJump = makeStatsJump(kit)
ctx.slots.inject('conversation.composer.dock', () => {
return ctx.slots.register(
// Below the shipped stats entry (order 0), inside the dock's slot
// anchor: invisible — behavior only.
{ name: 'conversation.composer.dock', id: 'stats-jump', order: 10, locale: NS },
props => h(StatsJump, props),
)
})
// `/context` slash command: opens the context modal (see command.ts for
// the trigger source). The modal itself renders from the input overlay
// slot, opened per session through the hooks-compartment store.
+5
View File
@@ -3,3 +3,8 @@
.lc-jump { display: inline-flex; align-items: center; justify-content: center; width: calc(28px + var(--dsh-content-font-delta, 0px)); height: calc(28px + var(--dsh-content-font-delta, 0px)); padding: 6px; border: 0; border-radius: 28px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; transition: background-color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out), color var(--ds-transition-duration, 0.2s) var(--ds-ease-in-out, ease-in-out); }
.lc-jump svg { width: calc(16px + var(--dsh-content-font-delta, 0px)); height: calc(16px + var(--dsh-content-font-delta, 0px)); }
.lc-jump:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
/* The chat stats-line jump (statsJump.tsx): the harness stats row is decorated in place — pointer cursor with a hover/focus underline; the plugin's own dock anchor renders nothing. */
.lc-stats-jump { display: none; }
.lc-stats-jump-row { cursor: pointer; }
.lc-stats-jump-row:hover, .lc-stats-jump-row:focus-visible { text-decoration: underline; }
+313
View File
@@ -0,0 +1,313 @@
// StatsJump (src/client/components/statsJump.tsx) — the stats-line jump:
// the invisible dock anchor decorates the harness stats row beside it, the
// row's click/keys open the Context tab through the real viewFocus module,
// and every hostile or absent dock shape degrades to a silent feature-off.
// jsdom supplies the real dock DOM and its live MutationObserver.
import assert from 'node:assert/strict'
import { act } from 'react'
import type { ReactNode } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, test } from 'vitest'
import { makeStatsJump, statsRowOf, wireDock } from '../../../src/client/components/statsJump'
import { DICT_EN } from '../../../src/client/i18n'
import { h } from '../../../src/client/react'
import { makeKit } from '../helpers/kit'
const StatsJump = makeStatsJump(makeKit())
/** Let jsdom deliver pending MutationObserver records. */
const tick = (): Promise<void> => new Promise(resolve => { setTimeout(resolve, 0) })
/** A bare dock element carrying `rowCount` shipped stats rows. */
function dockEl(rowCount = 1): { dock: HTMLElement; rows: HTMLElement[] } {
const dock = document.createElement('div')
dock.setAttribute('data-slot', 'conversation.composer.dock')
const rows: HTMLElement[] = []
for (let i = 0; i < rowCount; i++) {
const row = document.createElement('div')
row.textContent = `1 turns · ${i + 1} steps`
dock.appendChild(row)
rows.push(row)
}
document.body.appendChild(dock)
return { dock, rows }
}
/**
* A faithful dock for the full component: the harness slot anchor rendered
* by React in ONE commit — the shipped stats row(s) and the plugin entry as
* siblings inside it, the way the conversation shell mounts them (rendering
* straight into a pre-populated container would let React 18 clear the
* pre-existing row). `renderRows` re-renders to simulate the stats row
* mounting, unmounting, or being replaced by its own entry.
*/
async function mountDock(rowCount = 1): Promise<{
dock: HTMLElement
rows(): HTMLElement[]
renderRows(count: number): Promise<void>
un(): Promise<void>
}> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
const renderRows = async (count: number): Promise<void> => {
const rows: ReactNode[] = []
for (let i = 0; i < count; i++) rows.push(h('div', { key: i }, `1 turns · ${i + 1} steps`))
await act(async () => {
root.render(h('div', { 'data-slot': 'conversation.composer.dock' }, ...rows, h(StatsJump, {})))
})
}
const un = async (): Promise<void> => {
await act(async () => {
root.unmount()
})
container.remove()
}
await renderRows(rowCount)
const dock = container.firstElementChild as HTMLElement
const rows = (): HTMLElement[] =>
[...dock.children].filter(el => !el.classList.contains('lc-stats-jump') && el.getAttribute('role') !== 'tooltip') as HTMLElement[]
return { dock, rows, renderRows, un }
}
/** A Context tab button the jump can activate, with its native click count. */
function contextTab(): { el: HTMLButtonElement; count(): number } {
const el = document.createElement('button')
el.setAttribute('role', 'tab')
el.textContent = 'Context'
let n = 0
el.addEventListener('click', () => { n++ })
document.body.appendChild(el)
return { el, count: () => n }
}
/** The full decoration the wiring must leave on a live stats row. */
function assertWired(row: Element): void {
assert.ok(row.classList.contains('lc-stats-jump-row'), 'the row carries the jump class')
assert.equal(row.getAttribute('role'), 'button')
assert.equal(row.getAttribute('tabindex'), '0')
assert.equal(row.getAttribute('aria-label'), DICT_EN['jump.statsLine'])
}
describe('statsRowOf', () => {
afterEach(() => {
document.body.textContent = ''
})
test('answers the single non-bubble element child beside the anchor', () => {
const { dock, rows } = dockEl()
const anchor = document.createElement('span')
dock.appendChild(anchor)
const bubble = document.createElement('span')
bubble.setAttribute('role', 'tooltip')
dock.appendChild(bubble)
assert.equal(statsRowOf(anchor), rows[0])
})
test('answers null for a detached anchor and for ambiguous docks', () => {
assert.equal(statsRowOf(document.createElement('span')), null)
const { dock } = dockEl(2)
const anchor = document.createElement('span')
dock.appendChild(anchor)
assert.equal(statsRowOf(anchor), null, 'two live candidates: decorate nothing')
})
test('skips a hostile child that throws on attribute access', () => {
const { dock, rows } = dockEl()
const hostile = document.createElement('div')
Object.defineProperty(hostile, 'getAttribute', { get() { throw new Error('boom') } })
dock.appendChild(hostile)
const anchor = document.createElement('span')
dock.appendChild(anchor)
assert.equal(statsRowOf(anchor), rows[0])
})
})
describe('wireDock', () => {
afterEach(() => {
document.body.textContent = ''
})
test('a missing or detached anchor wires nothing and stays a no-op teardown', () => {
assert.doesNotThrow(wireDock(null, 'label', () => {}))
const lone = document.createElement('i')
let activated = 0
const off = wireDock(lone, 'label', () => { activated++ })
lone.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(activated, 0)
assert.doesNotThrow(off)
})
test('a row that throws at the first mark stays unwired and swallows the throw', () => {
const { dock, rows } = dockEl()
const { count } = contextTab()
Object.defineProperty(rows[0], 'classList', { get() { throw new Error('boom') } })
const anchor = document.createElement('span')
dock.appendChild(anchor)
const teardown = wireDock(anchor, 'label', () => {})
rows[0].dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 0, 'no listener survived the failed wiring')
assert.equal(rows[0].getAttribute('role'), null)
teardown()
})
test('a row that throws partway leaves a half-dressed row with no active jump', () => {
const { dock, rows } = dockEl()
const { count } = contextTab()
Object.defineProperty(rows[0], 'setAttribute', { get() { throw new Error('boom') } })
const anchor = document.createElement('span')
dock.appendChild(anchor)
const teardown = wireDock(anchor, 'label', () => {})
assert.ok(rows[0].classList.contains('lc-stats-jump-row'), 'the class mark landed before the throw')
assert.equal(rows[0].getAttribute('role'), null)
rows[0].dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 0)
teardown()
})
test('an exotic non-HTML candidate is not the stats row: feature off', () => {
const { dock } = dockEl(0)
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
dock.appendChild(svg)
const anchor = document.createElement('span')
dock.appendChild(anchor)
const teardown = wireDock(anchor, 'label', () => {})
assert.equal(svg.getAttribute('role'), null)
teardown()
})
})
describe('StatsJump — wiring and activation', () => {
afterEach(() => {
document.body.textContent = ''
})
test('renders only an invisible anchor beside the row', async () => {
const { dock, un } = await mountDock()
const anchor = dock.querySelector('span.lc-stats-jump')
assert.ok(anchor !== null)
assert.equal(anchor.getAttribute('hidden'), '')
assert.equal(anchor.getAttribute('aria-hidden'), 'true')
assert.equal(anchor.textContent, '')
await un()
})
test('decorates the row at mount; click and Enter/Space open the Context tab', async () => {
const { rows, un } = await mountDock()
const { count } = contextTab()
const row = rows()[0]
assert.ok(row !== undefined)
assertWired(row)
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 1, 'a plain click activates the jump')
const enter = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true })
row.dispatchEvent(enter)
assert.equal(count(), 2)
assert.ok(enter.defaultPrevented)
const space = new KeyboardEvent('keydown', { key: ' ', bubbles: true, cancelable: true })
row.dispatchEvent(space)
assert.equal(count(), 3)
assert.ok(space.defaultPrevented, 'space does not scroll the page')
const other = new KeyboardEvent('keydown', { key: 'a', bubbles: true, cancelable: true })
row.dispatchEvent(other)
assert.equal(count(), 3, 'other keys do not activate')
assert.ok(!other.defaultPrevented)
await un()
})
test('a stats row that mounts after the entry is spotted through the observer', async () => {
const { dock, un } = await mountDock(0)
const { count } = contextTab()
const row = document.createElement('div')
row.textContent = '1 turns · 1 steps'
dock.appendChild(row)
await tick()
assertWired(row)
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 1)
await un()
})
test('tooltip-bubble churn keeps exactly one listener on the row', async () => {
const { dock, rows, un } = await mountDock()
const { count } = contextTab()
const bubble = document.createElement('span')
bubble.setAttribute('role', 'tooltip')
dock.appendChild(bubble)
await tick()
dock.removeChild(bubble)
await tick()
const row = rows()[0]
assert.ok(row !== undefined)
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 1, 'the rescans rewired nothing')
await un()
})
test('a replaced row is unwired whole and its successor takes over', async () => {
const { dock, rows, renderRows, un } = await mountDock()
const { count } = contextTab()
const retired = rows()[0]
assert.ok(retired !== undefined)
assertWired(retired)
await renderRows(0)
const fresh = document.createElement('div')
dock.appendChild(fresh)
await tick()
assert.equal(retired.className, '', 'the retired row is stripped clean')
assert.equal(retired.getAttribute('role'), null)
retired.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 0, 'the retired row lost its listener')
assertWired(fresh)
fresh.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 1)
assert.equal(rows().length, 1, 'only the fresh row remains a candidate')
await un()
})
test('an ambiguous dock decorates nothing', async () => {
const { rows, un } = await mountDock(2)
for (const row of rows()) assert.equal(row.getAttribute('role'), null)
await un()
})
test('a drag-selection click does not switch tabs', async () => {
const { rows, un } = await mountDock()
const { count } = contextTab()
const row = rows()[0]
assert.ok(row !== undefined)
const doc = document as unknown as { getSelection: () => unknown }
const original = doc.getSelection
doc.getSelection = () => ({ isCollapsed: false })
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 0, 'an open selection swallows the click')
doc.getSelection = original
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 1, 'a clean click still activates')
await un()
})
test('unmount strips the marks, the listeners, and the observer', async () => {
const { dock, rows, un } = await mountDock()
const { count } = contextTab()
const row = rows()[0]
assert.ok(row !== undefined)
assertWired(row)
await un()
assert.equal(row.className, '')
assert.equal(row.getAttribute('role'), null)
assert.equal(row.getAttribute('tabindex'), null)
assert.equal(row.getAttribute('aria-label'), null)
row.dispatchEvent(new MouseEvent('click', { bubbles: true }))
assert.equal(count(), 0, 'the listener went with the wiring')
const fresh = document.createElement('div')
dock.appendChild(fresh)
await tick()
assert.equal(fresh.className, '', 'the disconnected observer decorates nothing')
})
})
+25
View File
@@ -128,6 +128,31 @@ describe('client entry: assistant-actions seat', () => {
})
})
describe('client entry: composer dock seat', () => {
test('registers the stats-line jump entry below the shipped stats row', async () => {
const ctx = new TestClientCtx()
applyTo(ctx)
const entries = ctx.slots.of('conversation.composer.dock')
assert.equal(entries.length, 1)
const { registration, component } = entries[0]
assert.equal(registration.name, 'conversation.composer.dock')
assert.equal(registration.id, 'stats-jump')
assert.equal(registration.order, 10, 'below the shipped stats entry (order 0)')
assert.equal(registration.locale, 'dsh-context')
// The entry renders only the invisible anchor: this bare mount has no
// stats row beside it, so nothing gets decorated.
const el = component({}) as ReactElement
assert.equal(typeof el.type, 'function')
assert.equal((el.type as { name: string }).name, 'StatsJump')
const m = await mount(el)
assert.equal(queryAll(m.container, 'span.lc-stats-jump[hidden]').length, 1)
assert.equal(queryAll(m.container, '.lc-stats-jump-row').length, 0)
await m.unmount()
ctx.dispose()
})
})
describe('client entry: sessions scope seam', () => {
test('absent sessions service: apply is a noop for the sessions seams', () => {
const ctx = new TestClientCtx()
+7 -4
View File
@@ -126,16 +126,19 @@ describe.skipIf(staging.artifactsMissing())('bundle smoke — the built lib/clie
assert.ok(!css.includes('120ms ease'), 'lightningcss minified the sheet')
})
test('registrations: bilingual dictionaries, three slots, the /context trigger source', () => {
test('registrations: bilingual dictionaries, four slots, the /context trigger source', () => {
assert.ok(state.dicts.get('dsh-context')?.zh && state.dicts.get('dsh-context')?.en, 'bilingual dictionaries registered')
assert.equal(state.slots.length, 3, 'view tab + assistant action + input overlay slots')
assert.equal(state.slots.length, 4, 'view tab + assistant action + composer dock + input overlay slots')
assert.equal(state.slots[0]?.[0], 'conversation.view')
assert.equal(state.slots[0]?.[1].order, 20)
assert.equal(state.slots[0]?.[1].label?.(), '上下文', 'tab label localized')
assert.equal(state.slots[1]?.[0], 'conversation.chat.assistant-actions')
assert.equal(state.slots[1]?.[1].id, 'context-jump', 'jump action rides its own slot id')
assert.equal(state.slots[2]?.[0], 'conversation.input.overlay')
const overlayInject = state.slots[2]?.[1].inject
assert.equal(state.slots[2]?.[0], 'conversation.composer.dock')
assert.equal(state.slots[2]?.[1].id, 'stats-jump', 'stats-line jump rides its own slot id')
assert.equal(state.slots[2]?.[1].order, 10, 'below the shipped stats entry (order 0)')
assert.equal(state.slots[3]?.[0], 'conversation.input.overlay')
const overlayInject = state.slots[3]?.[1].inject
const overlayHooks = overlayInject?.('s1') as { hooks: { contextModal: { getSnapshot: unknown } } } | undefined
assert.equal(typeof overlayHooks?.hooks.contextModal.getSnapshot, 'function', 'overlay hooks carry the modal store')
assert.equal(state.sources.length, 1, '/context trigger source registered')
+1
View File
@@ -264,6 +264,7 @@ export function bundleRequires(): string[] {
export const SLOT_SEAMS = [
'conversation.view',
'conversation.chat.assistant-actions',
'conversation.composer.dock',
'conversation.input.overlay',
'settings.plugin.item',
] as const