fix(web): bind the right Sidebar before same-commit mount effects (#4693)
* fix(web): bind the right Sidebar before same-commit mount effects A plan review arriving while a global panel was active crashed its opener when the user returned: the frame mounts the Conversation column ahead of the right Sidebar seat, so PlanReviewOpen's mount effect called ctx.sidebarRight.openResource before the seat's passive bindService effect had published the binding. The slot runtime retired the crashed entry for the rest of the page, removing the View full plan link and every later automatic open. Publish the seat binding from a layout effect, which runs before any passive effect of the same commit regardless of tree order. The seat spec mounts a Conversation-column opener ahead of the seat and returns from a global panel; the plan-review Web scenario delivers a review while the Plugins panel is open and expects the preview and the link after the return. Both fail before the fix. * fix(web): open the plan review once the Sidebar seat is mounted Replace the layout-effect binding with an observable: ctx.sidebarRight.mounted publishes the session whose seat is on screen, undefined while a global panel replaces the Conversation. The seat keeps binding from its passive effect. PlanReviewOpen reads the source through a bound hook and opens once a seat is defined, so a review mounting ahead of the seat in the same commit no longer assumes a bound seat and never crashes its entry. The seat spec's opener stand-in now reads `mounted` the way a slot component does; the service spec pins that only real transitions publish, and the PlanReviewOpen spec covers a review that mounts before any seat and opens once the seat binds. * test(web): align the plan-review console tripwires and the seat term The off-screen review case now asserts the reconnect warnings like its siblings and filters crash logs by error level as the other e2e files do. The zh README keeps 席位 for the seat throughout. * test(plan): keep the review props cast at its baseline fingerprint verify-no-unknown-casts counts a modified assertion as new. The two PlanReviewOpen specs now share the unchanged baseline cast through one helper and supply the seat hook as a typed value beside it.
This commit is contained in:
1 parent
8d6245bcc8
commit
ab9689b2ca
15 files changed
+231
-32
No files matched your search
@@ -10,7 +10,7 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import type { Browser, ConsoleMessage, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -211,6 +211,51 @@ describe('web e2e: plan review takeover round trip', () => {
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('opens a review that arrives while a global panel is active', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-plan-review-panel-return'))
|
||||
const agent = scaffold.ctx.agents.get(reviewedSession)
|
||||
if (agent === undefined) throw new Error('The reviewed Session has no active agent')
|
||||
// The Plugins panel replaces the Conversation and its right Sidebar. The
|
||||
// review arrives while neither is mounted; returning mounts both in one
|
||||
// commit, and the review's automatic open runs before the Sidebar's own
|
||||
// effects. A crash there retires the opener for the rest of the page.
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await expect.poll(() => page.locator('[data-composer-input]').count()).toBe(0)
|
||||
const crashes: string[] = []
|
||||
const onConsole = (message: ConsoleMessage): void => {
|
||||
if (message.type() === 'error' && /slot entry crashed/i.test(message.text())) crashes.push(message.text())
|
||||
}
|
||||
page.on('console', onConsole)
|
||||
const controller = new AbortController()
|
||||
const asked = scaffold.ctx.userQuestions.ask({
|
||||
agent, signal: controller.signal,
|
||||
questions: [{ id: 'off-screen', question: 'Approve this plan?',
|
||||
detail: '# Off-screen review\n\nSubmitted while the Plugins panel was open.',
|
||||
options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent: { kind: 'plan-review', approve: 'Approve' },
|
||||
}],
|
||||
})
|
||||
const outcome = asked.then(value => value, (error: unknown) => ({ error }))
|
||||
try {
|
||||
const row = page.locator('[role="treeitem"]').filter({ has: page.locator('[data-state="warning"]') }).first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await row.click()
|
||||
const card = page.locator('[data-plan-review-key]')
|
||||
await card.waitFor({ timeout: 10_000 })
|
||||
const preview = page.locator('[data-plan-preview^="dsh-resource://plan-review/"]')
|
||||
await preview.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
expect(await preview.getByText('Submitted while the Plugins panel was open.').isVisible()).toBe(true)
|
||||
expect(await card.getByRole('button', { name: 'Open plan in sidebar' }).count()).toBe(1)
|
||||
expect(await page.locator('[data-slot-error]').count()).toBe(0)
|
||||
expect(crashes).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
} finally {
|
||||
page.off('console', onConsole)
|
||||
controller.abort()
|
||||
await outcome
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'session.v3.jsonl', 'review.expected.md', 'sidebar.expected.md', 'preview.expected.md',
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-plan/README.md
|
||||
README.md: 96ecd3777ef0ce4ce6babd977eef055ad5c683ec
|
||||
README.zh.md: fb6eb850b868913bdccec175500ff4e26d7a2157
|
||||
README.md: 847774392822ac81abe6f942a245a08a375785b9
|
||||
README.zh.md: 65a4da070cdf204e4d2c394f2d9cfec8c71ec7c1
|
||||
@@ -51,7 +51,7 @@ Admission failures (`matched: false`, business errors, transport faults) surface
|
||||
|
||||
The chip occupies the conversation-declared `conversation.input.plan` single seat; the node half is an empty apply (the roster row). Reads ride the generic projection pair through the standard-kit `useProjection`: the effective target is `pending ? !active : active` — a folded host value, not client optimism, so an arriving frame corrects the chip either way. The seat's injected face carries one verb, `exitPlanMode`, which executes `/plan off` through `ctx.remote.commands.execute` and maps admission failures to an inline error line. The placeholder and hint text live in ui-conversation's `conversation` locale namespace and are shared verbatim with the claimed `/plan` command hint.
|
||||
|
||||
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor.
|
||||
Plan cards derive from native `tool/call` or PTC dispatch arguments through a Conversation Definition, with each invocation’s resolved Turn location. They contribute to the additive `conversation.chat.turnTail` list alongside file deliveries. The plan resource address identifies the invocation and its complete ordinary or direct-parent subagent Session address; its provider reads existing Session history, including older pages, without storing document text in sidebar layout. The question plugin owns the review action slot and supplies its request key, complete text, and optional invocation identity. The automatic open reads `ctx.sidebarRight.mounted` through a bound hook and runs once a seat is on screen: a review arriving while a global panel is active mounts ahead of the returning seat in the same commit, before that seat binds. The [decision](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.md) explains why review lifetime and document lifetime remain separate. Subagent plan addresses also preserve unknown mode so history reads can resolve the child descriptor.
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ kind: "package-reference"
|
||||
|
||||
徽章占据 conversation 声明的 `conversation.input.plan` 单实例座位;node 半部是空 apply(roster 行)。读取经 standard-kit 的 `useProjection` 走通用投影对:有效目标是 `pending ? !active : active`——折叠的宿主值而非客户端乐观态,因此到达的帧无论哪个方向都会纠正徽章。座位注入面携带一个动词 `exitPlanMode`,经 `ctx.remote.commands.execute` 执行 `/plan off`,并把准入失败映射为一行内联错误。placeholder 与提示文案位于 ui-conversation 的 `conversation` locale 命名空间,与已认领 `/plan` 命令的提示逐字共用。
|
||||
|
||||
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。
|
||||
计划卡片通过 Conversation Definition 从原生 `tool/call` 或 PTC dispatch 参数派生,并使用每次调用已解析的回合位置。它们与文件产物一起贡献到可追加的 `conversation.chat.turnTail` 列表。计划资源地址标识调用及完整的普通会话地址或子会话直接父级地址;provider 读取已有会话历史及较早分页,不把正文存入侧边栏布局。提问插件拥有审批操作插槽,并提供请求键、完整正文和可选的调用标识。自动打开通过绑定的 hook 读取 `ctx.sidebarRight.mounted`,在座位出现在屏幕上后才执行:全局面板打开期间到达的审批,会在返回时与座位在同一次提交里先挂载、早于座位完成绑定。[决策记录](../../../.agents/notes/implemented/feature/2026-09-17-persistent-plan-cards.zh.md)说明审批与文档为何保持独立生命周期。 子代理计划地址也保留未知模式,以便历史读取解析子 descriptor。
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import { FileTypeIcon, IconChevronRightOutlineRegular } from '@deepseek-ai/dsh-c
|
||||
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { shallowEqual } from '@deepseek-ai/dsh-client-store'
|
||||
import type {} from './plan-definition.ts'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-user-questions/client'
|
||||
import type { createPlanReviewStore } from './review-store.ts'
|
||||
import css from './PlanPreview.module.css'
|
||||
@@ -20,6 +21,10 @@ export interface PlanOpenInjected {
|
||||
export interface PlanReviewOpenInjected {
|
||||
/** Open the logged plan, or the request's temporary document when no invocation exists. */
|
||||
openReview: (review: PropsRuntime<'conversation.plan-review.actions'>['review'], requestKey: string) => void
|
||||
readonly hooks: {
|
||||
/** The session whose right Sidebar seat is mounted; `undefined` while none is on screen. */
|
||||
readonly sidebarMounted: HostObservable<SessionId | undefined>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,17 +57,23 @@ export function PlanCards({ turn, useChat, openPlan, t }: PropsRuntime<'conversa
|
||||
|
||||
/**
|
||||
* Open each pending plan automatically and retain a manual opener without answering it.
|
||||
*
|
||||
* The automatic open waits for a mounted Sidebar seat: a review that arrives
|
||||
* while the Conversation is off screen mounts in the same commit as the seat,
|
||||
* ahead of it, and the seat binds from its own effect. Reading the bound
|
||||
* session through the hook opens once that binding exists.
|
||||
* @param props - Review identity, Session store, localized copy, and navigation.
|
||||
* @returns an opener for either logged or temporary plan text.
|
||||
*/
|
||||
export function PlanReviewOpen({ review, requestKey, openReview, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanReviewOpenInjected> & PropsLocale<'plan'> & PropsStore<ReturnType<typeof createPlanReviewStore>>) {
|
||||
export function PlanReviewOpen({ review, requestKey, openReview, useSidebarMounted, t, useStore, actions }: PropsRuntime<'conversation.plan-review.actions'> & InjectFace<PlanReviewOpenInjected> & PropsLocale<'plan'> & PropsStore<ReturnType<typeof createPlanReviewStore>>) {
|
||||
const identity = review.callId === undefined ? `review:${requestKey}` : `call:${review.callId}`
|
||||
const opened = useStore(state => state.opened[identity] === true)
|
||||
const mounted = useSidebarMounted(session => session !== undefined)
|
||||
useEffect(() => {
|
||||
if (opened) return
|
||||
if (opened || !mounted) return
|
||||
openReview(review, requestKey)
|
||||
actions.markOpened(identity)
|
||||
}, [identity, opened, openReview, review, requestKey, actions])
|
||||
}, [identity, opened, mounted, openReview, review, requestKey, actions])
|
||||
return <button type="button" className={css.reviewLink} title={t('preview.open')} aria-label={t('preview.open')}
|
||||
onClick={() => { openReview(review, requestKey) }}>{t('preview.full')}<IconChevronRightOutlineRegular size={14} /></button>
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export function apply(ctx: ClientContext): void {
|
||||
params: { planReview: { markdown: review.plan, title: extractMarkdownPlainText(review.plan, { mode: 'first-line' }) } },
|
||||
})
|
||||
},
|
||||
hooks: { sidebarMounted: ctx.sidebarRight.mounted },
|
||||
}),
|
||||
}, PlanReviewOpen))
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { PlanChip } from '../src/client/PlanModeControl.tsx'
|
||||
import { PlanCards, PlanReviewOpen, type PlanOpenInjected, type PlanReviewOpenInjected } from '../src/client/PlanCard.tsx'
|
||||
@@ -31,13 +32,14 @@ function providePreview(ctx: Context) {
|
||||
const registerType = vi.fn<Context['sidebarRightTabs']['register']>(() => removeType)
|
||||
const openResourceIn = vi.fn<Context['sidebarRight']['openResourceIn']>()
|
||||
const openResource = vi.fn<Context['sidebarRight']['openResource']>()
|
||||
const mounted = createSnapshotStore<SessionId | undefined>(undefined)
|
||||
const subagentAddress = vi.fn<Context['sessions']['subagentAddress']>(() => undefined)
|
||||
ctx.provide('sessions', { subagentAddress })
|
||||
ctx.provide('resources', { register: vi.fn(() => removeResources) })
|
||||
ctx.provide('sidebarRightTabs', { register: registerType })
|
||||
ctx.provide('sidebarRight', { openResourceIn, openResource })
|
||||
ctx.provide('sidebarRight', { openResourceIn, openResource, mounted })
|
||||
ctx.provide('remote.session', {})
|
||||
return { events, removeResources, removeType, registerType, openResourceIn, openResource, subagentAddress }
|
||||
return { events, removeResources, removeType, registerType, openResourceIn, openResource, mounted, subagentAddress }
|
||||
}
|
||||
|
||||
const SID = 's-plan' as SessionId
|
||||
@@ -183,6 +185,8 @@ describe('ui-plan browser apply', () => {
|
||||
const review = b.slots.entries('conversation.plan-review.actions')[0]!
|
||||
expect(review.component).toBe(PlanReviewOpen)
|
||||
const reviewInjected = (review.inject as unknown as (sessionId: SessionId) => PlanReviewOpenInjected)(SID)
|
||||
// The automatic open reads the service's mounted-seat source, not a copy.
|
||||
expect(reviewInjected.hooks.sidebarMounted).toBe(b.mounted)
|
||||
const pending = { id: 'review', question: 'Approve?', plan: plan.markdown, callId: plan.callId, approve: { label: 'Approve' } }
|
||||
reviewInjected.openReview(pending, 'question:1')
|
||||
expect(b.openResource).toHaveBeenLastCalledWith(address)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi, type Mock } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
@@ -7,6 +7,7 @@ import type { ChatSnapshot } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import { chatViewDefinition } from '../../ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
import { PlanCards, PlanReviewOpen } from '../src/client/PlanCard.tsx'
|
||||
@@ -29,6 +30,16 @@ function planNode(data: typeof plan) {
|
||||
function planHook(nodes: readonly unknown[]): Parameters<typeof PlanCards>[0]['useChat'] {
|
||||
return ((select: (snapshot: unknown) => unknown) => select({ nodes: { values: () => nodes } })) as Parameters<typeof PlanCards>[0]['useChat']
|
||||
}
|
||||
/** The opener's props share; each test supplies the seat hook beside it. */
|
||||
function reviewProps(review: { plan: string; callId?: typeof plan.callId }, openReview: Mock, store: ReturnType<ReturnType<typeof createPlanReviewStore>['create']>) {
|
||||
return { review, requestKey: 'question:1', t, openReview, actions: store.actions,
|
||||
useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
|
||||
} as unknown as Parameters<typeof PlanReviewOpen>[0]
|
||||
}
|
||||
/** A bound `useSidebarMounted` reading one observable seat value. */
|
||||
function seatHook(read: () => SessionId | undefined) {
|
||||
return <S,>(select: (session: SessionId | undefined) => S): S => select(read())
|
||||
}
|
||||
|
||||
describe('submitted plan identity', () => {
|
||||
it('reads native, running PTC, and settled PTC calls', () => {
|
||||
@@ -164,9 +175,7 @@ describe('plan entry points and document', () => {
|
||||
const openReview = vi.fn()
|
||||
const store = createPlanReviewStore().create()
|
||||
const review = { plan: markdown, ...(logged ? { callId: plan.callId } : {}) }
|
||||
const props = { review, requestKey: 'question:1', t, openReview, actions: store.actions,
|
||||
useStore: (select: (state: ReturnType<typeof store.getSnapshot>) => unknown) => select(store.getSnapshot()),
|
||||
} as unknown as Parameters<typeof PlanReviewOpen>[0]
|
||||
const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => target.session.sessionId) }
|
||||
const first = render(<PlanReviewOpen {...props} />)
|
||||
expect(openReview).toHaveBeenCalledExactlyOnceWith(review, 'question:1')
|
||||
first.rerender(<PlanReviewOpen {...props} />)
|
||||
@@ -186,6 +195,31 @@ describe('plan entry points and document', () => {
|
||||
} as Parameters<typeof PlanReviewOpen>[0]} />)
|
||||
expect(openReview).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
it('waits for a mounted sidebar seat before opening automatically, then opens once', () => {
|
||||
// The review and the seat mount in one commit, the review first: its effect
|
||||
// runs while no seat is bound, and the seat's own effect binds afterwards.
|
||||
const openReview = vi.fn()
|
||||
const store = createPlanReviewStore().create()
|
||||
const mounted = createSnapshotStore<SessionId | undefined>(undefined)
|
||||
const review = { plan: markdown, callId: plan.callId }
|
||||
const props = { ...reviewProps(review, openReview, store), useSidebarMounted: seatHook(() => mounted.getSnapshot()) }
|
||||
const view = render(<PlanReviewOpen {...props} />)
|
||||
expect(openReview).not.toHaveBeenCalled()
|
||||
expect(store.getSnapshot().opened).toEqual({})
|
||||
// The manual opener stays available without a seat; the service decides what to do.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open plan in sidebar' }))
|
||||
expect(openReview).toHaveBeenCalledTimes(1)
|
||||
mounted.set(target.session.sessionId)
|
||||
view.rerender(<PlanReviewOpen {...props} />)
|
||||
expect(openReview).toHaveBeenCalledTimes(2)
|
||||
expect(store.getSnapshot().opened).toEqual({ [`call:${plan.callId}`]: true })
|
||||
view.rerender(<PlanReviewOpen {...props} />)
|
||||
mounted.set(undefined)
|
||||
view.rerender(<PlanReviewOpen {...props} />)
|
||||
mounted.set(target.session.sessionId)
|
||||
view.rerender(<PlanReviewOpen {...props} />)
|
||||
expect(openReview).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
it('renders temporary Markdown and reports expired navigation after reload', () => {
|
||||
const address = reviewPreviewAddress(target.session.sessionId, 'window:question:1')
|
||||
expect(isReviewPreviewAddress(address)).toBe(true)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar-right/README.md
|
||||
README.md: e9c143f34076c308f3afdb348cc09506f3983a35
|
||||
README.zh.md: 72d0c20817c690c1dcca5a97b67adbdfa1f11261
|
||||
README.md: 9318723b99adb003b25cf69bf07a8419eea6d1ae
|
||||
README.zh.md: dcd6dcd59ac19c18c0a7d2e9a72e7a92a6d3bec4
|
||||
@@ -92,7 +92,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
|
||||
|
||||
`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference.
|
||||
|
||||
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws.
|
||||
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; the presentation switch is the panel's own control and not part of this face. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the mounted surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands need a mounted session surface; with none, they throw rather than write into a surface nobody draws. `mounted` is the observable of the session whose seat is on screen, `undefined` while a global panel replaces the Conversation or no session is selected. A component that opens content from its own mount effect reads it through a bound hook and acts once it is defined: the frame mounts the Conversation column ahead of this seat, and the seat publishes its binding from a passive effect of the same commit, so an effect that assumed a bound seat would run first and throw. The plan review's automatic open reads it this way.
|
||||
|
||||
<a id="the-tab-domain"></a>
|
||||
## The Tab domain
|
||||
|
||||
@@ -92,7 +92,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
|
||||
|
||||
`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。
|
||||
|
||||
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。
|
||||
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;形态切换是面板自己的控件,不属于这个接口。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进已挂载停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令需要一个已挂载的会话停靠面;没有时它们 throw,而不是写进一个没人绘制的面里。`mounted` 是「席位正在屏幕上的那个会话」的可观察值,全局面板替代 Conversation 或没有选中会话时为 `undefined`。要在自己的挂载 effect 里打开内容的组件通过绑定的 hook 读取它,并在它有值后再行动:框架把 Conversation 列挂载在这个席位之前,而席位在同一次提交的 passive effect 里才发布绑定,一个假定席位已绑定的 effect 会先运行并 throw。计划审阅的自动打开就是这样读取它的。
|
||||
|
||||
<a id="the-tab-domain"></a>
|
||||
## Tab 域
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* session id, bound actions, its surface — for exactly as long as it is mounted,
|
||||
* and every command on the public face goes through that binding; a command
|
||||
* arriving with no seat mounted has no session to act on and fails loudly rather
|
||||
* than writing into a surface nobody is drawing. And the plugin adopts each
|
||||
* than writing into a surface nobody is drawing. `mounted` publishes that
|
||||
* binding's session as an observable, so a consumer that wants to open content
|
||||
* as soon as a seat is on screen subscribes to it instead of assuming one is
|
||||
* bound when its own effect runs. And the plugin adopts each
|
||||
* session's store instance as the runtime mints it, so the controller reaches
|
||||
* any session's store by id and syncs the Tab domain from that store's commits.
|
||||
*
|
||||
@@ -28,6 +31,7 @@
|
||||
* callers use the service's navigation methods.
|
||||
*/
|
||||
import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { FloatRect, PaneId, TabId, TabRecord } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import { activeDockPaneId, canSplit, findContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -148,6 +152,15 @@ export type SidebarRightCloseHandler = (sessionId: SessionId, tab: TabRecord) =>
|
||||
|
||||
/** The outward right-Sidebar face (`ctx.sidebarRight`). */
|
||||
export interface ISidebarRight {
|
||||
/**
|
||||
* The session whose seat is mounted, or `undefined` while no seat is on
|
||||
* screen (a global panel is active, or no session is selected). Moves when a
|
||||
* seat binds or releases, so a component that opens content from its own
|
||||
* mount effect reads it through a bound hook and acts once it is defined:
|
||||
* the frame mounts the Conversation column ahead of the seat, and the seat
|
||||
* publishes its binding from a passive effect of the same commit.
|
||||
*/
|
||||
readonly mounted: ObservableSnapshot<SessionId | undefined>
|
||||
/**
|
||||
* Open a resource: claim it, place it, reveal the column, record the navigation.
|
||||
*
|
||||
@@ -216,6 +229,9 @@ export interface ISidebarRight {
|
||||
export class SidebarRightController implements ISidebarRight {
|
||||
/** Open tab metadata across saved and adopted Sessions, independent of visible seats. */
|
||||
readonly openTabs: SidebarTabInventory['source']
|
||||
private readonly mountedSession = createSnapshotStore<SessionId | undefined>(undefined)
|
||||
/** The mounted seat's session; see {@link ISidebarRight.mounted}. */
|
||||
readonly mounted: ObservableSnapshot<SessionId | undefined> = this.mountedSession
|
||||
private binding: SidebarRightBinding | undefined
|
||||
private readonly closeHandlers = new Map<string, SidebarRightCloseHandler>()
|
||||
|
||||
@@ -271,13 +287,22 @@ export class SidebarRightController implements ISidebarRight {
|
||||
*/
|
||||
bind(binding: SidebarRightBinding): () => void {
|
||||
this.binding = binding
|
||||
this.publishMounted()
|
||||
return () => {
|
||||
// A newer seat may already have taken over; only the binding that is
|
||||
// still ours may be cleared.
|
||||
if (this.binding === binding) this.binding = undefined
|
||||
if (this.binding !== binding) return
|
||||
this.binding = undefined
|
||||
this.publishMounted()
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish the mounted session only when it changes; a republished binding for the same session is silent. */
|
||||
private publishMounted(): void {
|
||||
const next = this.binding?.sessionId
|
||||
if (this.mountedSession.getSnapshot() !== next) this.mountedSession.set(next)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a resource: claim it, place it, reveal the column, record the navigation.
|
||||
* @param address - a `dsh-resource://<type>/…` address.
|
||||
@@ -426,7 +451,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
* @returns the record, or `undefined` with no mounted surface.
|
||||
*/
|
||||
active(): TabRecord | undefined {
|
||||
const layout = this.mounted()?.layout
|
||||
const layout = this.mountedSurface()?.layout
|
||||
if (layout === undefined) return undefined
|
||||
const { activeTabId } = getPane(layout, layout.activePaneId)
|
||||
return Object.values(layout.tabs).find(tab => tab.id === activeTabId)
|
||||
@@ -437,7 +462,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
* @returns `true` while expanded; `false` while collapsed or with no mounted surface.
|
||||
*/
|
||||
isExpanded(): boolean {
|
||||
return this.mounted()?.layout.expanded ?? false
|
||||
return this.mountedSurface()?.layout.expanded ?? false
|
||||
}
|
||||
|
||||
/** Collapse an expanded column, or expand a collapsed one. */
|
||||
@@ -452,7 +477,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
*/
|
||||
focus(tabId: TabId): void {
|
||||
const { sessionId, actions } = this.require()
|
||||
if (this.mounted()?.layout.tabs[tabId] === undefined) return
|
||||
if (this.mountedSurface()?.layout.tabs[tabId] === undefined) return
|
||||
actions.focusTab(sessionId, tabId)
|
||||
}
|
||||
|
||||
@@ -463,7 +488,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
*/
|
||||
split(paneId?: PaneId): PaneId | undefined {
|
||||
const { sessionId, actions, canSplitPane } = this.require()
|
||||
const layout = this.mounted()?.layout
|
||||
const layout = this.mountedSurface()?.layout
|
||||
if (layout === undefined) return undefined
|
||||
const target = paneId ?? activeDockPaneId(layout)
|
||||
const node = layout.nodes[target]
|
||||
@@ -481,7 +506,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
*/
|
||||
float(tabId: TabId, rect?: FloatRect): void {
|
||||
const { sessionId, actions } = this.require()
|
||||
const layout = this.mounted()?.layout
|
||||
const layout = this.mountedSurface()?.layout
|
||||
if (layout === undefined || layout.tabs[tabId] === undefined) return
|
||||
if (findTabPane(layout, tabId).host !== 'dock') return
|
||||
actions.floatTab(sessionId, tabId, rect)
|
||||
@@ -493,7 +518,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
*/
|
||||
dock(paneId: PaneId): void {
|
||||
const { sessionId, actions } = this.require()
|
||||
const node = this.mounted()?.layout.nodes[paneId]
|
||||
const node = this.mountedSurface()?.layout.nodes[paneId]
|
||||
if (node === undefined || node.kind !== 'pane' || node.host !== 'float') return
|
||||
actions.unfloatPane(sessionId, paneId)
|
||||
}
|
||||
@@ -520,7 +545,7 @@ export class SidebarRightController implements ISidebarRight {
|
||||
}
|
||||
|
||||
/** The mounted session's surface; `undefined` without a seat or before its first open. */
|
||||
private mounted(): SurfaceState | undefined {
|
||||
private mountedSurface(): SurfaceState | undefined {
|
||||
const { binding } = this
|
||||
return binding === undefined ? undefined : binding.surfaces[binding.sessionId]
|
||||
}
|
||||
|
||||
@@ -80,7 +80,8 @@ export interface SidebarRightInjected {
|
||||
* Publish this seat's session, actions, and the store's surfaces to `ctx.sidebarRight`.
|
||||
*
|
||||
* The service is root-scoped and cannot read a per-entry store, so the only
|
||||
* honest source is the mounted seat. Held for as long as the seat is mounted.
|
||||
* honest source is the mounted seat. Held for as long as the seat is mounted;
|
||||
* the service publishes the bound session through `ctx.sidebarRight.mounted`.
|
||||
* @param binding - what a command needs to act on this session, and what a tab's own action needs to act on its.
|
||||
* @returns a release callback.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/** Sidebar presentation and tab subscriptions through the production slot renderer. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent } from '@testing-library/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react'
|
||||
import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -21,6 +21,13 @@ declare module '../src/client/contract/params.ts' {
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/** A Conversation-column stand-in rendered before the seat, opening a resource as soon as a seat is mounted. */
|
||||
'sidebar-right.test.opener': { kind: 'single'; scope: 'session'; owner: { armed: boolean } }
|
||||
}
|
||||
}
|
||||
|
||||
const SESSION = 's-test' as SessionId
|
||||
const OTHER = 's-other' as SessionId
|
||||
const runtimes: SlotTestRuntime[] = []
|
||||
@@ -57,7 +64,7 @@ function transition(property = 'transform') {
|
||||
}
|
||||
}
|
||||
|
||||
async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
|
||||
async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, opener = false) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
runtimes.push(runtime)
|
||||
const frame = { openRightbar: vi.fn(), closeRightbar: vi.fn() }
|
||||
@@ -68,12 +75,40 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
await runtime.declare({
|
||||
'sidebar-right.test.opener': { kind: 'single', scope: 'session' },
|
||||
'rightbar': { kind: 'single', scope: 'root' },
|
||||
'conversation.session.header.corner': { kind: 'single', scope: 'session' },
|
||||
})
|
||||
await runtime.sessions.add({ id: SESSION })
|
||||
let reference = runtime.sessions.retainFor(runtime.ctx, SESSION, { source: 'mainView' })
|
||||
const feature = await runtime.mount({ inject: [...inject], apply })
|
||||
// The frame mounts the Conversation column before the right column, so a
|
||||
// Conversation component's mount effect runs before the seat's. The opener
|
||||
// stands in for one that opens a resource as soon as a seat is mounted — it
|
||||
// reads `mounted` the way a slot component reads its bound hook — and shows
|
||||
// only while the Conversation is selected, as the main panel does.
|
||||
const opened: string[] = []
|
||||
function OpenerMount() {
|
||||
const { mounted } = runtime.ctx.sidebarRight
|
||||
const seat = useSyncExternalStore(listener => mounted.subscribe(listener), () => mounted.getSnapshot())
|
||||
useEffect(() => {
|
||||
if (seat === undefined) return
|
||||
const address = `dsh-resource://file/session/s-test/arrival-${opened.length + 1}.txt`
|
||||
runtime.ctx.sidebarRight.openResource(address)
|
||||
opened.push(address)
|
||||
}, [seat])
|
||||
return null
|
||||
}
|
||||
function Opener({ usePanelInfo, armed }: PropsRuntime<'sidebar-right.test.opener'>) {
|
||||
const visible = usePanelInfo(info => info.activePanelId === null)
|
||||
return armed && visible ? <OpenerMount /> : null
|
||||
}
|
||||
let arm = (_armed: boolean): void => { throw new Error('mountSeat(opener = true) renders the opener') }
|
||||
if (opener) {
|
||||
await act(async () => { runtime.slots.register({ name: 'sidebar-right.test.opener' }, Opener) })
|
||||
const openerView = runtime.renderSlot('sidebar-right.test.opener', { armed: false }, { session: reference })
|
||||
arm = (armed) => { openerView.update({ armed }) }
|
||||
}
|
||||
const bodies = new Map<string, SidebarRightTabInfo>()
|
||||
const titles = new Map<string, SidebarRightTabInfo>()
|
||||
const hooks = new Map<string, PropsRuntime<'sidebar.right.pane.tab'>['useTabInfo']>()
|
||||
@@ -115,7 +150,7 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0) {
|
||||
}
|
||||
return {
|
||||
runtime, feature, controller, instance, actions: instance.actions, layout,
|
||||
open, selectSession, frame, pin, bodies, titles, hooks, view,
|
||||
open, selectSession, frame, pin, bodies, titles, hooks, view, arm, opened,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,6 +174,29 @@ describe('RightbarSeat presentation', () => {
|
||||
expect(h.layout()).toBe(retained)
|
||||
})
|
||||
|
||||
it('publishes the mounted seat so a Conversation opener acts in the commit that returns from a global panel', async () => {
|
||||
const h = await mountSeat(1440, true, 0, true)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
|
||||
// Armed while the seat is already bound: the ordinary case.
|
||||
act(() => { h.arm(true) })
|
||||
expect(h.opened).toHaveLength(1)
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened)
|
||||
// A global panel unmounts both columns and the mounted seat with them.
|
||||
act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) })
|
||||
expect(h.view.container.querySelector('[data-sidebar-right-panel]')).toBeNull()
|
||||
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
|
||||
// Returning mounts both in one commit, the Conversation's effects first; the
|
||||
// opener sees no seat, then the seat's own effect binds and it opens.
|
||||
act(() => { h.runtime.panelInfo.set({ activePanelId: null }) })
|
||||
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
|
||||
expect(h.opened).toHaveLength(2)
|
||||
expect(Object.values(h.layout().tabs).map(tab => tab.contentId)).toEqual(h.opened)
|
||||
expect(h.controller.active()?.contentId).toBe(h.opened[1])
|
||||
expect(errors.mock.calls.map(call => String(call[0])).filter(text => text.includes('slot entry crashed'))).toEqual([])
|
||||
expect(document.querySelector('[data-slot-error]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([0, 1, 2])('selects the default from %i guide entries and protects only a sole guide', async (entryCount) => {
|
||||
const h = await mountSeat(1440, true, entryCount)
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
|
||||
@@ -111,6 +111,26 @@ describe('SidebarRightController — opening', () => {
|
||||
expect(() => { controller.dock('pane1' as PaneId) }).toThrow('no session surface is mounted')
|
||||
})
|
||||
|
||||
it('publishes the mounted session only on real transitions of the seat binding', () => {
|
||||
const h = harness()
|
||||
const seen: (string | undefined)[] = []
|
||||
const unsubscribe = h.controller.mounted.subscribe(() => { seen.push(h.controller.mounted.getSnapshot()) })
|
||||
try {
|
||||
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
|
||||
const first = h.publish()
|
||||
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
|
||||
// The seat republishes on every store commit; the same session is silent.
|
||||
const second = h.publish()
|
||||
expect(seen).toEqual([SESSION])
|
||||
// A stale release — the first seat's, after a newer one took over — changes nothing.
|
||||
first()
|
||||
expect(h.controller.mounted.getSnapshot()).toBe(SESSION)
|
||||
second()
|
||||
expect(h.controller.mounted.getSnapshot()).toBeUndefined()
|
||||
expect(seen).toEqual([SESSION, undefined])
|
||||
} finally { unsubscribe() }
|
||||
})
|
||||
|
||||
it('refuses an address no registered type claims, before touching the surface', () => {
|
||||
const { controller, publish, titles } = harness()
|
||||
publish()
|
||||
|
||||
Reference in new issue
Block a user