diff --git a/src/client/components/statsJump.tsx b/src/client/components/statsJump.tsx new file mode 100644 index 0000000..f049356 --- /dev/null +++ b/src/client/components/statsJump.tsx @@ -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(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' }) + } +} diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 2d8c988..b2e39b1 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -76,6 +76,7 @@ export const DICT_ZH: Record = { 'brief.noInputs': '(无新增)', 'brief.locate': '在上下文浏览器中查看', 'jump.title': '在上下文标签页中查看此轮', + 'jump.statsLine': '在上下文标签页中查看上下文构成', 'brief.turnTip': '这一轮开场时用户发送的消息;回合内的每一步都会回显同一条', 'brief.inputTip': '上一步回复之后、本次请求发出前新进入上下文的内容——通常是上一步所调工具的结果;每轮的第一步无新增', 'brief.replyTip': '这一步模型返回的内容(文本回复或工具调用);此步发起的调用,其结果会出现在下一柱的「输入」行', @@ -291,6 +292,7 @@ export const DICT_EN: Record = { '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', diff --git a/src/client/index.ts b/src/client/index.ts index 87e1485..3416aef 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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. diff --git a/src/client/styles/jump.css b/src/client/styles/jump.css index c4b5dfa..2952829 100644 --- a/src/client/styles/jump.css +++ b/src/client/styles/jump.css @@ -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; } diff --git a/tests/client/components/statsJump.spec.ts b/tests/client/components/statsJump.spec.ts new file mode 100644 index 0000000..af77e28 --- /dev/null +++ b/tests/client/components/statsJump.spec.ts @@ -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 => 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 + un(): Promise +}> { + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const renderRows = async (count: number): Promise => { + 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 => { + 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') + }) +}) diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index f813da9..eb6f471 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -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() diff --git a/tests/compat/bundle.spec.ts b/tests/compat/bundle.spec.ts index a18e6f7..630780f 100644 --- a/tests/compat/bundle.spec.ts +++ b/tests/compat/bundle.spec.ts @@ -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') diff --git a/tests/compat/staging.ts b/tests/compat/staging.ts index 3089a50..45ccd2d 100644 --- a/tests/compat/staging.ts +++ b/tests/compat/staging.ts @@ -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