Merge remote-tracking branch 'origin/worktree-webslot2' into worktree/fix-collapsed-sidebar-rail
# Conflicts: # apps/web/tests/smoke-fixture.e2e.ts # packages/client/ui-layout/src/client/AppFrame.tsx # packages/client/ui-layout/src/client/columns.ts # packages/client/ui-layout/src/client/index.ts # packages/client/ui-layout/tests/app-frame.spec.tsx # packages/client/ui-layout/tests/columns.spec.ts # packages/client/ui-sidebar/README.md # packages/client/ui-sidebar/src/client/SidebarRoot.tsx # packages/client/ui-sidebar/src/client/contract/slots.ts # packages/client/ui-sidebar/src/client/index.ts # packages/client/ui-sidebar/tests/apply.spec.tsx # packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
# @deepseek-ai/dsh-client-ui-sidebar
|
||||
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Its collapsed render keeps the expand control and settings entry in the layout-owned compact rail. Contract: api-contracts v3 §6.
|
||||
Sidebar plugin: session multi-level tree (cwd grouping + parentId nesting), search, by-workspace grouping, state dots, three creation entries. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session/sidebar-open hooks, actions) and `SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected` (the owner share referenced from ui-layout's slot declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory binds layout/sessions off `RootBinding<ClientContext>`.
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — plain service callbacks: onOpen/onCreate/onToggleSidebar) and `SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected` (owner `{collapsed,width}` plus the standard `useSessions` hook, resolved off ui-layout's SlotMap declaration, never re-stated). `apply` registers SidebarRoot cast-free against that composition; the inject factory closes over the plugin's own ctx.
|
||||
|
||||
There is no plugin store: rows derive in the component (`useMemo` over the `useSessions` snapshot + local expansion/search state) through the pure `deriveRows` in `tree.ts`.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only — SidebarRoot, the row components, and the tree derivation are internal (the slot registration closes over them; tests import src paths directly).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-web-react": "workspace:^",
|
||||
"clsx": "^2.0.0",
|
||||
"react": "^18.2.0"
|
||||
},
|
||||
|
||||
@@ -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,14 +26,28 @@ const GROUP_BY_ITEMS = [
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
|
||||
type SidebarBodyProps = Pick<SidebarRootComponentProps, 'useTree' | 'useCurrent' | 'actions' | 'tree'>
|
||||
/** 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]
|
||||
}
|
||||
|
||||
/** Expanded-only content; unmounting drops tree/current subscriptions while the rail is collapsed. */
|
||||
function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
const rows = useTree((s) => s.rows)
|
||||
const query = useTree((s) => s.query)
|
||||
const groupBy = useTree((s) => s.groupBy)
|
||||
const current = useCurrent()
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
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()
|
||||
|
||||
@@ -45,14 +61,39 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.listArea}>
|
||||
<div className={css.root}>
|
||||
<div className={css.headerBlock}>
|
||||
<div className={css.logoRow}>
|
||||
<span className={css.brand}>
|
||||
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
|
||||
<FishLogo size={23} />
|
||||
<span className={css.wordmark}>deepseek</span>
|
||||
<span className={css.badge}>HARNESS</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="Collapse sidebar"
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className={css.newSession} onClick={() => { onCreate() }}>
|
||||
<IconNewChatOutline16 size={14} />
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={css.listArea}>
|
||||
<div className={css.sectionHeader}>
|
||||
<span className={css.sectionLabel}>WorkSpace</span>
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId={groupBy}
|
||||
selectedId="workspace"
|
||||
onSelect={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
@@ -70,7 +111,7 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { actions.create() }}
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 />
|
||||
</button>
|
||||
@@ -83,14 +124,14 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
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>
|
||||
@@ -109,8 +150,8 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
<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>
|
||||
)
|
||||
@@ -120,72 +161,17 @@ function SidebarBody({ useTree, useCurrent, actions, tree }: SidebarBodyProps) {
|
||||
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>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* @param props - composed slot props (owner share + injected surface, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot(props: SidebarRootComponentProps) {
|
||||
const open = props.useSidebarOpen()
|
||||
|
||||
return (
|
||||
<div className={clsx(css.root, !open && css.collapsed)}>
|
||||
<div className={css.headerBlock}>
|
||||
<div className={css.logoRow}>
|
||||
{open
|
||||
? (
|
||||
<span className={css.brand}>
|
||||
{/* Wordmark svg not extracted yet (figma 88:8932) — text stands in at the same ink. */}
|
||||
<FishLogo size={23} />
|
||||
<span className={css.wordmark}>deepseek</span>
|
||||
<span className={css.badge}>HARNESS</span>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={open ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
onClick={() => { props.actions.toggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{open
|
||||
? (
|
||||
<button type="button" className={css.newSession} onClick={() => { props.actions.create() }}>
|
||||
<IconNewChatOutline16 size={14} />
|
||||
New Session
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
|
||||
{open
|
||||
? (
|
||||
<SidebarBody
|
||||
useTree={props.useTree}
|
||||
useCurrent={props.useCurrent}
|
||||
actions={props.actions}
|
||||
tree={props.tree}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 />
|
||||
{open ? <span>Settings</span> : null}
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,51 +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
|
||||
/** Sidebar open selector; the collapsed render keeps only persistent rail controls. */
|
||||
useSidebarOpen: () => boolean
|
||||
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
|
||||
|
||||
@@ -1,72 +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.
|
||||
* 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 {
|
||||
deriveRows, formatRelativeTime, projectLabel,
|
||||
UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
type ProjectRow, type SessionRow, type SidebarRow, type TreeView,
|
||||
} from './tree.ts'
|
||||
export {
|
||||
createSidebarTreeStore,
|
||||
type GroupBy, type SidebarTreeState, type SidebarTreeStore,
|
||||
} from './store.ts'
|
||||
export { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
||||
export { 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),
|
||||
useSidebarOpen: () => layout.sidebar.useSelector(s => s.open),
|
||||
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',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () => {}
|
||||
|
||||
|
||||
@@ -1,57 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* apply wiring on a real cordis Context + SlotsService: tree store built and
|
||||
* subscribed, SidebarRoot registered into the layout-owned sidebar slot with
|
||||
* the inject surface bound off the root binding ctx, effect teardown
|
||||
* unregisters and drops the list subscription. Behavior-level assertions
|
||||
* only — the inject factory's cast shape is due to change with the slot
|
||||
* type-chain redesign.
|
||||
* apply wiring on a real cordis Context + SlotsService (terminal register
|
||||
* form): SidebarRoot registered into the layout-declared sidebar slot, the
|
||||
* thin inject surface (three plain service callbacks closed over the plugin
|
||||
* ctx — no hooks, no store lines), load-order fail-loud, and fiber-teardown
|
||||
* unregistration. Component behavior is covered props-direct in
|
||||
* sidebar-root.spec.tsx; no renderer machinery here.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { scopedSlots, RootBindingProvider } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import type { SidebarRootInjected } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
// Type-only: ui-layout's SlotMap merge so the sidebar slot key typechecks.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid('a')],
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', cwd: '/proj', running: false, updatedAt: 1 } },
|
||||
current: undefined,
|
||||
})
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')) }
|
||||
const sidebar = createSnapshotStore({ open: true, width: 300 })
|
||||
const layout = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
|
||||
sidebar,
|
||||
open: vi.fn(),
|
||||
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
|
||||
}
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')), open: vi.fn() }
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
ctx.provide('sessions', sessions)
|
||||
ctx.provide('layout', layout)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
slots.define('sidebar', { kind: 'single', scope: 'root' })
|
||||
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
|
||||
// a live entry declares it in children (declaration account: design §2.2).
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
return { ctx, slots, sessions, layout }
|
||||
}
|
||||
|
||||
function mountSlot(ctx: Context, slots: SlotsService) {
|
||||
const surface = scopedSlots(slots.core, 'sidebar')
|
||||
return render(
|
||||
<RootBindingProvider value={{ ctx }}>
|
||||
{surface.renderSlot('sidebar', {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
/** The sidebar entry's injected share, read off the stored entry. */
|
||||
function injectedOf(slots: SlotsService): SidebarRootInjected {
|
||||
const entries = slots.entries('sidebar')
|
||||
expect(entries).toHaveLength(1)
|
||||
// The typed StoredEntry.inject is declaration-derived ((...args: never[])
|
||||
// shape); the sidebar factory is parameterless, so the call is safe here.
|
||||
const inject = entries[0]!.inject as (() => SidebarRootInjected) | undefined
|
||||
return inject!()
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
@@ -60,105 +56,57 @@ describe('apply', () => {
|
||||
})
|
||||
|
||||
it('fails loud when mounted without the inject declaration', async () => {
|
||||
// ctx.sessions rides the cordis property proxy: reading it from a plugin
|
||||
// ctx.slots rides the cordis property proxy: reading it from a plugin
|
||||
// that never declared the dependency throws instead of yielding undefined.
|
||||
// Await the fiber thenable itself, not a second .await() chain: the test
|
||||
// invariant host wraps plugin() with an eager readiness promise, and only
|
||||
// the thenable settles it (a parallel .await() leaves it unhandled).
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
|
||||
})
|
||||
|
||||
it('registers SidebarRoot which renders from the live list', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
it('fails loud when no live entry has declared the sidebar slot', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
ctx.provide('sessions', {})
|
||||
ctx.provide('layout', {})
|
||||
await expect(ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/slot "sidebar" is not declared/)
|
||||
})
|
||||
|
||||
it('binds actions to layout/sessions off the root binding', async () => {
|
||||
it('registers SidebarRoot with the thin three-callback inject surface', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const injected = injectedOf(slots)
|
||||
// The whole business face: three plain callbacks, no hooks, no store lines.
|
||||
expect(Object.keys(injected).sort()).toEqual(['onCreate', 'onOpen', 'onToggleSidebar'])
|
||||
})
|
||||
|
||||
it('routes the callbacks to the layout/sessions services', async () => {
|
||||
const { ctx, slots, sessions, layout } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
const injected = injectedOf(slots)
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
injected.onToggleSidebar()
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('alpha')) })
|
||||
expect(layout.open).toHaveBeenCalledWith('a')
|
||||
injected.onOpen(sid('a'))
|
||||
expect(sessions.open).toHaveBeenCalledWith('a')
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
injected.onCreate()
|
||||
expect(sessions.create).toHaveBeenCalledWith({})
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(layout.open).toHaveBeenCalledWith('minted')
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sessions.open).toHaveBeenCalledWith('minted')
|
||||
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
})
|
||||
|
||||
it('throws from the inject factory when binding ctx lacks the services', async () => {
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
const bare = new Context()
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
const surface = scopedSlots(slots.core, 'sidebar')
|
||||
render(
|
||||
<RootBindingProvider value={{ ctx: bare }}>
|
||||
{surface.renderSlot('sidebar', {})}
|
||||
</RootBindingProvider>,
|
||||
)
|
||||
// The slot error boundary absorbs the throw and logs it.
|
||||
expect(document.querySelector('[data-slot-error="sidebar"]')).toBeTruthy()
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('search input drives the plugin-owned tree store', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
act(() => {
|
||||
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'zzz' } })
|
||||
})
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('expansion toggles route through the injected tree actions', async () => {
|
||||
const { ctx, slots, sessions } = await bench()
|
||||
sessions.list.update((draft) => {
|
||||
draft.ids.push(sid('kid'))
|
||||
draft.byId[sid('kid')] = {
|
||||
id: sid('kid'), title: 'child', cwd: '/proj', parentId: sid('a'), running: false, updatedAt: 2,
|
||||
}
|
||||
})
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
expect(screen.getByText('alpha')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
|
||||
expect(screen.getByText('child')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot and drops the list subscription', async () => {
|
||||
const { ctx, slots, sessions } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('sidebar')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('sidebar')).toHaveLength(0)
|
||||
// A post-teardown list change must not reach a disposed store.
|
||||
expect(() => {
|
||||
sessions.list.update((draft) => { draft.ids = [] })
|
||||
}).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* SidebarRoot interaction spec on the real framework stack: real tree store
|
||||
* (web-react SnapshotStore) feeding the component through the same selector
|
||||
* hook the inject surface hands out. Covers expand/collapse, subtree unfold,
|
||||
* search filtering, row activation, and the creation entries.
|
||||
* SidebarRoot interaction spec, props-direct (slot-parity test doctrine:
|
||||
* components are fed composed props, no assembly machinery). The standard
|
||||
* useSessions hook is stubbed with a real web-react SnapshotStore selector;
|
||||
* expansion/search live inside the component, so all viewing behavior is
|
||||
* driven through the DOM. Covers expand/collapse, subtree unfold, search
|
||||
* filtering, row activation, and the creation entries.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { act, useSyncExternalStore } from 'react'
|
||||
// Engine home: runtime/client since the store migration; the engine carries
|
||||
// no hook (runtime is React-free), so the spec binds the selector locally.
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
createSidebarTreeStore, SidebarRoot,
|
||||
type SidebarActions, type SidebarTreeStore,
|
||||
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
/** Minimal selector hook over an engine store (production binding lives in the renderer). */
|
||||
function hookOf<T>(src: { getSnapshot(): T; subscribe(fn: () => void): () => void }) {
|
||||
return <S,>(sel: (s: T) => S, _eq?: (a: S, b: S) => boolean): S =>
|
||||
sel(useSyncExternalStore(src.subscribe.bind(src), src.getSnapshot.bind(src)))
|
||||
}
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
@@ -42,31 +49,29 @@ function summary(init: SummaryInit): SessionSummary {
|
||||
function listStateOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map((s) => s.id), byId }
|
||||
return { ids: summaries.map((s) => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount(...summaries: SessionSummary[]) {
|
||||
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const tree: SidebarTreeStore = createSidebarTreeStore({ list })
|
||||
const current = createSnapshotStore<{ id: SessionId | undefined }>({ id: undefined })
|
||||
const sidebar = createSnapshotStore({ open: true })
|
||||
const actions: SidebarActions = {
|
||||
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
|
||||
create: vi.fn(),
|
||||
toggleSidebar: vi.fn(() => { sidebar.update((d) => { d.open = !d.open }) }),
|
||||
}
|
||||
// Real engine store as the useSessions stub: same uSES selector shape the
|
||||
// framework delivers, so list updates re-render exactly like production.
|
||||
const sessions = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const onOpen = vi.fn((id: SessionId) => { sessions.update((d) => { d.current = id }) })
|
||||
const onCreate = vi.fn()
|
||||
const onToggleSidebar = vi.fn()
|
||||
const utils = render(
|
||||
<SidebarRoot
|
||||
useTree={tree.store.useSelector}
|
||||
useCurrent={() => current.useSelector((s) => s.id)}
|
||||
useSidebarOpen={() => sidebar.useSelector((s) => s.open)}
|
||||
actions={actions}
|
||||
tree={tree}
|
||||
collapsed={false}
|
||||
width={300}
|
||||
useSessions={hookOf(sessions)}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
/>,
|
||||
)
|
||||
return { list, tree, current, sidebar, actions, ...utils }
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
}
|
||||
|
||||
const projectData = () => [
|
||||
@@ -75,6 +80,9 @@ const projectData = () => [
|
||||
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
|
||||
]
|
||||
|
||||
/** Flush the store's microtask-batched notification into React. */
|
||||
const flush = async () => { await act(async () => { await Promise.resolve() }) }
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
@@ -97,11 +105,13 @@ describe('SidebarRoot', () => {
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a session on row click and marks it selected', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
it('opens a session on row click and marks it selected', async () => {
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('root work')) })
|
||||
expect(actions.open).toHaveBeenCalledWith('root')
|
||||
expect(onOpen).toHaveBeenCalledWith('root')
|
||||
// The mock routed the open into sessions.current — highlight follows.
|
||||
await flush()
|
||||
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
@@ -131,32 +141,20 @@ describe('SidebarRoot', () => {
|
||||
})
|
||||
|
||||
it('routes the three creation entries with the right cwd', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
const { onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith()
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith()
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith('/proj')
|
||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapsed rail keeps the expand and settings controls', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
it('collapse button and group-by menu behave', () => {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.getByLabelText('Expand sidebar')).toBeTruthy()
|
||||
expect(screen.getByLabelText('Settings')).toBeTruthy()
|
||||
expect(screen.queryByText('HARNESS')).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand sidebar')) })
|
||||
expect(actions.toggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('group-by menu behaves', () => {
|
||||
mount(...projectData())
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
expect(screen.getByText('Update')).toBeTruthy()
|
||||
@@ -171,28 +169,27 @@ describe('SidebarRoot', () => {
|
||||
})
|
||||
|
||||
it('re-renders when the sessions list gains a session', async () => {
|
||||
const { list } = mount(...projectData())
|
||||
const { sessions } = mount(...projectData())
|
||||
act(() => {
|
||||
list.update((draft) => {
|
||||
sessions.update((draft) => {
|
||||
draft.ids.push(sid('fresh'))
|
||||
draft.byId[sid('fresh')] = summary({ id: 'fresh', title: 'brand new', cwd: '/fresh', updatedAt: 99 })
|
||||
})
|
||||
})
|
||||
// Store notifications are microtask-batched.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
await flush()
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row "More" anchors swallow the click without opening or toggling', () => {
|
||||
const { actions, tree } = mount(...projectData())
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
const before = tree.store.getSnapshot().expandedProjects.length
|
||||
// Project-row anchor: must not collapse the project.
|
||||
// Project-row anchor: must not collapse the project (rows stay visible).
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
|
||||
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
// Session-row anchor: must not open the session.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
|
||||
expect(actions.open).not.toHaveBeenCalled()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the running state dot only for running sessions', () => {
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSidebarTreeStore } from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
title: init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
updatedAt: init.updatedAt ?? 0,
|
||||
}
|
||||
if (init.cwd !== undefined) s.cwd = init.cwd
|
||||
if (init.parentId !== undefined) s.parentId = sid(init.parentId)
|
||||
return s
|
||||
}
|
||||
|
||||
function listStateOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map(s => s.id), byId }
|
||||
}
|
||||
|
||||
function setup(...summaries: SessionSummary[]) {
|
||||
const list = createSnapshotStore<SessionListState>(listStateOf(...summaries))
|
||||
const tree = createSidebarTreeStore({ list })
|
||||
return { list, tree }
|
||||
}
|
||||
|
||||
const flushMicrotasks = () => new Promise<void>((resolve) => { queueMicrotask(resolve) })
|
||||
|
||||
describe('createSidebarTreeStore', () => {
|
||||
it('materializes rows from the initial list snapshot', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
expect(tree.store.getSnapshot().rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/p', sessionCount: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('re-derives when the sessions list changes', async () => {
|
||||
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
list.update((draft) => {
|
||||
draft.ids.push(sid('b'))
|
||||
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q', updatedAt: 99 })
|
||||
})
|
||||
// Snapshot-store notifications are microtask-batched.
|
||||
await flushMicrotasks()
|
||||
expect(tree.store.getSnapshot().rows.map(r => r.type === 'project' && r.key)).toEqual(['/q', '/p'])
|
||||
})
|
||||
|
||||
it('toggleProject expands and collapses synchronously', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(2)
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('toggleSession unfolds a subtree', () => {
|
||||
const { tree } = setup(
|
||||
summary({ id: 'root', cwd: '/p', updatedAt: 2 }),
|
||||
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 1 }),
|
||||
)
|
||||
tree.toggleProject('/p')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(2)
|
||||
tree.toggleSession(sid('root'))
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('setQuery switches into search mode and back', () => {
|
||||
const { tree } = setup(
|
||||
summary({ id: 'a', title: 'needle', cwd: '/p' }),
|
||||
summary({ id: 'b', title: 'other', cwd: '/q' }),
|
||||
)
|
||||
tree.setQuery('needle')
|
||||
const rows = tree.store.getSnapshot().rows
|
||||
expect(rows.map(r => r.type)).toEqual(['project', 'session'])
|
||||
tree.setQuery('')
|
||||
expect(tree.store.getSnapshot().rows.every(r => r.type === 'project')).toBe(true)
|
||||
})
|
||||
|
||||
it('setGroupBy records the strategy and re-derives', () => {
|
||||
const { tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.setGroupBy('workspace')
|
||||
expect(tree.store.getSnapshot().groupBy).toBe('workspace')
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('dispose stops re-derivation on list changes', async () => {
|
||||
const { list, tree } = setup(summary({ id: 'a', cwd: '/p' }))
|
||||
tree.dispose()
|
||||
list.update((draft) => {
|
||||
draft.ids.push(sid('b'))
|
||||
draft.byId[sid('b')] = summary({ id: 'b', cwd: '/q' })
|
||||
})
|
||||
await flushMicrotasks()
|
||||
expect(tree.store.getSnapshot().rows).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -3,7 +3,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
|
||||
import {
|
||||
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
type SessionRow, type TreeView,
|
||||
} from '@deepseek-ai/dsh-client-ui-sidebar/client'
|
||||
} from '../src/client/tree.ts'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
@@ -32,12 +32,12 @@ function summary(init: SummaryInit): SessionSummary {
|
||||
function listOf(...summaries: SessionSummary[]): SessionListState {
|
||||
const byId: Record<SessionId, SessionSummary> = {}
|
||||
for (const s of summaries) byId[s.id] = s
|
||||
return { ids: summaries.map(s => s.id), byId }
|
||||
return { ids: summaries.map(s => s.id), byId, current: undefined }
|
||||
}
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? new Set(),
|
||||
expandedSessions: partial.expandedSessions ?? new Set(),
|
||||
expandedProjects: partial.expandedProjects ?? [],
|
||||
expandedSessions: partial.expandedSessions ?? [],
|
||||
query: partial.query ?? '',
|
||||
})
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('deriveRows grouping', () => {
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 2 }),
|
||||
)
|
||||
expect(deriveRows(list, view()).filter(r => r.type === 'session')).toHaveLength(0)
|
||||
const rows = deriveRows(list, view({ expandedProjects: new Set(['/p']) }))
|
||||
const rows = deriveRows(list, view({ expandedProjects: ['/p'] }))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
|
||||
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
|
||||
@@ -112,8 +112,8 @@ describe('deriveRows session tree', () => {
|
||||
|
||||
it('nests children under expanded parents with increasing depth', () => {
|
||||
const rows = deriveRows(treeList, view({
|
||||
expandedProjects: new Set(['/p']),
|
||||
expandedSessions: new Set(['root', 'kid']),
|
||||
expandedProjects: ['/p'],
|
||||
expandedSessions: ['root', 'kid'],
|
||||
}))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
|
||||
@@ -124,7 +124,7 @@ describe('deriveRows session tree', () => {
|
||||
})
|
||||
|
||||
it('collapses subtrees at unexpanded sessions', () => {
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['other', 'root'])
|
||||
})
|
||||
@@ -133,7 +133,7 @@ describe('deriveRows session tree', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'p1', cwd: '/a', updatedAt: 2 }),
|
||||
summary({ id: 'stray', cwd: '/b', parentId: sid('p1'), updatedAt: 1 }),
|
||||
), view({ expandedProjects: new Set(['/a', '/b']) }))
|
||||
), view({ expandedProjects: ['/a', '/b'] }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/a' }),
|
||||
expect.objectContaining({ id: 'p1', depth: 0 }),
|
||||
@@ -147,7 +147,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'x', cwd: '/p', parentId: sid('y'), updatedAt: 2 }),
|
||||
summary({ id: 'y', cwd: '/p', parentId: sid('x'), updatedAt: 1 }),
|
||||
summary({ id: 'self', cwd: '/p', parentId: sid('self'), updatedAt: 3 }),
|
||||
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['x', 'y', 'self']) }))
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['x', 'y', 'self'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toContain('self')
|
||||
expect(ids).toContain('x')
|
||||
@@ -160,7 +160,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'b', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'a', cwd: '/p', updatedAt: 7 }),
|
||||
summary({ id: 'c', cwd: '/p', updatedAt: 7 }),
|
||||
), view({ expandedProjects: new Set(['/p']) }))
|
||||
), view({ expandedProjects: ['/p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
@@ -170,7 +170,7 @@ describe('deriveRows session tree', () => {
|
||||
summary({ id: 'p', cwd: '/p', updatedAt: 9 }),
|
||||
summary({ id: 'old', cwd: '/p', parentId: sid('p'), updatedAt: 1 }),
|
||||
summary({ id: 'new', cwd: '/p', parentId: sid('p'), updatedAt: 5 }),
|
||||
), view({ expandedProjects: new Set(['/p']), expandedSessions: new Set(['p']) }))
|
||||
), view({ expandedProjects: ['/p'], expandedSessions: ['p'] }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['p', 'new', 'old'])
|
||||
})
|
||||
@@ -178,7 +178,7 @@ describe('deriveRows session tree', () => {
|
||||
it('carries the running flag onto rows', () => {
|
||||
const rows = deriveRows(
|
||||
listOf(summary({ id: 'a', cwd: '/p', running: true })),
|
||||
view({ expandedProjects: new Set(['/p']) }))
|
||||
view({ expandedProjects: ['/p'] }))
|
||||
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user