diff --git a/src/client/components/contextJump.tsx b/src/client/components/contextJump.tsx index 471665c..0b5db34 100644 --- a/src/client/components/contextJump.tsx +++ b/src/client/components/contextJump.tsx @@ -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 ( diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 5e889cb..aae37fc 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -85,7 +85,7 @@ export const DICT_ZH: Record = { '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 = { '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', diff --git a/src/client/index.ts b/src/client/index.ts index eccbb01..6cb5d51 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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. diff --git a/src/client/viewFocus.ts b/src/client/viewFocus.ts index 1c23493..8ad368f 100644 --- a/src/client/viewFocus.ts +++ b/src/client/viewFocus.ts @@ -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() /** 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). diff --git a/tests/client/components/contextJump.spec.ts b/tests/client/components/contextJump.spec.ts index 81d46cb..abf69f6 100644 --- a/tests/client/components/contextJump.spec.ts +++ b/tests/client/components/contextJump.spec.ts @@ -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() + } + }) +}) diff --git a/tests/client/index.spec.ts b/tests/client/index.spec.ts index 60f757a..839ec45 100644 --- a/tests/client/index.spec.ts +++ b/tests/client/index.spec.ts @@ -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. diff --git a/tests/client/viewFocus.spec.ts b/tests/client/viewFocus.spec.ts index 0cbcb61..4e9f8e7 100644 --- a/tests/client/viewFocus.spec.ts +++ b/tests/client/viewFocus.spec.ts @@ -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 }