feat(web): add workspace-aware session flow

This commit is contained in:
imccyu
2026-07-25 16:04:48 +08:00
parent 755e2a8c51
commit 9eb9c70a8a
170 changed files with 7573 additions and 3006 deletions

View File

@@ -1,10 +1,10 @@
# @deepseek-ai/dsh-client-ui-layout
Shell plugin: three-column AppFrame (drag handles, concession chain) + ctx.layout viewing-state service (nav, panel widths, persist); defines the sidebar/conversation/details/conversation.empty slots. The sidebar is fixed-width (it never concedes to viewport pressure — only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width; collapse/expand animates the grid tracks on the deepsuite sider curve. Contract: api-contracts v3 §5.
Shell plugin: three-column AppFrame (drag handles and concession chain) plus the `ctx.layout` panel-geometry service; it registers into the runtime-owned `root` slot and declares `sidebar`, `conversation`, `details`, and `conversation.empty`. The sidebar is fixed-width (only details shrinks, then auto-closes); a closed sidebar retains a 56px control rail while details closes to zero width.
Slot declarations use the composed-props entry form (`owner` share, no full `props`): the exported OwnerShare contracts are `SidebarOwnerProps` / `ConvOwnerProps` / `DetailsOwnerProps` / `EmptyOwnerProps` — registrants reference them via `OwnerOf<'sidebar' | ...>` and compose their own injected share locally. No entry declares `children` (declaring it requires the registered component to carry the slots face — reserved for future business slots): delegation authority is the component-side whitelist, i.e. AppFrame's `ScopedSlots<FrameSlotKey>` face over sidebar/conversation/details/conversation.empty. Since the root-slot rework the frame itself registers into 'root' and renders those child slots at its own render sites; the shell only renders 'root'.
AppFrame reads the runtime Session projection: `baselinesReady` selects loading, a page-local `SessionListState.intent` selects the empty composer, and a connected Session renders through `SessionProvider`. The conversation and empty-state owner shares are empty; each registrant obtains business data from standard hooks and actions from its own inject face. The sidebar owner share contains only `collapsed` and `width`; navigation actions belong to sidebar's own injected service face.
The export surface is the cross-package contract only: the AppFrame trio (+ `AppFrameProps`) consumed by the web shell's assembly, `LayoutService` with its store shapes (`NavState`/`PanelState`/`ViewId`), and the OwnerShare contracts. The concession-chain solver (`computeColumns`) and its geometry constants are package-internal; tests import them from `/src`.
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal; tests import internals through `/src`.
## Model Experience

View File

@@ -6,10 +6,10 @@
* renders HERE with live parameters from the concession solve, and the
* session pair renders under the SessionProvider standard seat (render-prop
* form, injected by the renderer because the children declaration contains
* session-scope slots; session slots get sessionId as a framework-standard
* prop, so the owner shares stay empty). Pure component: everything arrives
* through the four prop shares — zero cordis or framework imports, zero
* self-made hooks.
* session-scope slots; session data arrives through framework-standard props
* and each registrant's inject face). Pure component: everything arrives
* through the three framework shares — zero cordis or framework imports,
* zero self-made hooks.
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
@@ -18,7 +18,7 @@ import { computeColumns } from './columns.ts'
import type { createLayoutStore } from './stores.ts'
import css from './AppFrame.module.css'
/** Full composed props: runtime share + child-slot render share + store share (no business face). */
/** Full composed props: runtime share + child-slot render share + store share. */
export type AppFrameProps =
& PropsRuntime<'root'>
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
@@ -82,8 +82,17 @@ function DragHandle(props: { side: 'sidebar' | 'details'; left: number; onStart:
}
/** The three-column frame (see module doc). SessionProvider arrives as a standard seat (declaring a session-scope child summons it — no framework import). */
export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: AppFrameProps) {
export function AppFrame({
useStore,
actions,
renderSlot,
SessionProvider,
useSessions,
useWorkspaces,
}: AppFrameProps) {
const panels = useStore((s) => s)
const sessions = useSessions(s => s)
const baselinesReady = useWorkspaces(s => s.baselinesReady)
const frameRef = useRef<HTMLDivElement | null>(null)
const [viewport, setViewport] = useState(() => window.innerWidth)
@@ -143,24 +152,47 @@ export function AppFrame({ useStore, actions, renderSlot, SessionProvider }: App
sidebar keeps the mounted slot at the compact-rail width, and the
component sees its rendered state as owner params decided here
(collapsed follows the preference, not the resolved width). */}
{renderSlot('sidebar', { collapsed: panels.sidebar === 0, width: cols.sidebar })}
{renderSlot('sidebar', {
collapsed: panels.sidebar === 0,
width: cols.sidebar,
})}
</div>
<SessionProvider
empty={() => (
{!baselinesReady
? (
<>
<CenterColumn>{renderSlot('conversation.empty', {})}</CenterColumn>
<CenterColumn>
<div role="status">Loading workspaces and sessions</div>
</CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* sessionId is a framework-standard prop on session slots — the owner passes nothing. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
)
: sessions.intent !== undefined
? (
<>
<CenterColumn>
{renderSlot('conversation.empty', {})}
</CenterColumn>
<DetailsColumn />
</>
)
: (
<SessionProvider
empty={() => (
<>
<CenterColumn><div role="status">Opening session</div></CenterColumn>
<DetailsColumn />
</>
)}
>
{() => (
<>
{/* Session data and actions arrive from standard hooks and the registrant's inject face. */}
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
</>
)}
</SessionProvider>
)}
{/* The collapsed rail is fixed-width: no resize handle while closed. */}
{panels.sidebar > 0 && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
{cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}

View File

@@ -29,8 +29,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
// The 'root' entry itself is the runtime's built-in slot (declared
// there); these four are the frame's children, declared by the same
// register() call that contributes AppFrame. Session slots carry no
// owner share: the framework injects sessionId as a standard prop.
// register() call that contributes AppFrame. Session owners never pass
// sessionId: the framework injects it as a standard prop.
'sidebar': { kind: 'single'; scope: 'root'; owner: SidebarOwnerProps }
'conversation': { kind: 'single'; scope: 'session'; owner: ConvOwnerProps }
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
@@ -41,12 +41,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
// OwnerShare contracts — the render-side share the slot owner supplies at
// renderSlot. Registrants IMPORT these and compose their full component props
// through the four-share intersection (PropsRuntime & PropsRenderSlots &
// PropsStore & I). Session owner shares stay literally empty: a phantom
// `sessionId?: never` would intersect with the framework's mandatory
// SessionStandardProps.sessionId and collapse the composed props to never —
// the anti-smuggling guard is mutually exclusive with standard injection, so
// the standard member's own type is the only guard on standard keys. Phantom
// members remain fine on keys the standards never claim (EmptyOwnerProps).
// PropsStore & I). Conversation business state and actions arrive through
// framework-standard hooks and each registrant's inject face, not owner props.
/** Sidebar owner share: live column state from the frame's concession solve. */
export interface SidebarOwnerProps {
@@ -56,13 +52,13 @@ export interface SidebarOwnerProps {
width: number
}
/** Conversation owner share: empty — sessionId arrives as a framework-standard prop. */
/** Conversation owner share: business state and actions belong to the registrant. */
export interface ConvOwnerProps {}
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
export interface DetailsOwnerProps {}
/** Empty-state owner share (ui-conversation registers EmptyState here). */
/** Empty-state owner share: business state and actions belong to the registrant. */
export interface EmptyOwnerProps { children?: never }
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
@@ -89,9 +85,8 @@ export function apply(ctx: ClientContext): void {
// Exclusive store: the factory itself — the framework instantiates per
// entry and delivers useStore/actions to AppFrame as standard props.
store: createLayoutStore,
// No business face for the frame (I = {}): the hook's job is the
// assembly side effect wiring the entry's bound actions into the
// cross-plugin service seam.
// The hook's only side effect connects the root store to ctx.layout;
// conversation business actions belong to their registrants.
inject: (actions: PanelActions) => {
layout.attachPanels(actions)
return {}

View File

@@ -13,19 +13,19 @@ import {
SIDEBAR_DEFAULT, SIDEBAR_MAX, SIDEBAR_MIN,
} from './columns.ts'
/** Panel width preferences in px (0 = closed) — the layout store's state. */
type PanelWidths = { sidebar: number; details: number }
/** Layout store state: panel width preferences in px (0 = closed). */
type LayoutState = { sidebar: number; details: number }
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type LayoutActions = {
setSidebar: (draft: PanelWidths, px: number) => void
setDetails: (draft: PanelWidths, px: number) => void
toggleSidebar: (draft: PanelWidths) => void
openDetails: (draft: PanelWidths) => void
closeDetails: (draft: PanelWidths) => void
setSidebar: (draft: LayoutState, px: number) => void
setDetails: (draft: LayoutState, px: number) => void
toggleSidebar: (draft: LayoutState) => void
openDetails: (draft: LayoutState) => void
closeDetails: (draft: LayoutState) => void
}
/**
@@ -36,9 +36,9 @@ type LayoutActions = {
* open/close transitions write 0 / the default explicitly.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createLayoutStore(): EngineStoreHandle<PanelWidths, LayoutActions> {
return defineStore({
init: () => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
export function createLayoutStore(): EngineStoreHandle<LayoutState, LayoutActions> {
const handle = defineStore({
init: (): LayoutState => ({ sidebar: SIDEBAR_DEFAULT, details: 0 }),
persist: 'dsh.layout.panels',
actions: {
setSidebar: (d, px: number) => { d.sidebar = clampWidth(px, SIDEBAR_MIN, SIDEBAR_MAX) },
@@ -48,4 +48,5 @@ export function createLayoutStore(): EngineStoreHandle<PanelWidths, LayoutAction
closeDetails: (d) => { d.details = 0 },
},
})
return handle
}

View File

@@ -17,9 +17,13 @@ import { AppFrame } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.
import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client/AppFrame.tsx'
import { SIDEBAR_COLLAPSED } from '@deepseek-ai/dsh-client-ui-layout/src/client/columns.ts'
import { createLayoutStore } from '@deepseek-ai/dsh-client-ui-layout/src/client/stores.ts'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState,
} from '@deepseek-ai/dsh-client-runtime/client'
// Session-mode switch for the SessionProvider stub prop.
const sessionMode = { current: true }
const baselinesReady = { current: true }
// Render-prop contract stub fed through the standard seat prop (the renderer
// injects the real one in production): session mode runs children(id), empty
@@ -59,13 +63,31 @@ function mountFrame() {
if (key === 'details') return <div data-testid="details-content" />
return <div data-testid="empty-content" />
}) as AppFrameProps['renderSlot']
const useSessions = ((sel: (s: unknown) => unknown) => sel({ ids: [], byId: {} })) as never
const sessionId = 's-test' as SessionId
const workspaceId = 'w-test' as WorkspaceId
const sessionState = {
ids: sessionMode.current ? [sessionId] : [],
byId: sessionMode.current
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, updatedAt: 1 } }
: {},
current: sessionMode.current ? sessionId : undefined,
phase: 'ready',
intent: sessionMode.current
? undefined
: { sessionId: 'intent' as SessionId, target: { kind: 'workspace', workspaceId }, prompt: '', phase: 'connecting' },
} as SessionListState
const useSessions = ((sel: (s: SessionListState) => unknown) => sel(sessionState)) as never
const workspaceState: WorkspaceListState = {
items: [], intent: undefined, state: 'idle', phase: 'ready', error: null,
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
}
const utils = render(
<AppFrame
useStore={hookOf(instance) as never}
actions={instance.actions}
renderSlot={renderSlot}
useSessions={useSessions}
useWorkspaces={((sel: (s: WorkspaceListState) => unknown) => sel(workspaceState)) as never}
SessionProvider={SessionProviderStub}
/>,
)
@@ -91,6 +113,7 @@ function drag(handle: Element, fromX: number, toX: number): void {
beforeEach(() => {
frameWidth = 1920
sessionMode.current = true
baselinesReady.current = true
localStorage.clear() // the layout store persists; instances must not bleed across tests
vi.useFakeTimers()
vi.stubGlobal('ResizeObserver', ResizeObserverStub)
@@ -131,13 +154,22 @@ describe('AppFrame', () => {
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
})
it('renders the empty branch through conversation.empty when no session is current', () => {
it('keeps a connecting page-local Session intent in conversation.empty', () => {
sessionMode.current = false
const { slotCalls, getByTestId, queryByTestId } = mountFrame()
expect(getByTestId('empty-content')).toBeTruthy()
expect(queryByTestId('center-content')).toBeNull()
expect(slotCalls.map((c) => c.key)).toContain('conversation.empty')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
expect(slotCalls.find((c) => c.key === 'conversation.empty')!.props).toEqual({})
})
it('keeps the loading branch until both object-layer baselines are ready', () => {
baselinesReady.current = false
const { slotCalls, getByRole } = mountFrame()
expect(getByRole('status').textContent).toContain('Loading workspaces and sessions')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation')
expect(slotCalls.map((c) => c.key)).not.toContain('conversation.empty')
})
it('sidebar slot receives live concession output as owner props', () => {

View File

@@ -22,12 +22,12 @@ async function bench() {
describe('ui-layout client apply', () => {
it('declares its service dependencies', () => {
expect(inject).toContain('slots')
expect(inject).toEqual(['slots'])
})
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
// The one register() call occupied 'root'…
@@ -39,9 +39,23 @@ describe('ui-layout client apply', () => {
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
})
it('injects no business face and attaches the layout actions', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const actions = {
setSidebar: vi.fn(), setDetails: vi.fn(), toggleSidebar: vi.fn(), openDetails: vi.fn(), closeDetails: vi.fn(),
}
const injected = (slots.entries('root')[0]!.inject as (actions: never) => object)(actions as never)
expect(injected).toEqual({})
const layout = ctx.get('layout') as LayoutService
layout.toggleSidebar()
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
})
it('teardown unwinds the service, the root registration, and the child declarations', async () => {
const { ctx, slots } = await bench()
const fiber = ctx.plugin({ inject: ['slots'], apply })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(ctx.get('layout')).toBeUndefined()