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:
1 parent
33068ee1c9
commit
fbf32ecc67
8 files changed
+496
-4
No files matched your search
@@ -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' })
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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; }
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user