feat(client): jump from a chat reply to its turn in the Context tab
Add an icon to each finalized reply's assistant-actions row that opens the Context tab pinned to that reply's turn: the click resolves the reply's request seq off the served node seat, records it in a per-session relay, and activates the tab; the view consumes the relay once its projection data is in, flips the trend chart to turn bars, pins and centers the turn's aggregate, and resets the shared scroller. Every hop degrades quietly (unknown seat, unresolvable seq, missing tab, hostile nodes, aged-out turn).
This commit is contained in:
1 parent
84d4f57a6f
commit
0c8312763e
13 files changed
+521
-37
No files matched your search
@@ -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 (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<rect x="2" y="3" width="12" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="7" width="8.5" height="2" rx="1" fill="currentColor" />
|
||||
<rect x="2" y="11" width="5.5" height="2" rx="1" fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Tooltip label={t('jump.title')} side="bottom">
|
||||
<button type="button" className="lc-jump" aria-label={t('jump.title')} onClick={jump}>
|
||||
<JumpIcon />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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<number | null>(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<number | null>(null)
|
||||
const [hoverCat, setHoverCat] = React.useState<string | null>(null)
|
||||
const [pickedKinds, setPickedKinds] = React.useState<string[]>([...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])
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export const DICT_ZH: Record<string, string> = {
|
||||
'brief.more': '+{n}',
|
||||
'brief.noInputs': '(无新增)',
|
||||
'brief.locate': '在上下文浏览器中查看',
|
||||
'jump.title': '在上下文标签页中查看此轮',
|
||||
'brief.turnTip': '这一轮开场时用户发送的消息;回合内的每一步都会回显同一条',
|
||||
'brief.inputTip': '上一步回复之后、本次请求发出前新进入上下文的内容——通常是上一步所调工具的结果;每轮的第一步无新增',
|
||||
'brief.replyTip': '这一步模型返回的内容(文本回复或工具调用);此步发起的调用,其结果会出现在下一柱的「输入」行',
|
||||
@@ -249,6 +250,7 @@ export const DICT_EN: Record<string, string> = {
|
||||
'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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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<string, number>()
|
||||
|
||||
/** 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<HTMLButtonElement>('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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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<HTMLSelectElement>(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()
|
||||
})
|
||||
})
|
||||
@@ -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)]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user