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()
+ }
+ })
+})