fix(web): close details when current session changes

This commit is contained in:
NI0317
2026-07-29 11:21:13 +08:00
parent 1f242753ec
commit 406cd3602b
9 changed files with 210 additions and 26 deletions

View File

@@ -21,8 +21,10 @@ import type {
SessionId, SessionListState, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
// Session selection controls for the SessionProvider and useSessions stubs.
const selectedSession = { current: 's-test' as SessionId | undefined }
const selectedSessionBlank = { current: false }
const sessionsPhase = { current: 'ready' as SessionListState['phase'] }
const baselinesReady = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
@@ -31,7 +33,7 @@ const baselinesReady = { current: true }
// shape. Typed as the seat's own component type so the branded sessionId
// parameter stays contract-checked.
const SessionProviderStub: AppFrameProps['SessionProvider'] = ({ children, empty }) =>
sessionMode.current ? <>{children('s-test' as Parameters<typeof children>[0])}</> : <>{empty?.() ?? null}</>
selectedSession.current === undefined ? <>{empty?.() ?? null}</> : <>{children(selectedSession.current)}</>
/** Observer stub: captures the callback so tests can fire resizes manually. */
@@ -64,32 +66,35 @@ function mountFrame() {
if (key === 'conversation.empty') return <div data-testid="empty-content" />
return <div data-testid="other-content" />
}) as AppFrameProps['renderSlot']
const sessionId = 's-test' as SessionId
const sessionState = {
ids: sessionMode.current ? [sessionId] : [],
byId: sessionMode.current
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: false, updatedAt: 1 } }
: {},
current: sessionMode.current ? sessionId : undefined,
phase: 'ready',
} as SessionListState
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
const useSessions = ((sel: (s: SessionListState) => unknown) => {
const current = selectedSession.current
const sessionState = {
ids: current === undefined ? [] : [current],
byId: current === undefined
? {}
: { [current]: { id: current, displayTitle: 'Test', running: false, blank: selectedSessionBlank.current, updatedAt: 1 } },
current,
phase: sessionsPhase.current,
} as SessionListState
return sel(sessionState)
}) as never
const workspaceState: WorkspaceListState = {
items: [], state: 'idle', phase: 'ready', error: null,
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
}
const utils = render(
const element = () => (
<AppFrame
useStore={hookOf(instance) as never}
useStore={hookOf(instance)}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
SessionProvider={SessionProviderStub}
/>,
/>
)
const utils = render(element())
const frame = utils.container.firstElementChild as HTMLElement
return { instance, frame, slotCalls, ...utils }
return { instance, frame, slotCalls, rerenderFrame: () => { utils.rerender(element()) }, ...utils }
}
function tracks(frame: HTMLElement): number[] {
@@ -109,7 +114,9 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
selectedSession.current = 's-test' as SessionId
selectedSessionBlank.current = false
sessionsPhase.current = 'ready'
baselinesReady.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
@@ -154,7 +161,7 @@ describe('AppFrame', () => {
it('keeps the conversation slot mounted while no session is current', () => {
// No current session: the session-maybe conversation shell owns the New
// Session view itself — the center column renders it unconditionally.
sessionMode.current = false
selectedSession.current = undefined
const { slotCalls, getByTestId } = mountFrame()
expect(getByTestId('center-content')).toBeTruthy()
expect(slotCalls.map(c => c.key)).toContain('conversation')
@@ -169,6 +176,46 @@ describe('AppFrame', () => {
expect(slotCalls.map(c => c.key)).toContain('details')
})
it('closes details when the ready current Session changes, including New Session, and keeps it closed on return', () => {
const { frame, instance, rerenderFrame } = mountFrame()
expect(tracks(frame)).toEqual([280, 360])
selectedSession.current = 's-next' as SessionId
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
act(() => { instance.actions.openDetails() })
selectedSession.current = 's-blank' as SessionId
selectedSessionBlank.current = true
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
selectedSession.current = 's-test' as SessionId
selectedSessionBlank.current = false
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
act(() => { instance.actions.openDetails() })
selectedSession.current = undefined
act(() => { rerenderFrame() })
expect(tracks(frame)).toEqual([280, 0])
})
it('preserves open details across active-session baseline restore but closes it for an initial New Session view', () => {
sessionsPhase.current = 'pending'
const active = mountFrame()
expect(tracks(active.frame)).toEqual([280, 360])
sessionsPhase.current = 'ready'
act(() => { active.rerenderFrame() })
expect(tracks(active.frame)).toEqual([280, 360])
active.unmount()
selectedSession.current = 's-blank' as SessionId
selectedSessionBlank.current = true
const blank = mountFrame()
expect(tracks(blank.frame)).toEqual([280, 0])
})
it('sidebar slot receives live concession output as owner props', () => {
const { slotCalls } = mountFrame()
expect(slotCalls.find(c => c.key === 'sidebar')!.props).toEqual({ collapsed: false, width: 280 })