Every client file reached hooks and element types through the central
require seam (src/client/react.ts) and spelled them React.useState /
ReactNS.ReactElement. Import the members directly instead — named value
imports for hooks, inline type-only imports for element types — and let
the bundler own the single require('react'), which is exactly what the
platform module table answers.
This needs JSX off the classic runtime (it required a React identifier
in every tsx scope): tsconfig moves to jsx: react-jsx, so the bundle
sources jsx from 'react/jsx-runtime', already seeded by every supported
harness (verified against the dsh-v0.1.2-rc.1 platform.ts). The bundle
smoke's fake module table gains the specifier to stay a faithful shell
stand-in. h() survives as a local createElement alias at the slot
render-prop seams and the lightbox portal imports createPortal from
react-dom directly.
Verified: the built client.js requires react, react/jsx-runtime, and
react-dom exactly once each; the compat lane (bundle smoke + the
real-baseline matrix) passes against the fresh artifacts; the full gate
stays green with per-file coverage at 100%.
138 lines
6.1 KiB
TypeScript
138 lines
6.1 KiB
TypeScript
// ContextJump (src/client/components/contextJump.tsx) — the assistant-action
|
||
// jump button rendered for real: seat props re-proved, the clicked reply's
|
||
// request seq resolved off the served node seat, the relay + tab activation
|
||
// driven through the real viewFocus module.
|
||
|
||
import { createElement as h } from 'react'
|
||
import assert from 'node:assert/strict'
|
||
import { describe, test } from 'vitest'
|
||
import { makeContextJumpButton } from '../../../src/client/components/contextJump'
|
||
import type { ConversationNodeLike, UseChatLike } from '../../../src/client/services'
|
||
import { takeContextFocus } from '../../../src/client/viewFocus'
|
||
import { DICT_EN } from '../../../src/client/i18n'
|
||
import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
|
||
|
||
const kit = makeKit()
|
||
const Jump = makeContextJumpButton(kit)
|
||
|
||
const SESSION = 'sv-jump'
|
||
|
||
/** The chat node seat: the ChatSnapshot arm carrying assistant nodes with message ids (hostile shapes forced past the seat type). */
|
||
function chatWith(nodes: unknown): { useChat?: UseChatLike } {
|
||
return { useChat: (sel => sel({ legacy: { nodes: nodes as readonly ConversationNodeLike[] } })) as UseChatLike }
|
||
}
|
||
|
||
const reply = { kind: 'assistant', seq: 4, messageId: 'msg-4' }
|
||
const toolNode = { kind: 'tool', seq: 3, messageId: 'msg-tool' }
|
||
|
||
describe('ContextJump — seat guards', () => {
|
||
test('a non-string or empty message id renders nothing', async () => {
|
||
const m1 = await mount(h(Jump, {}))
|
||
assert.equal(queryAll(m1.container, 'button').length, 0)
|
||
await m1.unmount()
|
||
const m2 = await mount(h(Jump, { messageId: '' }))
|
||
assert.equal(queryAll(m2.container, 'button').length, 0)
|
||
await m2.unmount()
|
||
const m3 = await mount(h(Jump, { messageId: 42 }))
|
||
assert.equal(queryAll(m3.container, 'button').length, 0)
|
||
await m3.unmount()
|
||
})
|
||
|
||
test('renders the labelled icon button for a finalized reply', async () => {
|
||
const m = await mount(h(Jump, { messageId: 'msg-4' }))
|
||
const btn = query(m.container, 'button.lc-jump')
|
||
assert.equal(btn.getAttribute('aria-label'), DICT_EN['jump.title'])
|
||
assert.ok(btn.querySelector('svg') !== null)
|
||
await m.unmount()
|
||
})
|
||
|
||
test('a click with no served node seat switches tabs and mints no focus', async () => {
|
||
const m = await mount(h(Jump, { messageId: 'msg-4', sessionId: SESSION }))
|
||
await click(query(m.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), null)
|
||
await m.unmount()
|
||
})
|
||
})
|
||
|
||
describe('ContextJump — click flow', () => {
|
||
test('resolves the reply seq off the session seat, records the relay, and activates the tab', async () => {
|
||
const bar = document.createElement('div')
|
||
const chat = document.createElement('button')
|
||
chat.setAttribute('role', 'tab')
|
||
chat.setAttribute('aria-selected', 'true')
|
||
chat.textContent = 'Chat'
|
||
const context = document.createElement('button')
|
||
context.setAttribute('role', 'tab')
|
||
context.textContent = 'Context'
|
||
let contextClicks = 0
|
||
context.addEventListener('click', () => { contextClicks++ })
|
||
bar.append(chat, context)
|
||
document.body.appendChild(bar)
|
||
try {
|
||
const m = await mount(h(Jump, { messageId: 'msg-4', sessionId: SESSION, ...chatWith([reply, toolNode]) }))
|
||
await click(query(m.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), 4, 'the clicked reply’s request seq rides the relay')
|
||
assert.equal(contextClicks, 1, 'the Context tab button was clicked')
|
||
assert.equal(takeContextFocus(SESSION), null)
|
||
await m.unmount()
|
||
} finally {
|
||
bar.remove()
|
||
}
|
||
})
|
||
|
||
test('the seq resolution prefers the matching assistant node and drops non-finite seqs', async () => {
|
||
const m = await mount(h(Jump, { messageId: 'msg-tool', sessionId: SESSION, ...chatWith([reply, toolNode]) }))
|
||
await click(query(m.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), null, 'a tool node never mints a focus')
|
||
await m.unmount()
|
||
|
||
const broken = { kind: 'assistant', seq: Number.NaN, messageId: 'msg-x' }
|
||
const m2 = await mount(h(Jump, { messageId: 'msg-x', sessionId: SESSION, ...chatWith([broken]) }))
|
||
await click(query(m2.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), null, 'a non-finite seq never mints a focus')
|
||
await m2.unmount()
|
||
})
|
||
|
||
test('a hostile node that throws on property access is skipped, not fatal', async () => {
|
||
const hostile = { get kind(): string { throw new Error('boom') } }
|
||
const m = await mount(h(Jump, { messageId: 'msg-4', sessionId: SESSION, ...chatWith([hostile, reply]) }))
|
||
await click(query(m.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), 4, 'the real reply after the hostile element still resolves')
|
||
await m.unmount()
|
||
|
||
const hostileMatch = {
|
||
get kind(): string { return 'assistant' },
|
||
get messageId(): string { throw new Error('boom') },
|
||
}
|
||
const m2 = await mount(h(Jump, { messageId: 'msg-9', sessionId: SESSION, ...chatWith([hostileMatch]) }))
|
||
await click(query(m2.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), null, 'a hostile match candidate degrades to no focus')
|
||
await m2.unmount()
|
||
})
|
||
|
||
test('an unknown reply and a missing session still switch tabs, minting no relay entry', async () => {
|
||
const bar = document.createElement('div')
|
||
const context = document.createElement('button')
|
||
context.setAttribute('role', 'tab')
|
||
context.textContent = 'Context'
|
||
let contextClicks = 0
|
||
context.addEventListener('click', () => { contextClicks++ })
|
||
bar.appendChild(context)
|
||
document.body.appendChild(bar)
|
||
try {
|
||
const m = await mount(h(Jump, { messageId: 'msg-none', sessionId: SESSION, ...chatWith([reply]) }))
|
||
await click(query(m.container, 'button.lc-jump'))
|
||
assert.equal(takeContextFocus(SESSION), null)
|
||
assert.equal(contextClicks, 1, 'the tab still opens, just without a pinned step')
|
||
await m.unmount()
|
||
|
||
const m2 = await mount(h(Jump, { messageId: 'msg-4', sessionId: 7, ...chatWith([reply]) }))
|
||
await click(query(m2.container, 'button.lc-jump'))
|
||
assert.equal(contextClicks, 2)
|
||
await m2.unmount()
|
||
} finally {
|
||
bar.remove()
|
||
}
|
||
})
|
||
})
|