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:
1 parent
60598ae0e4
commit
a1277bac34
7 files changed
+128
-23
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user