feat(client): land the chat-to-Context jump in the right Sidebar

The assistant-action jump now opens the Context tab on the right Sidebar
over the guarded `ctx.sidebarRight.openTab` optional seam (the column
expands in the same step, keeping the clicked reply in view) instead of
switching the conversation tab. Every harness or placement the seam
cannot serve — lines older than 0.1.5-rc.1, tab-only placement, no
mounted session surface, a hostile face — keeps the conversation-tab
activation as fallback. The tooltip is reworded neutrally ("Context
view") since the landing follows what the harness serves.
This commit is contained in:
bowenliang123 committed 2026-09-18 14:49:03 +08:00
1 parent 60598ae0e4
commit a1277bac34
7 files changed
+128 -23

No files matched your search

+12 -9
View File
@@ -1,19 +1,20 @@
/**
* The assistant-message action that jumps to the Context tab at this reply's
* The assistant-message action that opens the Context view 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 the `useChat`
* node seat, 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.
* node seat, records it in the viewFocus relay, and opens the Context tab on
* the right Sidebar — where the jump pins the reply's TURN (see contextView's
* leg 2). A harness or placement without the sidebar tab falls back to the
* conversation tab; an unresolvable seq still opens the view, just without a
* pin; a message id that is not a plain string renders nothing at all.
*/
import { type ReactElement } from 'react'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ConversationNodeLike, UseChatLike } from '../services'
import type { ClientCtx, ConversationNodeLike, UseChatLike } from '../services'
import { conversationNodesOf } from '../services'
import { activateContextTab, requestContextFocus } from '../viewFocus'
import { activateContextTab, openContextSidebar, requestContextFocus } from '../viewFocus'
import type { ViewKit } from '../viewkit'
/** The assistant-action seat's currency, as far as this button consumes it. */
@@ -50,7 +51,7 @@ function JumpIcon(): ReactElement {
)
}
export function makeContextJumpButton(kit: ViewKit): (props: ContextJumpProps) => ReactElement | null {
export function makeContextJumpButton(ctx: ClientCtx, kit: ViewKit): (props: ContextJumpProps) => ReactElement | null {
const { t } = kit
return function ContextJump(props: ContextJumpProps): ReactElement | null {
const messageId = props.messageId
@@ -63,7 +64,9 @@ export function makeContextJumpButton(kit: ViewKit): (props: ContextJumpProps) =
if (seq !== null && typeof sessionId === 'string' && sessionId !== '') {
requestContextFocus(sessionId, seq)
}
activateContextTab(t('tab'))
// The sidebar expands over the chat, keeping the clicked reply in view;
// a harness or placement without that tab keeps the tab activation.
if (!openContextSidebar(ctx)) activateContextTab(t('tab'))
}
return (
<Tooltip label={t('jump.title')} side="bottom">
+2 -2
View File
@@ -85,7 +85,7 @@ export const DICT_ZH: Record<string, string> = {
'brief.more': '+{n}',
'brief.noInputs': '(无新增)',
'brief.locate': '在上下文浏览器中查看',
'jump.title': '在上下文标签页中查看此轮',
'jump.title': '在上下文视图中查看此轮',
'brief.turnTip': '这一轮开场时用户发送的消息;回合内的每一步都会回显同一条',
'brief.inputTip': '上一步回复之后、本次请求发出前新进入上下文的内容——通常是上一步所调工具的结果;每轮的第一步无新增',
'brief.replyTip': '这一步模型返回的内容(文本回复或工具调用);此步发起的调用,其结果会出现在下一柱的「输入」行',
@@ -369,7 +369,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',
'jump.title': 'View this turn in the Context view',
'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',
+4 -3
View File
@@ -103,9 +103,10 @@ function apply(ctx: ClientCtx): void {
}), 'dsh-context: placement')
// 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)
// opens the Context tab on the right Sidebar pinned to that reply's turn —
// falling back to the conversation tab wherever the sidebar serves no tab
// (see contextJump.tsx; the relay and view activation live in viewFocus.ts).
const ContextJump = makeContextJumpButton(ctx, kit)
ctx.slots.inject('conversation.chat.assistant-actions', () => {
return ctx.slots.register(
// After the shipped feedback entry (10), still inside the icon row.
+24 -1
View File
@@ -1,10 +1,14 @@
/**
* Chat → Context jump relay. The assistant-message action records the clicked
* reply's request seq here and activates the Context tab; the Context view
* reply's request seq here and opens the Context view — the right Sidebar's
* tab when this harness serves one, else the conversation tab; the 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.
*/
import { asRecord, type ClientCtx } from './services'
import { SIDEBAR_CONTEXT_KIND } from './sidebar'
const pendingFocus = new Map<string, number>()
/** Record the Context step (request seq) to reveal for `sessionId` — replaces any unconsumed request. */
@@ -20,6 +24,25 @@ export function takeContextFocus(sessionId: string): number | null {
return seq
}
/**
* Open the Context tab on the right Sidebar over `ctx.sidebarRight.openTab`
* (the column expands in the same step). OPTIONAL seam, re-proved at call
* time: a harness without the service (every line older than 0.1.5-rc.1), a
* placement that registered no tab type, no mounted session surface, or a
* hostile face all report false so the caller keeps its conversation-tab
* fallback.
*/
export function openContextSidebar(ctx: ClientCtx): boolean {
try {
const face = asRecord(ctx.get('sidebarRight'))
if (face === null || typeof face.openTab !== 'function') return false
;(face.openTab as (kind: string) => void).call(face, SIDEBAR_CONTEXT_KIND)
return true
} catch {
return false
}
}
/**
* Activate the Context tab by clicking its own tab-bar button (the semantic
* `button[role="tab"]` chrome the conversation shell renders for every view).
+53 -4
View File
@@ -1,7 +1,8 @@
// 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.
// request seq resolved off the served node seat, the relay + view opening
// driven through the real viewFocus module over a test client context (no
// sidebarRight service armed → the conversation-tab fallback).
import { createElement as h } from 'react'
import assert from 'node:assert/strict'
@@ -10,10 +11,11 @@ import { makeContextJumpButton } from '../../../src/client/components/contextJum
import type { ConversationNodeLike, UseChatLike } from '../../../src/client/services'
import { takeContextFocus } from '../../../src/client/viewFocus'
import { DICT_EN } from '../../../src/client/i18n'
import { asClientCtx, TestClientCtx } from '../helpers/harness'
import { click, makeKit, mount, query, queryAll } from '../helpers/kit'
const kit = makeKit()
const Jump = makeContextJumpButton(kit)
const Jump = makeContextJumpButton(asClientCtx(new TestClientCtx()), kit)
const SESSION = 'sv-jump'
@@ -55,7 +57,7 @@ describe('ContextJump — seat guards', () => {
})
describe('ContextJump — click flow', () => {
test('resolves the reply seq off the session seat, records the relay, and activates the tab', async () => {
test('no sidebar face: resolves the reply seq, records the relay, and falls back to the tab', async () => {
const bar = document.createElement('div')
const chat = document.createElement('button')
chat.setAttribute('role', 'tab')
@@ -135,3 +137,50 @@ describe('ContextJump — click flow', () => {
}
})
})
describe('ContextJump — sidebar landing', () => {
test('a served sidebarRight face opens the Context tab there, leaving the conversation tab alone', async () => {
const opened: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openTab: (kind: string) => { opened.push(kind) } } } })
const SidebarJump = makeContextJumpButton(asClientCtx(ctx), kit)
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(SidebarJump, { messageId: 'msg-4', sessionId: SESSION, ...chatWith([reply]) }))
await click(query(m.container, 'button.lc-jump'))
assert.deepEqual(opened, ['dsh-context'], 'the sidebar Context tab was opened by kind')
assert.equal(takeContextFocus(SESSION), 4, 'the clicked reply’s request seq rides the relay')
assert.equal(contextClicks, 0, 'the conversation tab was not touched')
await m.unmount()
} finally {
bar.remove()
}
})
test('an openTab that refuses (no mounted surface, unregistered kind) falls back to the tab', async () => {
const ctx = new TestClientCtx({ services: { sidebarRight: { openTab: () => { throw new Error('refused') } } } })
const SidebarJump = makeContextJumpButton(asClientCtx(ctx), kit)
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(SidebarJump, { messageId: 'msg-4', sessionId: SESSION, ...chatWith([reply]) }))
await click(query(m.container, 'button.lc-jump'))
assert.equal(contextClicks, 1, 'the refused open falls back to the conversation tab')
await m.unmount()
} finally {
bar.remove()
}
})
})
+1 -1
View File
@@ -116,7 +116,7 @@ describe('client entry: assistant-actions seat', () => {
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')
assert.equal(query(m.container, 'button.lc-jump').getAttribute('aria-label'), 'View this turn in the Context view')
await m.unmount()
// Interruption-frozen partials address no durable message: nothing renders.
+32 -3
View File
@@ -1,9 +1,11 @@
// viewFocus (src/client/viewFocus.ts) — the chat→Context jump relay and the
// Context-tab activation, driven against a real jsdom tab bar.
// viewFocus (src/client/viewFocus.ts) — the chat→Context jump relay, the
// sidebar-tab opener, and the conversation-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'
import { activateContextTab, openContextSidebar, requestContextFocus, takeContextFocus } from '../../src/client/viewFocus'
import { asClientCtx, TestClientCtx } from './helpers/harness'
describe('context focus relay', () => {
test('one request survives until taken, then the map entry is consumed', () => {
@@ -22,6 +24,33 @@ describe('context focus relay', () => {
})
})
describe('openContextSidebar', () => {
test('opens the registered kind through the sidebarRight face', () => {
const opened: string[] = []
const ctx = new TestClientCtx({ services: { sidebarRight: { openTab: (kind: string) => { opened.push(kind) } } } })
assert.equal(openContextSidebar(asClientCtx(ctx)), true)
assert.deepEqual(opened, ['dsh-context'])
})
test('no service, or a face without the verb, reports false', () => {
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx())), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: {} } }))), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: null } }))), false)
assert.equal(openContextSidebar(asClientCtx(new TestClientCtx({ services: { sidebarRight: 'x' } }))), false)
})
test('a hostile face — throwing read or openTab — reports false, never throws', () => {
const throwingRead = {
get(_name: string): unknown { throw new Error('boom') },
} as unknown as TestClientCtx
assert.equal(openContextSidebar(asClientCtx(throwingRead)), false)
const ctx = new TestClientCtx({ services: {
sidebarRight: { openTab: () => { throw new Error('no session surface mounted') } },
} })
assert.equal(openContextSidebar(asClientCtx(ctx)), false)
})
})
describe('activateContextTab', () => {
test('clicks the inactive tab matching the label, skips the active one, and reports misses', () => {
type Counted = HTMLElement & { __clicks: () => number }