fix(sidebar): preserve Session Tab lifetimes and pane-local pages
This commit is contained in:
1 parent
a44b143ace
commit
8463abc59d
3 files changed
+108
-9
No files matched your search
@@ -37,7 +37,7 @@ import { sidebarTargetFromElement, type SidebarRightTarget } from './focus.ts'
|
||||
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 { activeDockPaneId, canSplit, findContentTab, findPaneContentTab, dockPaneIds, findTabPane, getPane } from '@deepseek-ai/dsh-client-ui-dockkit'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { SidebarRightNavigationParams, SidebarRightResourceParams, SidebarRightTabParamsFor } from './contract/params.ts'
|
||||
@@ -440,8 +440,11 @@ export class SidebarRightController implements ISidebarRight {
|
||||
}, (tabId) => { this.tabDomain.navigate(sessionId, tabId, { address, params }) }) }
|
||||
const layout = surface?.layout
|
||||
const replaced = placement.replaceTab === undefined ? undefined : layout?.tabs[placement.replaceTab]
|
||||
const revealed = layout === undefined || placement.revealIfOpened === false
|
||||
? undefined : findContentTab(layout, claim.contentId, claim.kind)
|
||||
const replacementPane = replaced === undefined || layout === undefined ? undefined : findTabPane(layout, replaced.id)
|
||||
const revealed = layout === undefined ? undefined
|
||||
: claim.contentId === pageAddress(claim.kind)
|
||||
? findPaneContentTab(layout, replacementPane?.host === 'dock' ? replacementPane.id : placement.paneId ?? activeDockPaneId(layout), claim.contentId, claim.kind)
|
||||
: placement.revealIfOpened === false ? undefined : findContentTab(layout, claim.contentId, claim.kind)
|
||||
if (replaced === undefined || replaced.id === revealed) { commit(); return }
|
||||
this.removeAfterCleanup(sessionId, replaced, commit)
|
||||
}
|
||||
|
||||
@@ -210,8 +210,8 @@ function TabSlot({
|
||||
}), [tab.id, seat, fullscreen, active, signal, tabActions, useStore, useTabNavigation, shortcuts])
|
||||
const content = renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind, fallback, hookContext })
|
||||
return seat === 'sidebar.right.pane.tab.title'
|
||||
? <span className={css.tabTitle} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</span>
|
||||
: <div className={css.tabBody} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</div>
|
||||
? <span key={id} className={css.tabTitle} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</span>
|
||||
: <div key={id} className={css.tabBody} data-sidebar-right-tab={tab.id} data-sidebar-right-occurrence={id}>{content}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -312,10 +312,8 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject<H
|
||||
data-sidebar-right-session={sessionId}
|
||||
data-sidebar-right-panel={fullscreen ? 'fullscreen' : 'push'}
|
||||
data-sidebar-right-open={expanded || undefined}
|
||||
// Off-edge is out of reach: the stylesheet's visibility flip takes the
|
||||
// hidden panel out of the tab order, and this takes it out of the
|
||||
// accessibility tree.
|
||||
aria-hidden={!expanded || undefined}
|
||||
// Dockkit hides individual docked hosts; floating descendants remain accessible.
|
||||
aria-hidden={(!expanded && surface.layout.floats.length === 0) || undefined}
|
||||
>
|
||||
<div className={css.panelBody}>
|
||||
<DockLayout
|
||||
|
||||
@@ -168,6 +168,103 @@ async function mountSeat(viewportWidth = 1440, canShow = true, entryCount = 0, o
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps duplicate Session Tabs independent and preserves a body through docking and float focus', async () => {
|
||||
const h = await mountSeat()
|
||||
await act(async () => {
|
||||
h.runtime.ctx.sidebarRightTabs.register({ id: 'test/shared', kind: 'shared', keepMounted: true, title: () => 'Shared' })
|
||||
h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/shared' }, () => <input data-page-input />)
|
||||
})
|
||||
act(() => { h.controller.openTab('shared') })
|
||||
const tab = h.controller.active()!
|
||||
const body = document.querySelector<HTMLInputElement>('[data-page-input]')!
|
||||
const cell = body.closest('[data-dockkit-host]')
|
||||
const close = vi.fn()
|
||||
h.runtime.ctx.effect(() => h.controller.registerCloseHandler('shared', close))
|
||||
act(() => { h.controller.openTab('shared', { replaceTab: tab.id, revealIfOpened: false }) })
|
||||
expect(close).not.toHaveBeenCalled()
|
||||
let otherPane: PaneId | undefined
|
||||
act(() => { otherPane = h.controller.split() })
|
||||
fireEvent.click(body)
|
||||
expect(h.controller.active()?.id).toBe(tab.id)
|
||||
expect(otherPane).toBeDefined()
|
||||
act(() => { h.controller.openTab('shared', { paneId: otherPane!, revealIfOpened: false, preferNewPane: true }) })
|
||||
expect(h.controller.tabsIn(SESSION).filter(tab => tab.kind === 'shared')).toHaveLength(2)
|
||||
const duplicate = h.controller.active()!
|
||||
expect(duplicate.id).not.toBe(tab.id)
|
||||
expect(document.querySelectorAll('[data-page-input]')).toHaveLength(2)
|
||||
act(() => { h.controller.close(duplicate.id) })
|
||||
act(() => { h.controller.float(tab.id) })
|
||||
expect(document.querySelector('[data-page-input]')).toBe(body)
|
||||
expect(body.closest('[data-dockkit-host]')).toBe(cell)
|
||||
const floating = h.controller.focusedTarget(body)!
|
||||
expect(floating.host).toBe('float')
|
||||
act(() => { h.actions.setExpanded(SESSION, false) })
|
||||
expect(body.closest('[hidden]')).toBeNull()
|
||||
expect(body.closest('[aria-hidden="true"]')).toBeNull()
|
||||
act(() => { body.focus() })
|
||||
expect(h.controller.focusedTarget()?.tabId).toBe(tab.id)
|
||||
act(() => { h.controller.dock(floating.paneId) })
|
||||
expect(document.querySelector('[data-page-input]')).toBe(body)
|
||||
expect(body.closest('[aria-hidden="true"]')).not.toBeNull()
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(body.closest('[data-dockkit-host]')).toBe(cell)
|
||||
})
|
||||
|
||||
it('retains a collapsed Session Tab and remounts an unretained hidden body', async () => {
|
||||
const h = await mountSeat()
|
||||
let latest: SidebarRightTabInfo | undefined
|
||||
function Body(props: PropsRuntime<'sidebar.right.pane.tab'>) {
|
||||
latest = props.useTabInfo()
|
||||
return <input data-retention-parity />
|
||||
}
|
||||
await act(async () => {
|
||||
h.runtime.ctx.sidebarRightTabs.register({ id: 'test/retention', kind: 'retention', title: () => 'Retention' })
|
||||
h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/retention' }, Body)
|
||||
})
|
||||
act(() => { h.controller.openTab('retention') })
|
||||
const tab = h.controller.active()!
|
||||
const body = document.querySelector('[data-retention-parity]')!
|
||||
const signal = latest!.tab.signal
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(body.isConnected).toBe(true)
|
||||
expect(latest!.sidebar.expanded).toBe(false)
|
||||
expect(latest!.tab.visible).toBe(false)
|
||||
act(() => { h.controller.toggleExpanded() })
|
||||
expect(document.querySelector('[data-retention-parity]')).toBe(body)
|
||||
act(() => { h.controller.openTab('guide') })
|
||||
expect(body.isConnected).toBe(false)
|
||||
expect(signal.aborted).toBe(false)
|
||||
act(() => { h.controller.focus(tab.id) })
|
||||
expect(document.querySelector('[data-retention-parity]')).not.toBe(body)
|
||||
expect(latest!.tab.signal).toBe(signal)
|
||||
})
|
||||
|
||||
it('rebinds a restored Session Tab lifetime even when close and undo share one render', async () => {
|
||||
const h = await mountSeat()
|
||||
let lifetime: AbortSignal | undefined
|
||||
function Body(props: PropsRuntime<'sidebar.right.pane.tab'>) {
|
||||
lifetime = props.useTabInfo().tab.signal
|
||||
const [count, setCount] = useState(0)
|
||||
return <button data-restored-tab onClick={() => { setCount(value => value + 1) }}>{count}</button>
|
||||
}
|
||||
await act(async () => {
|
||||
h.runtime.ctx.sidebarRightTabs.register({ id: 'test/restored', kind: 'restored', keepMounted: true, title: () => 'Restored' })
|
||||
h.runtime.slots.register({ name: 'sidebar.right.pane.tab', key: 'test/restored' }, Body)
|
||||
})
|
||||
act(() => { h.controller.openTab('restored') })
|
||||
const tab = h.controller.active()!
|
||||
const original = document.querySelector('[data-restored-tab]')!
|
||||
const before = lifetime!
|
||||
fireEvent.click(original)
|
||||
act(() => { h.controller.close(tab.id); h.controller._undo() })
|
||||
expect(h.controller.active()?.id).toBe(tab.id)
|
||||
expect(before.aborted).toBe(true)
|
||||
expect(lifetime).not.toBe(before)
|
||||
expect(document.querySelector('[data-restored-tab]')).not.toBe(original)
|
||||
expect(original.isConnected).toBe(false)
|
||||
expect(document.querySelector('[data-restored-tab]')?.textContent).toBe('0')
|
||||
})
|
||||
|
||||
function element(container: HTMLElement, selector: string): HTMLElement {
|
||||
const node = container.querySelector<HTMLElement>(selector)
|
||||
if (node === null) throw new Error(`expected ${selector}`)
|
||||
@@ -803,6 +900,7 @@ describe('slot-owned useTabInfo', () => {
|
||||
h.controller.split()
|
||||
})
|
||||
const anchor = element(h.view.container, '[data-dockkit-split-button]').parentElement!
|
||||
fireEvent.keyDown(anchor, { key: 'Tab' })
|
||||
fireEvent.focus(anchor)
|
||||
expect(document.querySelector('[role="tooltip"]')?.getAttribute('aria-label')).toBe('Two panes is the limit ⌘ \\')
|
||||
act(() => { h.catalog.set([{ ...split, binding: { code: 'KeyG', modifiers: ['control'] },
|
||||
|
||||
Reference in new issue
Block a user