refactor(gui): slot system standard — single register, four props shares, framework store seat

The definitive slot model for the web client, replacing the first-generation
define/register two-step, ScopedSlots whitelist faces, and binding handles:

- 'root' is the only a-priori slot (SlotsService built-in); the shell renders
  exactly ctx.slots.renderSlot('root', {}).
- register is the single API: children = slot declaration + render
  authorization + runtime spec in one options object; misconfiguration fails
  loud at load (duplicate declaration, undeclared contribution, one store
  handle under two scopes).
- Component props arrive in four auto-derived shares: PropsRuntime<K>
  (owner params + session/global standard kits via declare-merge),
  PropsRenderSlots<S>, PropsStore<H>, and the inject business face.
  sessionId is framework-supplied; hooks are framework-made only.
- Framework store seat: defineStore factories declare schema/actions/persist;
  read = useStore, write = baked actions only; store scope derives from the
  mounting entry; per-session persist keys and clearPersisted lifecycle.
- inject factories read the apply closure's own ctx (binding handles retired;
  root-ctx back door closed); SessionProvider is self-wired render-prop.
- Rendering sits behind the SlotRenderer install seam; runtime stays
  React-free; ownership ledger keyed to the single entry axis closes the
  stale-authority window (StaleAuthorizationError probes).

Docs: the slot type-chain note is refreshed in place as the slot system
standard RFC (bilingual pair re-recorded); the web client architecture RFC
defers its slot sections there; packages/client/AGENTS.md gains the slot and
props discipline; gui-testing/web-styling notes drop missions/ references.

Tests: suites rewritten to the standard (props fed directly, real store
engines via createXXXStore().create(), no render machinery); load-time
negative samples for declaration/authorization/store conflicts; verified by
real-host playwright run (three columns, empty state, collapse, keyed session
remount, cross-slot selection sharing).

docs(ui-sidebar): point contract reference at the committed slot standard RFC

missions/ is workspace-local and never committed; the README must not cite it.
This commit is contained in:
imccyu
2026-07-23 01:40:30 +08:00
parent efa4326ff4
commit 1b0ea07bce
95 changed files with 5024 additions and 3322 deletions

View File

@@ -1,11 +1,12 @@
/**
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, search,
* WorkSpace section header with the group-by menu, session tree list,
* Settings foot. Pure presentational — data and actions arrive through the
* inject surface; the tree store is subscribed via useTree, never derived in
* render.
* Settings foot. Pure presentational — the session list arrives through the
* standard useSessions hook, viewing state (expansion, search) is local
* component state, and rows are derived in render via useMemo (slot design
* section 6: derived data is a pure function, no materializing store).
*/
import { Fragment, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import clsx from 'clsx'
import {
FishLogo,
@@ -14,6 +15,7 @@ import {
Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveRows } from './tree.ts'
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
@@ -24,16 +26,28 @@ const GROUP_BY_ITEMS = [
{ id: 'status', label: 'Status', disabled: true },
]
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/**
* Render the sidebar column.
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootComponentProps) {
const rows = useTree((s) => s.rows)
const query = useTree((s) => s.query)
const groupBy = useTree((s) => s.groupBy)
const current = useCurrent()
export function SidebarRoot({ useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
const list = useSessions((s) => s)
// Wave-2 seam: row highlight expects `current` on the sessions list
// snapshot (sessions.current lives with the runtime sessions service).
const current = useSessions((s) => s.current)
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
const [query, setQuery] = useState('')
const rows = useMemo(
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
[list, expandedProjects, expandedSessions, query],
)
const [menuOpen, setMenuOpen] = useState(false)
const now = Date.now()
@@ -60,13 +74,13 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="button"
className={css.iconButton}
aria-label="Collapse sidebar"
onClick={() => { actions.toggleSidebar() }}
onClick={() => { onToggleSidebar() }}
>
<IconPanelLeftOutline16 />
</button>
</div>
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
<IconNewChatOutline16 size={14} />
New Session
</button>
@@ -79,7 +93,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
selectedId="workspace"
onSelect={() => { setMenuOpen(false) }}
align="end"
anchor={(
@@ -97,7 +111,7 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="button"
className={css.iconButton}
aria-label="New workspace"
onClick={() => { actions.create() }}
onClick={() => { onCreate() }}
>
<IconProjectAddOutline16 />
</button>
@@ -110,14 +124,14 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { tree.setQuery(e.target.value) }}
onChange={(e) => { setQuery(e.target.value) }}
/>
{query !== '' && (
<button
type="button"
className={css.clearButton}
aria-label="Clear search"
onClick={() => { tree.setQuery('') }}
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
@@ -136,8 +150,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
<ProjectRowItem
row={row}
active={row.key === activeGroup}
onToggle={() => { tree.toggleProject(row.key) }}
onCreate={() => { actions.create(row.cwd) }}
onToggle={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
onCreate={() => { onCreate(row.cwd) }}
/>
</Fragment>
)
@@ -147,8 +161,8 @@ export function SidebarRoot({ useTree, useCurrent, actions, tree }: SidebarRootC
row={row}
selected={row.id === current}
now={now}
onOpen={() => { actions.open(row.id) }}
onToggle={() => { tree.toggleSession(row.id) }}
onOpen={() => { onOpen(row.id) }}
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
/>
))}
</div>

View File

@@ -1,49 +1,42 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot. The own injected share is declared here (a
* share's type lives with whoever wires it); the owner share is referenced
* off ui-layout's slot declaration through OwnerOf, never re-stated. Single
* domain — this is the package's whole contract surface.
* share's type lives with whoever wires it); the runtime share — owner
* props {collapsed,width} plus the standard useSessions hook — is
* PropsRuntime<'sidebar'>, resolved off ui-layout's SlotMap declaration and
* never re-stated. Single domain — this is the package's whole contract
* surface.
*/
import type { OwnerOf } from '@deepseek-ai/dsh-client-ui-slots'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so OwnerOf<'sidebar'> resolves.
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarTreeState } from '../store.ts'
/** Cross-plugin actions bound in apply (layout / sessions services). */
export interface SidebarActions {
open(id: SessionId): void
create(cwd?: string): void
toggleSidebar(): void
}
/** Plugin-owned tree viewing-state actions (tree store mutators). */
export interface SidebarTreeActions {
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). A type alias, not an interface: the alias carries an implicit
* index signature, so the factory's return crosses the registry's
* `Record<string, unknown>` boundary uncast.
* factory): plain cross-service callbacks only — tree data rides the
* standard useSessions hook and viewing state is component-local. A type
* alias, not an interface: the alias carries an implicit index signature,
* so the factory's return crosses the registry's `Record<string, unknown>`
* boundary uncast.
*/
export type SidebarRootInjected = {
useTree: SnapshotSelectorHook<SidebarTreeState>
/** Current session selector (row highlight); undefined selects nothing. */
useCurrent: () => SessionId | undefined
actions: SidebarActions
tree: SidebarTreeActions
/** Open (switch to) a session. */
onOpen: (id: SessionId) => void
/**
* Create a session and open it; cwd targets a project group (the
* sidebar's three creation entries all land in the new session).
*/
onCreate: (cwd?: string) => void
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
onToggleSidebar: () => void
}
/**
* Full component props: owner share referenced from ui-layout's declaration
* plus the own injected share. Root scope has no standard injection
* (useSession is session-scope only), so no standard term appears.
* Full component props: the framework runtime share (owner {collapsed,width}
* + standard useSessions) plus the own injected share. No children are
* declared and no store is registered, so no PropsRenderSlots/PropsStore
* term appears.
*/
export type SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected

View File

@@ -1,61 +1,41 @@
/**
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
* sidebar slot; tree derivation materialized in a plugin-owned snapshot
* store (pure consumer — no ctx service). Contract: api-contracts v3
* section 6; props composition in contract/slots.ts.
* Export discipline: packages/client/AGENTS.md.
* sidebar slot. Pure consumer — the session list arrives through the
* standard useSessions prop, tree rows derive in the component, and the
* inject surface is plain cross-service callbacks closed over the plugin's
* own ctx (slot design sections 5 and 6); props composition in
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
*/
import type { RootBinding } from '@deepseek-ai/dsh-client-ui-slots'
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { createSidebarTreeStore } from './store.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type {
SidebarActions, SidebarRootComponentProps, SidebarRootInjected, SidebarTreeActions,
} from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
export const inject = ['slots', 'layout', 'sessions']
/**
* Client plugin body: build the tree store and register SidebarRoot into the
* sidebar slot with the inject surface bound off the root binding's ctx.
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
* factory returns service callbacks only (no hooks, no store lines) — all
* data reads ride the framework's standard useSessions delivery.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.sessions
ctx.effect(() => {
const tree = createSidebarTreeStore(sessions)
// Called once per registration (root slots cache per entry); services are
// bound off the binding ctx per the contract's inject-surface wording.
const injectProps = (b: RootBinding<ClientContext>): SidebarRootInjected => {
const { sessions: boundSessions, layout } = b.ctx
return {
useTree: tree.store.useSelector,
useCurrent: () => layout.current.useSelector(s => s.sessionId),
actions: {
open: (id) => { layout.open(id) },
create: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void boundSessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { layout.open(id) })
},
toggleSidebar: () => { layout.toggleSidebar() },
},
tree: {
toggleProject: (key) => { tree.toggleProject(key) },
toggleSession: (id) => { tree.toggleSession(id) },
setQuery: (query) => { tree.setQuery(query) },
},
}
}
const disposeRegistration = ctx.slots.register('sidebar', SidebarRoot, { inject: injectProps })
return () => {
disposeRegistration()
tree.dispose()
}
}, 'ui-sidebar: tree store + slot registration')
const injectProps = (): SidebarRootInjected => ({
// Selection lives with the runtime sessions service (current rides the
// list snapshot); layout keeps only panel geometry.
onOpen: (id) => { ctx.sessions.open(id) },
onCreate: (cwd) => {
// Create-then-open: the sidebar's three creation entries all land
// in the new session (empty-state first-send stays with ui-conversation).
void ctx.sessions.create(cwd === undefined ? {} : { cwd })
.then((id: SessionId) => { ctx.sessions.open(id) })
},
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
'ui-sidebar: slot registration',
)
}

View File

@@ -1,94 +0,0 @@
/**
* Sidebar tree store: plugin-owned snapshot store materializing the derived
* row list. Subscribes to sessions.list and re-derives on list changes and
* on viewing-state actions (expansion, search, group-by) — components
* subscribe to `rows` and never derive in render. Contract: api-contracts
* v3 section 6.
*/
import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-web-react'
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import { deriveRows, type SidebarRow } from './tree.ts'
/** Grouping strategy. Only by-workspace is designed (figma); the menu shows the rest disabled. */
export type GroupBy = 'workspace'
/** Sidebar tree state: materialized rows plus the viewing state that shaped them. */
export interface SidebarTreeState {
rows: SidebarRow[]
/** Expanded project group keys (cwd or the ungrouped key). */
expandedProjects: string[]
/** Expanded session ids (subtree unfold). */
expandedSessions: string[]
query: string
groupBy: GroupBy
}
/** Store handle: snapshot store plus mutation actions and the list unsubscribe. */
export interface SidebarTreeStore {
readonly store: SnapshotStore<SidebarTreeState>
toggleProject(key: string): void
toggleSession(id: SessionId): void
setQuery(query: string): void
setGroupBy(groupBy: GroupBy): void
dispose(): void
}
/**
* Create the sidebar tree store bound to a sessions service.
* @param sessions - root sessions service (only the list store is consumed).
* @returns store handle; call dispose on plugin teardown.
*/
export function createSidebarTreeStore(sessions: Pick<SessionsService, 'list'>): SidebarTreeStore {
const store = createSnapshotStore<SidebarTreeState>({
rows: [],
expandedProjects: [],
expandedSessions: [],
query: '',
groupBy: 'workspace',
})
const rederive = (draft: SidebarTreeState): void => {
draft.rows = deriveRows(sessions.list.getSnapshot(), {
expandedProjects: new Set(draft.expandedProjects),
expandedSessions: new Set(draft.expandedSessions),
query: draft.query,
})
}
store.update(rederive)
const unsubscribe = sessions.list.subscribe(() => { store.update(rederive) })
const toggle = (list: string[], key: string): void => {
const at = list.indexOf(key)
if (at >= 0) list.splice(at, 1)
else list.push(key)
}
return {
store,
toggleProject(key) {
store.update((draft) => {
toggle(draft.expandedProjects, key)
rederive(draft)
})
},
toggleSession(id) {
store.update((draft) => {
toggle(draft.expandedSessions, id)
rederive(draft)
})
},
setQuery(query) {
store.update((draft) => {
draft.query = query
rederive(draft)
})
},
setGroupBy(groupBy) {
store.update((draft) => {
draft.groupBy = groupBy
rederive(draft)
})
},
dispose: unsubscribe,
}
}

View File

@@ -2,8 +2,9 @@
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
* Groups sessions by project directory (cwd), builds the per-group session
* tree from parentId links, sorts by recency, and applies search filtering
* with forced ancestor visibility. Components subscribe to the materialized
* rows and never derive in render. Contract: api-contracts v3 section 6.
* with forced ancestor visibility. Derived data is a pure function (slot
* design section 6): the component feeds the useSessions snapshot plus its
* local viewing state through useMemo — no materializing store.
*/
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
@@ -43,10 +44,10 @@ export interface SessionRow {
/** One flat sidebar list row. */
export type SidebarRow = ProjectRow | SessionRow
/** Viewing state consumed by the derivation. */
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
export interface TreeView {
expandedProjects: ReadonlySet<string>
expandedSessions: ReadonlySet<string>
expandedProjects: readonly string[]
expandedSessions: readonly string[]
query: string
}
@@ -217,17 +218,19 @@ function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarR
* without a title or label hit are dropped, and a label-only hit keeps the
* bare project row.
* @param list - sessions list snapshot.
* @param view - expansion sets and search query.
* @param view - local expansion arrays and search query.
* @returns rows in render order.
*/
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
const q = view.query.trim().toLowerCase()
const expandedProjects = new Set(view.expandedProjects)
const expandedSessions = new Set(view.expandedSessions)
const rows: SidebarRow[] = []
for (const g of groupByCwd(list)) {
if (q === '') {
const expanded = view.expandedProjects.has(g.key)
const expanded = expandedProjects.has(g.key)
rows.push({ type: 'project', key: g.key, cwd: g.cwd, label: g.label, sessionCount: g.summaries.size, expanded })
if (expanded) flattenVisible(g, view.expandedSessions, rows)
if (expanded) flattenVisible(g, expandedSessions, rows)
} else {
const visible = searchVisible(g, q)
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue

View File

@@ -15,10 +15,10 @@ export const name = 'client-ui-sidebar-invariant'
export const inject = ['invariants']
/**
* No runtime invariant: a pure-consumer plugin deriving its tree store from
* sessions.list — it emits no cordis events and owns no cross-plugin mutable
* state; derivation and interaction behavior are asserted directly by this
* package's tree/store/component specs.
* No runtime invariant: a pure-consumer plugin deriving its rows in-component
* from the standard useSessions delivery — it emits no cordis events and owns
* no cross-plugin mutable state; derivation and interaction behavior are
* asserted directly by this package's tree/component specs.
*/
const install: InvariantInstaller = () => {}