diff --git a/src/client/components/contextJump.tsx b/src/client/components/contextJump.tsx new file mode 100644 index 0000000..d3a1ad2 --- /dev/null +++ b/src/client/components/contextJump.tsx @@ -0,0 +1,79 @@ +/** + * The assistant-message action that jumps to the Context tab at this reply's + * turn. Registered on the harness `conversation.chat.assistant-actions` seat + * (the icon row beside copy/branch), it receives the finalized reply's durable + * message id, resolves the matching assistant node's seq off whichever node + * seat the running harness serves (`useChat` on 0.1.2+, the session snapshot + * before it), records it in the viewFocus relay, and activates the Context + * tab — where the jump pins the reply's TURN (see contextView's leg 2). An + * unresolvable seq still switches tabs, just without a pin; a message id that + * is not a plain string renders nothing at all. + */ + +import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type * as ReactNS from 'react' +import type { ConversationNodeLike, UseChatLike, UseSessionLike } from '../services' +import { conversationNodesOf } from '../services' +import { React } from '../react' +import { activateContextTab, requestContextFocus } from '../viewFocus' +import type { ViewKit } from '../viewkit' + +/** The assistant-action seat's currency, as far as this button consumes it. */ +export interface ContextJumpProps { + messageId?: unknown + sessionId?: unknown + useChat?: UseChatLike + useSession?: UseSessionLike +} + +/** + * The reply's request seq by its durable message id, or null when no served node proves the pair. Join/log nodes are untrusted input: each + * element is isolated, so one hostile object that throws on property access is skipped — the jump keeps its pin, never its click. + */ +export function seqOfMessageId(nodes: readonly ConversationNodeLike[] | undefined, messageId: string): number | null { + for (const node of nodes ?? []) { + try { + if (node.kind !== 'assistant' || node.messageId !== messageId) continue + return typeof node.seq === 'number' && Number.isFinite(node.seq) ? node.seq : null + } catch { + continue + } + } + return null +} + +/** The jump glyph: the plugin's mini stacked composition bars, same 16px outline family as the shipped row icons. */ +function JumpIcon(): ReactNS.ReactElement { + return ( + + ) +} + +export function makeContextJumpButton(kit: ViewKit): (props: ContextJumpProps) => ReactNS.ReactElement | null { + const { t } = kit + return function ContextJump(props: ContextJumpProps): ReactNS.ReactElement | null { + const messageId = props.messageId + // Interruption-frozen partials address no durable message — the owner + // already withholds them, and anything else non-string is ignored. + if (typeof messageId !== 'string' || messageId === '') return null + const jump = (): void => { + const seq = seqOfMessageId(conversationNodesOf(props), messageId) + const sessionId = props.sessionId + if (seq !== null && typeof sessionId === 'string' && sessionId !== '') { + requestContextFocus(sessionId, seq) + } + activateContextTab(t('tab')) + } + return ( + + + + ) + } +} diff --git a/src/client/components/contextView.tsx b/src/client/components/contextView.tsx index cbc1c35..4a8e0c4 100644 --- a/src/client/components/contextView.tsx +++ b/src/client/components/contextView.tsx @@ -25,9 +25,10 @@ import { makePluginInfo } from './pluginInfo' import { makeRequestDetail } from './requestDetail' import { makeStatsBoard } from './statsBoard' import { makeLegend, makeStackedBar } from './stackedBar' -import { aggregateByTurn, attachMarkers, makeTrendChart } from './trendChart' +import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart } from './trendChart' import { React, h } from '../react' +import { takeContextFocus } from '../viewFocus' import { makeErrorBoundary } from './errorBoundary' // The context page scrolls inside the conversation's shared `[data-conversation-scroll]` container, which the chat bottom-anchors — mirror @@ -95,6 +96,9 @@ export function makeContextView( const [trendMode, setTrendMode] = React.useState<'total' | 'delta'>(() => settings.defaultTrendMode()) // Strip-clicked turn: chart switches to turn granularity and scroll-centers that turn's bar, then clears via onFocusTurnHandled. const [focusTurn, setFocusTurn] = React.useState(null) + // Chat → Context jump: the assistant-action relay's one-shot request, held until the projection data is in, then resolved into a + // turn-level pin below (mirrors the strip-click's consume-once focus flow). + const [jumpSeq, setJumpSeq] = React.useState(null) const [hoverCat, setHoverCat] = React.useState(null) const [pickedKinds, setPickedKinds] = React.useState([...EVENT_KINDS]) const toggleKind = (k: string) => { @@ -173,6 +177,28 @@ export function makeContextView( ) const markers = React.useMemo(() => attachMarkers(displayRequests, events), [displayRequests, events]) + // Chat → Context jump, leg 1: pick up the assistant-action relay's request for this session (once per mount). + React.useEffect(() => { + if (typeof sessionId !== 'string' || sessionId === '') return + const seq = takeContextFocus(sessionId) + if (seq !== null) setJumpSeq(seq) + }, [sessionId]) + + // Leg 2: the action row belongs to the reply that CLOSED a turn, so the jump is turn-level — flip the chart to turn bars, pin that + // turn's aggregate (the relayed seq is the turn's last request, exactly the aggregate's record), and center it: the same flow as a + // turn-strip click. An aged-out turn clamps to the oldest retained bar. No page-scroller anchor → the reset degrades quietly. + React.useEffect(() => { + if (jumpSeq === null || data === null) return + setJumpSeq(null) + const target = jumpTargetOf(aggregateByTurn(requests), jumpSeq) + if (target === null) return + setGranularity('turn') + setSelectedSeq(target.seq) + setFocusTurn(target.turn ?? 0) + // The restore layout effect resolved the shared scroller on this same data render (layout effects precede this one). + if (scrollerRef.current !== null) scrollerRef.current.scrollTop = 0 + }, [jumpSeq, data, requests]) + // Step-brief raw material: every served node seq-sorted (live tail + archive), the tooltip's reply previews, and the // conversation-snapshot join the brief uses for call-argument enrichment (same join the Context browser builds). const briefList = React.useMemo(() => (data ? briefNodes(data) : []), [data]) diff --git a/src/client/components/trendChart.tsx b/src/client/components/trendChart.tsx index 8a53335..7aefd67 100644 --- a/src/client/components/trendChart.tsx +++ b/src/client/components/trendChart.tsx @@ -68,6 +68,16 @@ export function attachMarkers(requests: RequestRecord[], events: ContextEventRec return markers } +/** + * The chat→Context jump's target: the turn bar whose closing reply the user clicked — the relayed seq is a turn's LAST step, exactly the + * aggregate's record — or, when that turn has aged out of the host's retained window, the oldest retained bar. Resolved against turn + * aggregates, since the jump pins in turn granularity. Null only on an empty history. + */ +export function jumpTargetOf(requests: RequestRecord[], seq: number): RequestRecord | null { + for (const req of requests) if (req.seq === seq) return req + return requests.length > 0 ? requests[0] : null +} + export function makeTrendChart(kit: ViewKit): (props: TrendChartProps) => ReactNS.ReactElement { const { t, fmt, fmtTime, eventLabel, eventAt } = kit diff --git a/src/client/i18n.ts b/src/client/i18n.ts index b2ad64c..b401028 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -57,6 +57,7 @@ export const DICT_ZH: Record = { 'brief.more': '+{n}', 'brief.noInputs': '(无新增)', 'brief.locate': '在上下文浏览器中查看', + 'jump.title': '在上下文标签页中查看此轮', 'brief.turnTip': '这一轮开场时用户发送的消息;回合内的每一步都会回显同一条', 'brief.inputTip': '上一步回复之后、本次请求发出前新进入上下文的内容——通常是上一步所调工具的结果;每轮的第一步无新增', 'brief.replyTip': '这一步模型返回的内容(文本回复或工具调用);此步发起的调用,其结果会出现在下一柱的「输入」行', @@ -249,6 +250,7 @@ export const DICT_EN: Record = { 'brief.more': '+{n}', 'brief.noInputs': '(nothing new)', 'brief.locate': 'Reveal in Context Browser', + 'jump.title': 'View this turn 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 a5df40f..e81ede6 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -29,6 +29,7 @@ import { modalStoreOf } from './modalStore' import type { ClientCtx } from './services' import { createContextSettings, type SettingsField, type SettingsScopeBinderFace } from './settings' import { makeContextView } from './components/contextView' +import { makeContextJumpButton } from './components/contextJump' import { makeViewKit } from './viewkit' // Theme-native styles: the bundle's global-CSS channel injects the sheet as @@ -63,6 +64,18 @@ function apply(ctx: ClientCtx): void { ) }) + // Chat → Context jump: an icon in each finalized reply's action row that + // opens this tab pinned to that reply's turn (see contextJump.tsx; the + // relay and tab activation live in viewFocus.ts). + const ContextJump = makeContextJumpButton(kit) + ctx.slots.inject('conversation.chat.assistant-actions', () => { + return ctx.slots.register( + // After the shipped feedback entry (10), still inside the icon row. + { name: 'conversation.chat.assistant-actions', id: 'context-jump', order: 20, locale: NS }, + props => h(ContextJump, 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/services.ts b/src/client/services.ts index af2a0a7..b7558bc 100644 --- a/src/client/services.ts +++ b/src/client/services.ts @@ -50,6 +50,12 @@ export interface SlotsService { export interface ConversationNodeLike { kind: string seq: number + /** + * The durable message id (assistant nodes on the harness chat nodes; absent + * on synthetic/interrupted replies) — the key the chat's assistant-action + * seat addresses a finalized reply by. + */ + messageId?: unknown content?: readonly unknown[] blocks?: readonly unknown[] call?: { name: string; argsRaw: string } | null diff --git a/src/client/styles.css b/src/client/styles.css index ae83104..7357fbb 100644 --- a/src/client/styles.css +++ b/src/client/styles.css @@ -29,6 +29,10 @@ .lc-gran-btn { border: 0; background: transparent; color: var(--dsw-alias-label-secondary); font-size: 11px; line-height: 1; padding: 3px 8px; border-radius: 5px; cursor: pointer; font-family: inherit; } .lc-gran-btn:hover { color: var(--dsw-alias-label-primary); } .lc-gran-on, .lc-gran-on:hover { background: var(--dsw-alias-button-primary-fill); color: var(--dsw-alias-label-primary-foreground); } +/* The chat action-row jump button — mirrors the harness MessageIconActions `.action` chrome so it sits flush beside the shipped icons. */ +.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; } +.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); } .lc-settings-card { background: var(--dsw-alias-bg-layer-3); border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; transition: border-color .16s, background .16s; } .lc-settings-card:hover { border-color: var(--dsw-alias-label-dimmed); } .lc-settings-open, .lc-settings-open:hover { background: var(--dsw-alias-bg-layer-2); border-color: var(--dsw-alias-label-dimmed); } diff --git a/src/client/viewFocus.ts b/src/client/viewFocus.ts new file mode 100644 index 0000000..1c23493 --- /dev/null +++ b/src/client/viewFocus.ts @@ -0,0 +1,39 @@ +/** + * Chat → Context jump relay. The assistant-message action records the clicked + * reply's request seq here and activates the Context tab; the Context view + * consumes the one-shot focus once its projection data is in. Module-level + * per-session map — the same life pattern as the modal open-state stores. + */ + +const pendingFocus = new Map() + +/** Record the Context step (request seq) to reveal for `sessionId` — replaces any unconsumed request. */ +export function requestContextFocus(sessionId: string, seq: number): void { + pendingFocus.set(sessionId, seq) +} + +/** Take the pending focus request, if any — one-shot, the map entry is consumed. */ +export function takeContextFocus(sessionId: string): number | null { + const seq = pendingFocus.get(sessionId) + if (seq === undefined) return null + pendingFocus.delete(sessionId) + return seq +} + +/** + * Activate the Context tab by clicking its own tab-bar button (the semantic + * `button[role="tab"]` chrome the conversation shell renders for every view). + * The harness hands `openView` only to the ACTIVE view entry, so a nested + * chat action cannot call it — this rides the same button a user click would. + * Already-active is a no-op that still reports success; no matching tab (any + * dsh layout without the tab bar) reports failure and nothing happens. + */ +export function activateContextTab(label: string): boolean { + const tabs = document.querySelectorAll('button[role="tab"]') + for (const tab of tabs) { + if (tab.textContent.trim() !== label) continue + if (tab.getAttribute('aria-selected') !== 'true') tab.click() + return true + } + return false +} diff --git a/tests/client/components/contextJump.spec.ts b/tests/client/components/contextJump.spec.ts new file mode 100644 index 0000000..1c39bdf --- /dev/null +++ b/tests/client/components/contextJump.spec.ts @@ -0,0 +1,137 @@ +// 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 assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { makeContextJumpButton } from '../../../src/client/components/contextJump' +import type { ConversationNodeLike, UseSessionLike } from '../../../src/client/services' +import { takeContextFocus } from '../../../src/client/viewFocus' +import { DICT_EN } from '../../../src/client/i18n' +import { h } from '../../../src/client/react' +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 session snapshot arm carrying assistant nodes with message ids (hostile shapes forced past the seat type). */ +function sessionWith(nodes: unknown): { useSession?: UseSessionLike } { + return { useSession: (sel => sel({ nodes: nodes as readonly ConversationNodeLike[] })) as UseSessionLike } +} + +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, ...sessionWith([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, ...sessionWith([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, ...sessionWith([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, ...sessionWith([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, ...sessionWith([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, ...sessionWith([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, ...sessionWith([reply]) })) + await click(query(m2.container, 'button.lc-jump')) + assert.equal(contextClicks, 2) + await m2.unmount() + } finally { + bar.remove() + } + }) +}) diff --git a/tests/client/components/contextView.spec.ts b/tests/client/components/contextView.spec.ts index 40fa470..b7dca46 100644 --- a/tests/client/components/contextView.spec.ts +++ b/tests/client/components/contextView.spec.ts @@ -11,6 +11,7 @@ import { createRoot } from 'react-dom/client' import { afterEach, describe, test, vi } from 'vitest' import { h } from '../../../src/client/react' import { makeContextView } from '../../../src/client/components/contextView' +import { requestContextFocus, takeContextFocus } from '../../../src/client/viewFocus' import { createContextSettings } from '../../../src/client/settings' import type { SettingsScopeLike } from '../../../src/client/settings' import type { UseSessionLike } from '../../../src/client/services' @@ -90,6 +91,31 @@ function buttonByText(container: ParentNode, label: string): HTMLElement { return hit } +/** Mount inside a `[data-conversation-scroll]` scroller so the view's shared-scrollport probes find it. */ +async function mountInScroller(el: React.ReactElement, scroller: HTMLElement) { + const inner = document.createElement('div') + scroller.appendChild(inner) + document.body.appendChild(scroller) + const root = createRoot(inner) + await act(async () => { + root.render(el) + }) + return { + container: inner, + async update(next: React.ReactElement) { + await act(async () => { + root.render(next) + }) + }, + async unmount() { + await act(async () => { + root.unmount() + }) + scroller.remove() + }, + } +} + describe('ContextView — projection guards', () => { test('loading screen while useProjection is absent, empty, or the session id is missing', async () => { const View = makeView(new TestClientCtx()) @@ -155,19 +181,20 @@ describe('ContextView — projection guards', () => { }) }) -describe('ContextView — interactions', () => { - async function mountRich(sessionId: string) { - const View = makeView(new TestClientCtx()) - const m = await mount(h(View, { - sessionId, - useProjection: projectionsFor(richTimeline(), { - contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] }, - }), - useSession: (sel => sel({ nodes: [] })) as UseSessionLike, - })) - return m - } +/** The rich tab: full projection (stats, chart, markers, browser headers) over two turns plus a turn-less step. */ +async function mountRich(sessionId: string) { + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { + sessionId, + useProjection: projectionsFor(richTimeline(), { + contextHeaders: { headers: [{ seq: 1, time: T0, system: 'SYS', tools: [{ name: 'bash', tokens: 12, description: 'run' }] }] }, + }), + useSession: (sel => sel({ nodes: [] })) as UseSessionLike, + })) + return m +} +describe('ContextView — interactions', () => { test('renders the rich tab: stats, chart bars, markers, events, nodes, browser', async () => { const m = await mountRich('sv-rich') assert.ok(text(m.container).includes('deepseek-v4-flash · deepseek')) @@ -654,29 +681,6 @@ describe('ContextView — targeted content fetch and image loading', () => { }) describe('ContextView — scroll ledger', () => { - async function mountInScroller(el: React.ReactElement, scroller: HTMLElement) { - const inner = document.createElement('div') - scroller.appendChild(inner) - document.body.appendChild(scroller) - const root = createRoot(inner) - await act(async () => { - root.render(el) - }) - return { - async update(next: React.ReactElement) { - await act(async () => { - root.render(next) - }) - }, - async unmount() { - await act(async () => { - root.unmount() - }) - scroller.remove() - }, - } - } - test('restores the saved position per session and re-applies only once per mount', async () => { const View = makeView(new TestClientCtx()) const props = { @@ -825,3 +829,66 @@ describe('ContextView — error boundary', () => { } }) }) + +describe('ContextView — chat→Context jump', () => { + test('the relay flips the chart to turn bars, pins the reply\'s turn, and consumes itself', async () => { + // The clicked reply closed turn 1: its request seq (4) is that turn's LAST step — exactly the aggregate's record. + requestContextFocus('sv-jump', 4) + const m = await mountRich('sv-jump') + assert.ok(buttonByText(m.container, DICT_EN['gran.turn']).className.includes('lc-gran-on'), 'granularity flips to turn') + assert.equal(queryAll(m.container, '.lc-bar').length, 2, 'the chart renders turn aggregates') + assert.ok(query(m.container, '.lc-bar[data-seq="4"]').className.includes('lc-bar-selected'), 'the reply\'s turn bar is pinned') + assert.equal(query(m.container, 'select.lc-br-pick').value, '4') + assert.equal(takeContextFocus('sv-jump'), null, 'the relay is one-shot') + await m.unmount() + }) + + test('no session id: the relay stays pending — the loading view never takes it', async () => { + requestContextFocus('sv-jumpnone', 4) + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { sessionId: '', useProjection: () => undefined })) + assert.ok(text(m.container).includes(DICT_EN.loading)) + await m.unmount() + assert.equal(takeContextFocus('sv-jumpnone'), 4, 'an absent session id skips leg 1') + takeContextFocus('sv-jumpnone') + }) + + test('an empty history consumes the relay and pins nothing', async () => { + requestContextFocus('sv-jumpempty', 4) + const View = makeView(new TestClientCtx()) + const m = await mount(h(View, { sessionId: 'sv-jumpempty', useProjection: projectionsFor(timeline()) })) + assert.equal(queryAll(m.container, '.lc-bar').length, 0) + assert.equal(takeContextFocus('sv-jumpempty'), null) + await m.unmount() + }) + + test('a turn-less reply routes to the turn-0 bar', async () => { + // The trailing turn-less step: its group key derives to 0, the ?? 0 arm. + requestContextFocus('sv-jump0', 6) + const m = await mountRich('sv-jump0') + assert.ok(query(m.container, '.lc-bar[data-seq="6"]').className.includes('lc-bar-selected')) + assert.equal(takeContextFocus('sv-jump0'), null) + await m.unmount() + }) + + test('the jump resets the shared scroller even when a saved position was about to be restored', async () => { + const View = makeView(new TestClientCtx()) + const props = { sessionId: 'sv-jumpscroll', useProjection: projectionsFor(richTimeline()) } + + // Seed the ledger: a first visit scrolls, the unmount saves 42. + const scroller1 = document.createElement('div') + scroller1.setAttribute('data-conversation-scroll', '') + const m1 = await mountInScroller(h(View, props), scroller1) + scroller1.scrollTop = 42 + await m1.unmount() + + // The jump remount: restore applies 42, then the jump resolves and resets to top. + requestContextFocus('sv-jumpscroll', 4) + const scroller2 = document.createElement('div') + scroller2.setAttribute('data-conversation-scroll', '') + const m2 = await mountInScroller(h(View, props), scroller2) + assert.equal(scroller2.scrollTop, 0, 'the jump lands at the top of the tab') + assert.ok(query(m2.container, '.lc-bar[data-seq="4"]').className.includes('lc-bar-selected')) + await m2.unmount() + }) +}) diff --git a/tests/client/components/trendChart.spec.ts b/tests/client/components/trendChart.spec.ts index 242a835..d455aca 100644 --- a/tests/client/components/trendChart.spec.ts +++ b/tests/client/components/trendChart.spec.ts @@ -16,7 +16,7 @@ import assert from 'node:assert/strict' import { afterAll, beforeAll, describe, test } from 'vitest' import { act } from 'react' import { h } from '../../../src/client/react' -import { aggregateByTurn, attachMarkers, makeTrendChart, type TrendChartProps } from '../../../src/client/components/trendChart' +import { aggregateByTurn, attachMarkers, jumpTargetOf, makeTrendChart, type TrendChartProps } from '../../../src/client/components/trendChart' import { CATS } from '../../../src/client/categories' import { replyTipsOf } from '../../../src/client/brief' import type { ContextEventRecord, RequestRecord, SurfaceNode } from '../../../src/shared/types' @@ -763,6 +763,15 @@ describe('aggregateByTurn', () => { }) }) +describe('jumpTargetOf', () => { + test('matches the exact seq; an aged-out seq clamps to the oldest retained request', () => { + const reqs = [req(50), req(60), req(70)] + assert.equal(jumpTargetOf(reqs, 60)?.seq, 60) + assert.equal(jumpTargetOf(reqs, 10)?.seq, 50, 'below the window → the oldest retained bar') + assert.equal(jumpTargetOf([], 1), null) + }) +}) + describe('attachMarkers', () => { test('attaches each boundary event to the first request logged after it', () => { const reqs = [req(5), req(10)] diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index 8a0f2d9..f813da9 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -103,6 +103,31 @@ describe('client entry: conversation.view slot', () => { }) }) +describe('client entry: assistant-actions seat', () => { + test('registers the chat→Context jump entry whose component renders the labelled icon button', async () => { + const ctx = new TestClientCtx() + applyTo(ctx) + const entries = ctx.slots.of('conversation.chat.assistant-actions') + assert.equal(entries.length, 1) + const { registration, component } = entries[0] + assert.equal(registration.name, 'conversation.chat.assistant-actions') + assert.equal(registration.id, 'context-jump') + assert.equal(registration.order, 20, 'right of the shipped feedback entry') + assert.equal(registration.locale, 'dsh-context') + + const el = component({ messageId: 'm1' }) as ReactElement + const m = await mount(el) + assert.equal(query(m.container, 'button.lc-jump').getAttribute('aria-label'), 'View this turn in the Context tab') + await m.unmount() + + // Interruption-frozen partials address no durable message: nothing renders. + const nil = await mount(component({ messageId: 7 }) as ReactElement) + assert.equal(queryAll(nil.container, 'button').length, 0) + await nil.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/client/viewFocus.spec.ts b/tests/client/viewFocus.spec.ts new file mode 100644 index 0000000..0cbcb61 --- /dev/null +++ b/tests/client/viewFocus.spec.ts @@ -0,0 +1,67 @@ +// viewFocus (src/client/viewFocus.ts) — the chat→Context jump relay and the +// Context-tab activation, driven against a real jsdom tab bar. + +import assert from 'node:assert/strict' +import { describe, test } from 'vitest' +import { activateContextTab, requestContextFocus, takeContextFocus } from '../../src/client/viewFocus' + +describe('context focus relay', () => { + test('one request survives until taken, then the map entry is consumed', () => { + requestContextFocus('sv-relay', 42) + assert.equal(takeContextFocus('sv-relay'), 42) + assert.equal(takeContextFocus('sv-relay'), null, 'one-shot: a second take finds nothing') + }) + + test('a fresh request replaces an unconsumed one; sessions are isolated', () => { + requestContextFocus('sv-race', 7) + requestContextFocus('sv-race', 9) + assert.equal(takeContextFocus('sv-race'), 9, 'the latest click wins') + requestContextFocus('sv-other', 1) + assert.equal(takeContextFocus('sv-other'), 1) + assert.equal(takeContextFocus('sv-unknown'), null) + }) +}) + +describe('activateContextTab', () => { + test('clicks the inactive tab matching the label, skips the active one, and reports misses', () => { + type Counted = HTMLElement & { __clicks: () => number } + const bar = document.createElement('div') + const mk = (label: string, selected: boolean): Counted => { + const b = document.createElement('button') + b.setAttribute('role', 'tab') + if (selected) b.setAttribute('aria-selected', 'true') + b.textContent = label + let clicks = 0 + b.addEventListener('click', () => { clicks++ }) + const counted = Object.assign(b, { __clicks: () => clicks }) + bar.appendChild(counted) + return counted + } + const chat = mk('Chat', true) + const context = mk('Context', false) + const trajectory = mk('Trajectory', false) + document.body.appendChild(bar) + try { + assert.equal(activateContextTab('Context'), true) + assert.equal(context.__clicks(), 1) + assert.equal(chat.__clicks(), 0) + + // Already-active: reported success without a redundant click. + context.setAttribute('aria-selected', 'true') + assert.equal(activateContextTab('Context'), true) + assert.equal(context.__clicks(), 1) + + // Whitespace-padded label text still matches the trimmed comparison. + context.setAttribute('aria-selected', 'false') + context.textContent = ' Context ' + assert.equal(activateContextTab('Context'), true) + assert.equal(context.__clicks(), 2) + + // No tab carries the label → nothing clicked, false. + assert.equal(activateContextTab('Missing'), false) + assert.equal(trajectory.__clicks(), 0) + } finally { + bar.remove() + } + }) +})