feat: slash system / input service / agent scope
This commit is contained in:
@@ -4,10 +4,9 @@
|
||||
* details), the drag handles (pointer capture + rAF throttle), the concession
|
||||
* chain (columns.ts), and the child-slot render decisions: the sidebar slot
|
||||
* 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 data arrives through framework-standard props
|
||||
* and each registrant's inject face). Pure component: everything arrives
|
||||
* session-aware occupants render in fixed column positions; strict entries
|
||||
* gate themselves on current-session availability while session-maybe
|
||||
* entries retain identity. Pure component: everything arrives
|
||||
* through the three framework shares — zero cordis or framework imports,
|
||||
* zero self-made hooks.
|
||||
*/
|
||||
@@ -21,7 +20,7 @@ import css from './AppFrame.module.css'
|
||||
/** Full composed props: runtime share + child-slot render share + store share. */
|
||||
export type AppFrameProps =
|
||||
& PropsRuntime<'root'>
|
||||
& PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'conversation.empty'>
|
||||
& PropsRenderSlots<'sidebar' | 'conversation' | 'details'>
|
||||
& PropsStore<ReturnType<typeof createLayoutStore>>
|
||||
|
||||
/** Center column grid item (session-body building block). */
|
||||
@@ -81,18 +80,13 @@ 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). */
|
||||
/** The three-column frame (see module doc). */
|
||||
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)
|
||||
|
||||
@@ -157,42 +151,13 @@ export function AppFrame({
|
||||
width: cols.sidebar,
|
||||
})}
|
||||
</div>
|
||||
{!baselinesReady
|
||||
? (
|
||||
<>
|
||||
<CenterColumn>
|
||||
<div role="status">Loading workspaces and sessions…</div>
|
||||
</CenterColumn>
|
||||
<DetailsColumn />
|
||||
</>
|
||||
)
|
||||
: 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>
|
||||
)}
|
||||
<>
|
||||
{/* Both column occupants stay at fixed tree positions. The
|
||||
conversation is session-maybe; the strict details entry
|
||||
naturally renders empty while no session is current. */}
|
||||
<CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
|
||||
<DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
|
||||
</>
|
||||
{/* 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} />}
|
||||
|
||||
@@ -35,9 +35,10 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
// 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 }
|
||||
// Current-session-optional: the occupant owns both the no-session hero
|
||||
// and live conversation states without changing its React identity.
|
||||
'conversation': { kind: 'single'; scope: 'session-maybe'; owner: ConvOwnerProps }
|
||||
'details': { kind: 'single'; scope: 'session'; owner: DetailsOwnerProps }
|
||||
'conversation.empty': { kind: 'single'; scope: 'root'; owner: EmptyOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,9 +62,6 @@ export interface ConvOwnerProps {}
|
||||
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
|
||||
export interface DetailsOwnerProps {}
|
||||
|
||||
/** 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). */
|
||||
export const inject = ['slots', 'theme']
|
||||
|
||||
@@ -81,9 +79,8 @@ export function apply(ctx: ClientContext): void {
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar': { kind: 'single', scope: 'root' },
|
||||
'conversation': { kind: 'single', scope: 'session' },
|
||||
'conversation': { kind: 'single', scope: 'session-maybe' },
|
||||
'details': { kind: 'single', scope: 'session' },
|
||||
'conversation.empty': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
// Exclusive store: the factory itself — the framework instantiates per
|
||||
// entry and delivers useStore/actions to AppFrame as standard props.
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { AppFrameProps } from '@deepseek-ai/dsh-client-ui-layout/src/client
|
||||
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,
|
||||
SessionId, SessionListState, WorkspaceListState,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
// Session-mode switch for the SessionProvider stub prop.
|
||||
@@ -61,24 +61,21 @@ function mountFrame() {
|
||||
if (key === 'sidebar') return <div data-testid="sidebar-content" />
|
||||
if (key === 'conversation') return <div data-testid="center-content" />
|
||||
if (key === 'details') return <div data-testid="details-content" />
|
||||
return <div data-testid="empty-content" />
|
||||
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 workspaceId = 'w-test' as WorkspaceId
|
||||
const sessionState = {
|
||||
ids: sessionMode.current ? [sessionId] : [],
|
||||
byId: sessionMode.current
|
||||
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, updatedAt: 1 } }
|
||||
? { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, blank: 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,
|
||||
items: [], state: 'idle', phase: 'ready', error: null,
|
||||
baselinesReady: baselinesReady.current, recentWorkspaceId: undefined,
|
||||
}
|
||||
const utils = render(
|
||||
@@ -154,14 +151,15 @@ describe('AppFrame', () => {
|
||||
expect(slotCalls.find((c) => c.key === 'details')!.props).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a connecting page-local Session intent in conversation.empty', () => {
|
||||
it('renders the New Session view state through the empty seat while no session is current', () => {
|
||||
// No current session = the pure view state: the conversation.empty slot
|
||||
// renders in the center column; no session slot dispatches.
|
||||
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', () => {
|
||||
@@ -169,7 +167,6 @@ describe('AppFrame', () => {
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
// Client apply wiring under the terminal register form: ctx.layout provided,
|
||||
// ONE register() call declares the four child slots + seats the store factory
|
||||
// ONE register() call declares the three child slots + seats the store factory
|
||||
// + wires the panel actions through the inject hook; teardown cascades
|
||||
// (service unprovided + declarations gone + registration cleared). Node half
|
||||
// and the invariant companion ride along — one-line surfaces the aggregate
|
||||
@@ -31,18 +31,17 @@ describe('ui-layout client apply', () => {
|
||||
expect(inject).toEqual(['slots', 'theme'])
|
||||
})
|
||||
|
||||
it('provides ctx.layout and registers AppFrame into root with the four child declarations', async () => {
|
||||
it('provides ctx.layout and registers AppFrame into root with the three child declarations', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(ctx.get('layout')).toBeInstanceOf(LayoutService)
|
||||
// The one register() call occupied 'root'…
|
||||
expect(slots.entries('root')).toHaveLength(1)
|
||||
// …and declared the four children in the ledger.
|
||||
// …and declared the three children in the ledger.
|
||||
expect(slots.spec('sidebar')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(slots.spec('conversation')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('details')).toEqual({ kind: 'single', scope: 'session' })
|
||||
expect(slots.spec('conversation.empty')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
it('injects no business face and attaches the layout actions', async () => {
|
||||
@@ -84,7 +83,6 @@ describe('ui-layout client apply', () => {
|
||||
expect(ctx.get('layout')).toBeUndefined()
|
||||
expect(slots.entries('root')).toHaveLength(0)
|
||||
expect(slots.spec('sidebar')).toBeUndefined()
|
||||
expect(slots.spec('conversation.empty')).toBeUndefined()
|
||||
// The built-in root declaration survives entry teardown (runtime-owned).
|
||||
expect(slots.spec('root')).toEqual({ kind: 'single', scope: 'root' })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user