Merge PR #500 into CI optimization
This commit is contained in:
19
packages/client/ui-sidebar/README.md
Normal file
19
packages/client/ui-sidebar/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# @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. Contract: api-contracts v3 §6.
|
||||
|
||||
`src/client/contract/slots.ts` is the single-domain contract file: `SidebarRootInjected` (the registrant's own injected share — tree hook, current-session hook, 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>`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as the sidebar renders the browser session list; nothing here reaches a model request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **State dots have two live data states (running/none)** — the done/error/amber sources arrive with P-II approvals and notifications; the four-color primitive is already wired.
|
||||
- **Group-by menu ships by-workspace only** — Update/Status grouping strategies are drawn without specs and deferred.
|
||||
- **"New task completed" unread marking is local viewing state** — completion-time > last-seen never reaches the host.
|
||||
62
packages/client/ui-sidebar/package.json
Normal file
62
packages/client/ui-sidebar/package.json
Normal file
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-client-ui-sidebar",
|
||||
"description": "Sidebar plugin: session multi-level tree, search, grouping, state dots",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./lib/types/client/index.d.ts",
|
||||
"default": "./lib/client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"dshClient": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-ui-layout"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
"scripts": {
|
||||
"bundle": "tsdown",
|
||||
"watch": "tsdown --watch"
|
||||
},
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@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"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@types/react": "~18.3.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/client.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
]
|
||||
}
|
||||
158
packages/client/ui-sidebar/src/client/Rows.module.css
Normal file
158
packages/client/ui-sidebar/src/client/Rows.module.css
Normal file
@@ -0,0 +1,158 @@
|
||||
/* Tree rows (figma Cell set 14:3080): project 54px two-line, session 34px
|
||||
single-line, radius 8, indent step 22px (16px slot + 6px gap). Hover swaps
|
||||
are pure CSS: project folder -> chevron + action buttons; session time ->
|
||||
ellipsis button. */
|
||||
|
||||
.projectRow,
|
||||
.sessionRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 0 8px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.projectRow:hover,
|
||||
.sessionRow:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.sessionRow.selected {
|
||||
background: var(--dsw-alias-interactive-bg-active);
|
||||
}
|
||||
|
||||
.projectRow {
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
|
||||
.folderActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* Project leading slot: folder by default, chevron on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
.projectRow:hover .folder { display: none; }
|
||||
|
||||
.projectText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.title {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.dot {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* Trailing action buttons surface on hover only (figma 27:4668 / 27:4656):
|
||||
bare 16px glyphs, gap 12, tertiary grey. */
|
||||
.rowActions {
|
||||
flex: none;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.projectRow:hover .rowActions,
|
||||
.sessionRow:hover .rowActions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.sessionRow:hover .time {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Session expand twist occupies the leading 16px slot; keep a spacer when absent
|
||||
so titles align across sibling rows. */
|
||||
.twist {
|
||||
composes: iconButton;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* "L" connector slot (figma arrow 14:3071): 16x16, glyph right-aligned. */
|
||||
.cornerSlot {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Chevrons and tree twists ride the caption grey (#ADB2B8); the folder glyph
|
||||
stays one step darker (tertiary, #81858C) per the cell spec. Declared last
|
||||
to win over the composed .iconButton color. */
|
||||
.chevron,
|
||||
.twist {
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
122
packages/client/ui-sidebar/src/client/Rows.tsx
Normal file
122
packages/client/ui-sidebar/src/client/Rows.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Sidebar tree row components (figma Cell set 14:3080): pure presentational —
|
||||
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
|
||||
* time->ellipsis, action buttons) are CSS-only.
|
||||
*/
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconChevronDownOutline14, IconChevronRightOutline14,
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTreeCorner8x10, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ProjectRow, SessionRow } from './tree.ts'
|
||||
import { formatRelativeTime } from './tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
|
||||
/** Indent step per tree level: 16px slot + 6px gap (figma). */
|
||||
const INDENT_STEP = 22
|
||||
|
||||
/**
|
||||
* Project (workspace) row: 54px, folder + title + session count; hover
|
||||
* reveals the chevron and the more/create buttons.
|
||||
* @param props.row - derived project row.
|
||||
* @param props.active - group contains the selected session (blue open folder).
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - create a session inside this group.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ row, active, onToggle, onCreate }: {
|
||||
row: ProjectRow
|
||||
active: boolean
|
||||
onToggle: () => void
|
||||
onCreate: () => void
|
||||
}) {
|
||||
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
|
||||
return (
|
||||
<div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
|
||||
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
|
||||
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
|
||||
</span>
|
||||
<span className={clsx(css.slot, css.chevron)}>
|
||||
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</span>
|
||||
<span className={css.projectText}>
|
||||
<span className={css.title}>{row.label}</span>
|
||||
<span className={css.meta}>{count}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{/* Row menu contents are not designed yet (figma draft notes); the button is the reserved anchor. */}
|
||||
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New session here"
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconPlusOutline16 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Session row: 34px, indent by depth, expand twist when it has children,
|
||||
* running state dot, relative time swapping to the more button on hover.
|
||||
* @param props.row - derived session row.
|
||||
* @param props.selected - row is the current session.
|
||||
* @param props.now - epoch ms for relative-time formatting.
|
||||
* @param props.onOpen - open this session.
|
||||
* @param props.onToggle - unfold/fold the subtree.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function SessionRowItem({ row, selected, now, onOpen, onToggle }: {
|
||||
row: SessionRow
|
||||
selected: boolean
|
||||
now: number
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
}) {
|
||||
// Rail (figma sub-cell slot sequence): twist slot, always-reserved state
|
||||
// slot (opacity-0 slots keep their 22px in figma, so titles align whether
|
||||
// or not the dot is lit), then the L connector on child rows. Extra depth
|
||||
// rides the left padding: indent spacers = depth - 1.
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.sessionRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + Math.max(0, row.depth - 1) * INDENT_STEP }}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{row.hasChildren
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
>
|
||||
{row.expanded ? <IconChevronDownOutline14 /> : <IconChevronRightOutline14 />}
|
||||
</button>
|
||||
)
|
||||
: <span className={css.slot} />}
|
||||
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
|
||||
{row.depth > 0 && (
|
||||
<span className={css.cornerSlot} data-tree-corner="">
|
||||
<IconTreeCorner8x10 />
|
||||
</span>
|
||||
)}
|
||||
<span className={css.title}>{row.title}</span>
|
||||
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
|
||||
<span className={css.rowActions}>
|
||||
<button type="button" className={css.iconButton} aria-label="More" onClick={(e) => { e.stopPropagation() }}>
|
||||
<IconEllipsisOutline16 />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
packages/client/ui-sidebar/src/client/SidebarRoot.module.css
Normal file
248
packages/client/ui-sidebar/src/client/SidebarRoot.module.css
Normal file
@@ -0,0 +1,248 @@
|
||||
/* Sidebar column (figma 133:7629): vertical stack, gap 8, padding 16/6,
|
||||
sidebar fill + 1px right border painted by the layout column. Header block
|
||||
(logo + New Session) and list area (section header + search + cells) carry
|
||||
their own inner gaps per the style spec (1.2 / 1.3). */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
padding: 6px 16px;
|
||||
box-sizing: border-box;
|
||||
background: var(--dsw-specific-sidebar-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* Header block (figma 133:7630): logo row + New Session, gap 16, padBottom 12. */
|
||||
.headerBlock {
|
||||
flex: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Logo row: 60px, brand mark left, collapse button right.
|
||||
figma pad is (l,t,r,b)=(4,8,4,8) — horizontal 4, vertical 8. */
|
||||
.logoRow {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Brand group (figma I133:7632): fish + wordmark ride the text ink
|
||||
(figma-flows ruling: main-screen instance is black; blue is brand
|
||||
emphasis only). */
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wordmark {
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* HARNESS badge (figma 34:10358): 14px tall, mono 11/500 on primary fill. */
|
||||
.badge {
|
||||
flex: none;
|
||||
padding: 0 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--dsw-alias-label-primary);
|
||||
color: var(--dsw-alias-label-primary-inverted);
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 14px;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.iconButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634). */
|
||||
.newSession {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 8px 16px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 510;
|
||||
line-height: 22px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newSession:hover {
|
||||
background: var(--dsw-alias-button-floating-hover);
|
||||
}
|
||||
|
||||
/* List area (figma 133:7640): section header + search + cells, gap 4.
|
||||
Relative for the bottom fade overlay. */
|
||||
.listArea {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
|
||||
transparent -> sidebar fill so it tracks the theme. */
|
||||
.fade {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 72px;
|
||||
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Batch separator (figma 133:7661): 20px spacer after an expanded project's
|
||||
session run, before the next project row. */
|
||||
.batchGap {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649). Upstream binds a dedicated
|
||||
design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped
|
||||
alias — a component token pinned to the static scale mirrors it (ruled
|
||||
compliant: indirect via custom property, upstream-variable equivalent). */
|
||||
.search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 38px;
|
||||
margin-bottom: 8px; /* + 4px area gap = 12px to the first cell (spec padB12) */
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
.searchInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.searchInput::placeholder {
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
/* Tree list: the only scrolling region. */
|
||||
.list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Foot: settings entry (figma 133:7668). */
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 29px;
|
||||
margin: 10px 0;
|
||||
padding: 0 2px 0 6px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.foot:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
164
packages/client/ui-sidebar/src/client/SidebarRoot.tsx
Normal file
164
packages/client/ui-sidebar/src/client/SidebarRoot.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { Fragment, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
FishLogo,
|
||||
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SidebarRootComponentProps } from './contract/slots.ts'
|
||||
import { ProjectRowItem, SessionRowItem } from './Rows.tsx'
|
||||
import css from './SidebarRoot.module.css'
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* @param props - composed slot props (owner share + injected surface, 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()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const now = Date.now()
|
||||
|
||||
// Presentational lookup (not tree derivation): the group holding the
|
||||
// selected session gets the active folder; only expanded groups can show it.
|
||||
let activeGroup: string | undefined
|
||||
if (current !== undefined) {
|
||||
for (const row of rows) {
|
||||
if (row.type === 'session' && row.id === current) { activeGroup = row.groupKey; break }
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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={() => { actions.toggleSidebar() }}
|
||||
>
|
||||
<IconPanelLeftOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className={css.newSession} onClick={() => { actions.create() }}>
|
||||
<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}
|
||||
onSelect={() => { setMenuOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setMenuOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { actions.create() }}
|
||||
>
|
||||
<IconProjectAddOutline16 />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className={css.search}>
|
||||
<IconSearchOutline16 size={14} />
|
||||
<input
|
||||
className={css.searchInput}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { tree.setQuery(e.target.value) }}
|
||||
/>
|
||||
{query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.clearButton}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { tree.setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div className={css.list} role="tree" aria-label="Sessions">
|
||||
{rows.length === 0 && (
|
||||
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
|
||||
)}
|
||||
{rows.map((row, i) => row.type === 'project'
|
||||
? (
|
||||
<Fragment key={`p:${row.key}`}>
|
||||
{/* Batch separator: a project row closing an expanded session run (figma 133:7661). */}
|
||||
{i > 0 && rows[i - 1]!.type === 'session' && <span className={css.batchGap} />}
|
||||
<ProjectRowItem
|
||||
row={row}
|
||||
active={row.key === activeGroup}
|
||||
onToggle={() => { tree.toggleProject(row.key) }}
|
||||
onCreate={() => { actions.create(row.cwd) }}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
: (
|
||||
<SessionRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
selected={row.id === current}
|
||||
now={now}
|
||||
onOpen={() => { actions.open(row.id) }}
|
||||
onToggle={() => { tree.toggleSession(row.id) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
|
||||
<div className={clsx(css.foot)} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 />
|
||||
Settings
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
packages/client/ui-sidebar/src/client/contract/slots.ts
Normal file
49
packages/client/ui-sidebar/src/client/contract/slots.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type { OwnerOf } 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.
|
||||
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.
|
||||
*/
|
||||
export type SidebarRootInjected = {
|
||||
useTree: SnapshotSelectorHook<SidebarTreeState>
|
||||
/** Current session selector (row highlight); undefined selects nothing. */
|
||||
useCurrent: () => SessionId | undefined
|
||||
actions: SidebarActions
|
||||
tree: SidebarTreeActions
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export type SidebarRootComponentProps = OwnerOf<'sidebar'> & SidebarRootInjected
|
||||
71
packages/client/ui-sidebar/src/client/index.ts
Normal file
71
packages/client/ui-sidebar/src/client/index.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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'
|
||||
|
||||
/** 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.
|
||||
* @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')
|
||||
}
|
||||
94
packages/client/ui-sidebar/src/client/store.ts
Normal file
94
packages/client/ui-sidebar/src/client/store.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* 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,
|
||||
}
|
||||
}
|
||||
265
packages/client/ui-sidebar/src/client/tree.ts
Normal file
265
packages/client/ui-sidebar/src/client/tree.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for sessions without a project directory. */
|
||||
export const UNGROUPED_KEY = ''
|
||||
|
||||
/** Display label for the ungrouped project row. */
|
||||
export const UNGROUPED_LABEL = 'Ungrouped'
|
||||
|
||||
/** Project (workspace) row: 54px, two lines (label + session count). */
|
||||
export interface ProjectRow {
|
||||
type: 'project'
|
||||
/** Group key: the cwd, or {@link UNGROUPED_KEY}. */
|
||||
key: string
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
/** Total sessions in the group, including hidden ones. */
|
||||
sessionCount: number
|
||||
expanded: boolean
|
||||
}
|
||||
|
||||
/** Session row: 34px single line; depth drives the 22px indent steps. */
|
||||
export interface SessionRow {
|
||||
type: 'session'
|
||||
id: SessionId
|
||||
/** Owning project group key (selection -> active-folder lookup). */
|
||||
groupKey: string
|
||||
title: string
|
||||
/** 0 = directly under the project row. */
|
||||
depth: number
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
running: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One flat sidebar list row. */
|
||||
export type SidebarRow = ProjectRow | SessionRow
|
||||
|
||||
/** Viewing state consumed by the derivation. */
|
||||
export interface TreeView {
|
||||
expandedProjects: ReadonlySet<string>
|
||||
expandedSessions: ReadonlySet<string>
|
||||
query: string
|
||||
}
|
||||
|
||||
interface Group {
|
||||
key: string
|
||||
cwd: string | undefined
|
||||
label: string
|
||||
summaries: Map<SessionId, SessionSummary>
|
||||
roots: SessionId[]
|
||||
children: Map<SessionId, SessionId[]>
|
||||
latest: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Project display label: basename of the group directory.
|
||||
* @param cwd - project directory, or undefined for the ungrouped bucket.
|
||||
* @returns basename, the raw cwd when it has no basename, or the ungrouped label.
|
||||
*/
|
||||
export function projectLabel(cwd: string | undefined): string {
|
||||
if (cwd === undefined || cwd === '') return UNGROUPED_LABEL
|
||||
const base = cwd.replace(/[/\\]+$/, '').split(/[/\\]/).pop()
|
||||
return base !== undefined && base !== '' ? base : cwd
|
||||
}
|
||||
|
||||
/** Recency comparator: newest first, id as the deterministic tiebreak (ids are unique per group). */
|
||||
function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
if (b.updatedAt !== a.updatedAt) return b.updatedAt - a.updatedAt
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
function groupByCwd(list: SessionListState): Group[] {
|
||||
const byKey = new Map<string, SessionSummary[]>()
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined) continue
|
||||
const key = s.cwd ?? UNGROUPED_KEY
|
||||
const members = byKey.get(key)
|
||||
if (members === undefined) byKey.set(key, [s])
|
||||
else members.push(s)
|
||||
}
|
||||
const groups: Group[] = []
|
||||
for (const [key, members] of byKey) {
|
||||
const summaries = new Map(members.map(m => [m.id, m]))
|
||||
const children = new Map<SessionId, SessionId[]>()
|
||||
const roots: SessionSummary[] = []
|
||||
for (const m of members) {
|
||||
// A session is a tree child only when its parent lives in the same
|
||||
// group; cross-group or unknown parents degrade to group roots.
|
||||
if (m.parentId !== undefined && m.parentId !== m.id && summaries.has(m.parentId)) {
|
||||
const kids = children.get(m.parentId)
|
||||
if (kids === undefined) children.set(m.parentId, [m.id])
|
||||
else kids.push(m.id)
|
||||
} else {
|
||||
roots.push(m)
|
||||
}
|
||||
}
|
||||
roots.sort(byRecency)
|
||||
for (const kids of children.values()) {
|
||||
kids.sort((a, b) => {
|
||||
const sa = summaries.get(a)
|
||||
const sb = summaries.get(b)
|
||||
/* v8 ignore next -- unreachable: kid ids are inserted alongside their summaries. */
|
||||
if (sa === undefined || sb === undefined) return 0
|
||||
return byRecency(sa, sb)
|
||||
})
|
||||
}
|
||||
const rootIds = roots.map(r => r.id)
|
||||
// parentId cycles (host bug) leave members unreachable from any root;
|
||||
// surface them as extra roots — the flatten walk's visited set stops
|
||||
// loops. Each node sits in at most one kids list and roots have no
|
||||
// in-group parent, so the scan pushes every reachable node exactly once.
|
||||
const reachable = new Set<SessionId>(rootIds)
|
||||
const stack = [...rootIds]
|
||||
while (stack.length > 0) {
|
||||
const top = stack.pop()
|
||||
/* v8 ignore next -- unreachable: the loop condition guarantees a non-empty stack. */
|
||||
if (top === undefined) break
|
||||
for (const kid of children.get(top) ?? []) {
|
||||
reachable.add(kid)
|
||||
stack.push(kid)
|
||||
}
|
||||
}
|
||||
for (const m of [...members].sort(byRecency)) {
|
||||
if (!reachable.has(m.id)) rootIds.push(m.id)
|
||||
}
|
||||
let latest = 0
|
||||
for (const m of members) latest = Math.max(latest, m.updatedAt)
|
||||
groups.push({
|
||||
key,
|
||||
cwd: key === UNGROUPED_KEY ? undefined : key,
|
||||
label: projectLabel(key === UNGROUPED_KEY ? undefined : key),
|
||||
summaries,
|
||||
roots: rootIds,
|
||||
children,
|
||||
latest,
|
||||
})
|
||||
}
|
||||
groups.sort((a, b) => b.latest - a.latest || (a.label < b.label ? -1 : a.label > b.label ? 1 : 0))
|
||||
return groups
|
||||
}
|
||||
|
||||
function sessionRow(g: Group, s: SessionSummary, depth: number, hasChildren: boolean, expanded: boolean): SessionRow {
|
||||
return {
|
||||
type: 'session',
|
||||
id: s.id,
|
||||
groupKey: g.key,
|
||||
title: s.title,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
updatedAt: s.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function flattenVisible(g: Group, expandedSessions: ReadonlySet<string>, rows: SidebarRow[]): void {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId, depth: number): void => {
|
||||
if (visited.has(id)) return
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return
|
||||
const kids = g.children.get(id) ?? []
|
||||
const expanded = expandedSessions.has(id)
|
||||
rows.push(sessionRow(g, s, depth, kids.length > 0, expanded))
|
||||
if (expanded) for (const kid of kids) walk(kid, depth + 1)
|
||||
}
|
||||
for (const root of g.roots) walk(root, 0)
|
||||
}
|
||||
|
||||
/** Matched sessions plus their ancestor chains (forced visible under search). */
|
||||
function searchVisible(g: Group, q: string): Set<SessionId> {
|
||||
const visible = new Set<SessionId>()
|
||||
for (const m of g.summaries.values()) {
|
||||
if (!m.title.toLowerCase().includes(q)) continue
|
||||
let cur: SessionSummary | undefined = m
|
||||
while (cur !== undefined && !visible.has(cur.id)) {
|
||||
visible.add(cur.id)
|
||||
cur = cur.parentId !== undefined && cur.parentId !== cur.id ? g.summaries.get(cur.parentId) : undefined
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
function flattenSearch(g: Group, visible: ReadonlySet<SessionId>, rows: SidebarRow[]): void {
|
||||
const visited = new Set<SessionId>()
|
||||
const walk = (id: SessionId, depth: number): void => {
|
||||
if (visited.has(id) || !visible.has(id)) return
|
||||
visited.add(id)
|
||||
const s = g.summaries.get(id)
|
||||
/* v8 ignore next -- unreachable: walked ids come from the grouped summaries. */
|
||||
if (s === undefined) return
|
||||
const kids = (g.children.get(id) ?? []).filter(kid => visible.has(kid))
|
||||
rows.push(sessionRow(g, s, depth, kids.length > 0, kids.length > 0))
|
||||
for (const kid of kids) walk(kid, depth + 1)
|
||||
}
|
||||
for (const root of g.roots) walk(root, 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the flat sidebar row list.
|
||||
*
|
||||
* Normal mode: every project row shows; sessions show under expanded
|
||||
* projects, descending only into expanded sessions. Search mode (non-blank
|
||||
* query, case-insensitive title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* 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.
|
||||
* @returns rows in render order.
|
||||
*/
|
||||
export function deriveRows(list: SessionListState, view: TreeView): SidebarRow[] {
|
||||
const q = view.query.trim().toLowerCase()
|
||||
const rows: SidebarRow[] = []
|
||||
for (const g of groupByCwd(list)) {
|
||||
if (q === '') {
|
||||
const expanded = view.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)
|
||||
} else {
|
||||
const visible = searchVisible(g, q)
|
||||
if (visible.size === 0 && !g.label.toLowerCase().includes(q)) continue
|
||||
rows.push({
|
||||
type: 'project',
|
||||
key: g.key,
|
||||
cwd: g.cwd,
|
||||
label: g.label,
|
||||
sessionCount: g.summaries.size,
|
||||
expanded: visible.size > 0,
|
||||
})
|
||||
flattenSearch(g, visible, rows)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time label for session rows (figma samples: now / 2min / 1h / 2d / 18d / 2mo).
|
||||
* @param updatedAt - epoch ms of the last update.
|
||||
* @param now - current epoch ms.
|
||||
* @returns compact age label.
|
||||
*/
|
||||
export function formatRelativeTime(updatedAt: number, now: number): string {
|
||||
const MIN = 60_000
|
||||
const HOUR = 3_600_000
|
||||
const DAY = 86_400_000
|
||||
const diff = Math.max(0, now - updatedAt)
|
||||
if (diff < MIN) return 'now'
|
||||
if (diff < HOUR) return `${Math.floor(diff / MIN)}min`
|
||||
if (diff < DAY) return `${Math.floor(diff / HOUR)}h`
|
||||
if (diff < 30 * DAY) return `${Math.floor(diff / DAY)}d`
|
||||
if (diff < 365 * DAY) return `${Math.floor(diff / (30 * DAY))}mo`
|
||||
return `${Math.floor(diff / (365 * DAY))}y`
|
||||
}
|
||||
6
packages/client/ui-sidebar/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-sidebar/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
10
packages/client/ui-sidebar/src/index.ts
Normal file
10
packages/client/ui-sidebar/src/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Sidebar plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 6.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for the sidebar plugin. */
|
||||
export function apply(): void {}
|
||||
32
packages/client/ui-sidebar/src/invariant.ts
Normal file
32
packages/client/ui-sidebar/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-sidebar`.
|
||||
* @module @deepseek-ai/dsh-client-ui-sidebar/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-sidebar'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-sidebar-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
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.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
158
packages/client/ui-sidebar/tests/apply.spec.tsx
Normal file
158
packages/client/ui-sidebar/tests/apply.spec.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
// @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.
|
||||
*/
|
||||
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 { 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'
|
||||
// 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 } },
|
||||
})
|
||||
const sessions = { list, create: vi.fn(async () => sid('minted')) }
|
||||
const layout = {
|
||||
current: createSnapshotStore<{ sessionId?: SessionId }>({}),
|
||||
open: vi.fn(),
|
||||
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' })
|
||||
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>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('apply', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions'])
|
||||
})
|
||||
|
||||
it('fails loud when mounted without the inject declaration', async () => {
|
||||
// ctx.sessions 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('binds actions to layout/sessions off the root binding', async () => {
|
||||
const { ctx, slots, sessions, layout } = await bench()
|
||||
await ctx.plugin({ inject: [...inject], apply }).await()
|
||||
mountSlot(ctx, slots)
|
||||
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('alpha')) })
|
||||
expect(layout.open).toHaveBeenCalledWith('a')
|
||||
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(sessions.create).toHaveBeenCalledWith({})
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(layout.open).toHaveBeenCalledWith('minted')
|
||||
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
})
|
||||
|
||||
it('throws from the inject factory when binding ctx lacks the services', 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()
|
||||
})
|
||||
})
|
||||
18
packages/client/ui-sidebar/tests/invariant.spec.ts
Normal file
18
packages/client/ui-sidebar/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import * as SidebarInvariant from '@deepseek-ai/dsh-client-ui-sidebar/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
describe('invariant companion', () => {
|
||||
it('registers under the package name with an empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(SidebarInvariant).await()).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('node-half apply is a no-op host placeholder', async () => {
|
||||
const { apply } = await import('@deepseek-ai/dsh-client-ui-sidebar')
|
||||
apply()
|
||||
expect(true).toBe(true) // reaching here without throw is the contract
|
||||
})
|
||||
})
|
||||
195
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
Normal file
195
packages/client/ui-sidebar/tests/sidebar-root.spec.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
// @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.
|
||||
*/
|
||||
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 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'
|
||||
|
||||
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 }
|
||||
}
|
||||
|
||||
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 actions: SidebarActions = {
|
||||
open: vi.fn((id: SessionId) => { current.update((d) => { d.id = id }) }),
|
||||
create: vi.fn(),
|
||||
toggleSidebar: vi.fn(),
|
||||
}
|
||||
const utils = render(
|
||||
<SidebarRoot
|
||||
useTree={tree.store.useSelector}
|
||||
useCurrent={() => current.useSelector((s) => s.id)}
|
||||
actions={actions}
|
||||
tree={tree}
|
||||
/>,
|
||||
)
|
||||
return { list, tree, current, actions, ...utils }
|
||||
}
|
||||
|
||||
const projectData = () => [
|
||||
summary({ id: 'root', title: 'root work', cwd: '/proj', updatedAt: 5 }),
|
||||
summary({ id: 'kid', title: 'forked child', cwd: '/proj', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'lone', title: 'elsewhere', cwd: '/other', updatedAt: 3 }),
|
||||
]
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
expect(screen.getByText('HARNESS')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
expect(screen.getByText('2 sessions')).toBeTruthy()
|
||||
expect(screen.getByText('1 session')).toBeTruthy()
|
||||
expect(screen.queryByText('root work')).toBeNull()
|
||||
})
|
||||
|
||||
it('expands a project on click and unfolds a subtree via the twist', () => {
|
||||
mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Expand')) })
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse')) })
|
||||
expect(screen.queryByText('forked child')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a session on row click and marks it selected', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
act(() => { fireEvent.click(screen.getByText('root work')) })
|
||||
expect(actions.open).toHaveBeenCalledWith('root')
|
||||
expect(screen.getByText('root work').closest('[role="treeitem"]')!.getAttribute('aria-selected')).toBe('true')
|
||||
})
|
||||
|
||||
it('search filters across groups and forces ancestor chains visible', () => {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
expect(screen.queryByText('elsewhere')).toBeNull()
|
||||
expect(screen.queryByText(/^other$/)).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Clear search')) })
|
||||
expect(screen.queryByText('root work')).toBeNull()
|
||||
expect(screen.getByText('proj')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the blank-list empty state without a query', () => {
|
||||
mount()
|
||||
expect(screen.getByText('No sessions yet')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the no-match empty state', () => {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'zzz-none' } }) })
|
||||
expect(screen.getByText('No matches')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('routes the three creation entries with the right cwd', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(actions.create).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
|
||||
expect(actions.create).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')
|
||||
})
|
||||
|
||||
it('collapse button and group-by menu behave', () => {
|
||||
const { actions } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(actions.toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
expect(screen.getByText('Update')).toBeTruthy()
|
||||
expect(screen.getByText('Status')).toBeTruthy()
|
||||
// Selecting the active strategy closes the list (only workspace is enabled).
|
||||
act(() => { fireEvent.click(screen.getByText('WorkSpace', { selector: 'button *' })) })
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
// Reopen and dismiss via Escape (Menu onClose channel).
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Group by')) })
|
||||
act(() => { fireEvent.keyDown(document, { key: 'Escape' }) })
|
||||
expect(screen.queryByText('Update')).toBeNull()
|
||||
})
|
||||
|
||||
it('re-renders when the sessions list gains a session', async () => {
|
||||
const { list } = mount(...projectData())
|
||||
act(() => {
|
||||
list.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() })
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row "More" anchors swallow the click without opening or toggling', () => {
|
||||
const { actions, tree } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
const before = tree.store.getSnapshot().expandedProjects.length
|
||||
// Project-row anchor: must not collapse the project.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
|
||||
expect(tree.store.getSnapshot().expandedProjects).toHaveLength(before)
|
||||
// Session-row anchor: must not open the session.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
|
||||
expect(actions.open).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the running state dot only for running sessions', () => {
|
||||
mount(
|
||||
summary({ id: 'busy', title: 'busy one', cwd: '/p', running: true, updatedAt: 2 }),
|
||||
summary({ id: 'idle', title: 'idle one', cwd: '/p', updatedAt: 1 }),
|
||||
)
|
||||
act(() => { fireEvent.click(screen.getByText('p')) })
|
||||
const busyRow = screen.getByText('busy one').closest('[role="treeitem"]')!
|
||||
const idleRow = screen.getByText('idle one').closest('[role="treeitem"]')!
|
||||
expect(busyRow.querySelector('[data-state="ongoing"]')).toBeTruthy()
|
||||
expect(idleRow.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
})
|
||||
})
|
||||
111
packages/client/ui-sidebar/tests/store.spec.ts
Normal file
111
packages/client/ui-sidebar/tests/store.spec.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
})
|
||||
})
|
||||
234
packages/client/ui-sidebar/tests/tree.spec.ts
Normal file
234
packages/client/ui-sidebar/tests/tree.spec.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import {
|
||||
deriveRows, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL,
|
||||
type SessionRow, type TreeView,
|
||||
} 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 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 }
|
||||
}
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? new Set(),
|
||||
expandedSessions: partial.expandedSessions ?? new Set(),
|
||||
query: partial.query ?? '',
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
it('takes the basename and survives trailing separators', () => {
|
||||
expect(projectLabel('/home/me/proj')).toBe('proj')
|
||||
expect(projectLabel('/home/me/proj/')).toBe('proj')
|
||||
expect(projectLabel('C:\\work\\thing')).toBe('thing')
|
||||
})
|
||||
|
||||
it('falls back for empty and root-only paths', () => {
|
||||
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('')).toBe(UNGROUPED_LABEL)
|
||||
expect(projectLabel('///')).toBe('///')
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows grouping', () => {
|
||||
it('groups by cwd into project rows with counts, newest group first', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
summary({ id: 'a', cwd: '/x/alpha', updatedAt: 10 }),
|
||||
summary({ id: 'b', cwd: '/x/beta', updatedAt: 30 }),
|
||||
summary({ id: 'c', cwd: '/x/alpha', updatedAt: 20 }),
|
||||
), view())
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/x/beta', label: 'beta', sessionCount: 1, expanded: false }),
|
||||
expect.objectContaining({ type: 'project', key: '/x/alpha', label: 'alpha', sessionCount: 2 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('orders equally-recent groups by label and skips ids missing from byId', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'b1', cwd: '/x/beta', updatedAt: 5 }),
|
||||
summary({ id: 'a1', cwd: '/x/alpha', updatedAt: 5 }),
|
||||
// Same basename and same recency as beta: label comparator returns 0,
|
||||
// insertion order breaks the tie.
|
||||
summary({ id: 'b2', cwd: '/y/beta', updatedAt: 5 }),
|
||||
)
|
||||
list.ids.push(sid('ghost'))
|
||||
const rows = deriveRows(list, view())
|
||||
expect(rows.map(r => r.type === 'project' && r.key)).toEqual(['/x/alpha', '/x/beta', '/y/beta'])
|
||||
})
|
||||
|
||||
it('buckets cwd-less sessions under the ungrouped project row', () => {
|
||||
const rows = deriveRows(listOf(summary({ id: 'a' })), view())
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: UNGROUPED_KEY, cwd: undefined, label: UNGROUPED_LABEL }),
|
||||
])
|
||||
})
|
||||
|
||||
it('hides sessions under collapsed projects and shows them when expanded', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'a', cwd: '/p', updatedAt: 1 }),
|
||||
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']) }))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ type: 'session', id: 'b', depth: 0 }),
|
||||
expect.objectContaining({ type: 'session', id: 'a', depth: 0 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows session tree', () => {
|
||||
const treeList = listOf(
|
||||
summary({ id: 'root', cwd: '/p', updatedAt: 5 }),
|
||||
summary({ id: 'kid', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'grandkid', cwd: '/p', parentId: sid('kid'), updatedAt: 3 }),
|
||||
summary({ id: 'other', cwd: '/p', updatedAt: 9 }),
|
||||
)
|
||||
|
||||
it('nests children under expanded parents with increasing depth', () => {
|
||||
const rows = deriveRows(treeList, view({
|
||||
expandedProjects: new Set(['/p']),
|
||||
expandedSessions: new Set(['root', 'kid']),
|
||||
}))
|
||||
expect(rows.slice(1)).toEqual([
|
||||
expect.objectContaining({ id: 'other', depth: 0, hasChildren: false }),
|
||||
expect.objectContaining({ id: 'root', depth: 0, hasChildren: true, expanded: true }),
|
||||
expect.objectContaining({ id: 'kid', depth: 1, hasChildren: true, expanded: true }),
|
||||
expect.objectContaining({ id: 'grandkid', depth: 2, hasChildren: false }),
|
||||
])
|
||||
})
|
||||
|
||||
it('collapses subtrees at unexpanded sessions', () => {
|
||||
const rows = deriveRows(treeList, view({ expandedProjects: new Set(['/p']) }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['other', 'root'])
|
||||
})
|
||||
|
||||
it('degrades a cross-group parent link to a group root', () => {
|
||||
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']) }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/a' }),
|
||||
expect.objectContaining({ id: 'p1', depth: 0 }),
|
||||
expect.objectContaining({ type: 'project', key: '/b' }),
|
||||
expect.objectContaining({ id: 'stray', depth: 0 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps cycle members visible as extra roots without looping', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
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']) }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toContain('self')
|
||||
expect(ids).toContain('x')
|
||||
expect(ids).toContain('y')
|
||||
expect(ids).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('breaks updatedAt ties deterministically by id', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
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']) }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('collects multiple children under one parent in recency order', () => {
|
||||
const rows = deriveRows(listOf(
|
||||
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']) }))
|
||||
const ids = rows.filter((r): r is SessionRow => r.type === 'session').map(r => r.id)
|
||||
expect(ids).toEqual(['p', 'new', 'old'])
|
||||
})
|
||||
|
||||
it('carries the running flag onto rows', () => {
|
||||
const rows = deriveRows(
|
||||
listOf(summary({ id: 'a', cwd: '/p', running: true })),
|
||||
view({ expandedProjects: new Set(['/p']) }))
|
||||
expect(rows[1]).toEqual(expect.objectContaining({ id: 'a', running: true }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveRows search', () => {
|
||||
const list = listOf(
|
||||
summary({ id: 'root', title: 'alpha work', cwd: '/p', updatedAt: 5 }),
|
||||
summary({ id: 'kid', title: 'deep needle here', cwd: '/p', parentId: sid('root'), updatedAt: 4 }),
|
||||
summary({ id: 'noise', title: 'unrelated', cwd: '/p', updatedAt: 3 }),
|
||||
summary({ id: 'q', title: 'quiet', cwd: '/other', updatedAt: 2 }),
|
||||
)
|
||||
|
||||
it('forces matched sessions and their ancestor chains visible, ignoring expansion', () => {
|
||||
const rows = deriveRows(list, view({ query: 'NEEDLE' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/p', expanded: true }),
|
||||
expect.objectContaining({ id: 'root', depth: 0, expanded: true }),
|
||||
expect.objectContaining({ id: 'kid', depth: 1 }),
|
||||
])
|
||||
})
|
||||
|
||||
it('drops groups without a hit and keeps a bare project row on label-only hits', () => {
|
||||
const rows = deriveRows(list, view({ query: 'other' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/other', expanded: false }),
|
||||
])
|
||||
})
|
||||
|
||||
it('blank query means normal mode', () => {
|
||||
const rows = deriveRows(list, view({ query: ' ' }))
|
||||
expect(rows.every(r => r.type === 'project')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = 1_000_000_000_000
|
||||
it.each([
|
||||
[now, 'now'],
|
||||
[now - 30_000, 'now'],
|
||||
[now - 2 * 60_000, '2min'],
|
||||
[now - 3_600_000, '1h'],
|
||||
[now - 2 * 86_400_000, '2d'],
|
||||
[now - 18 * 86_400_000, '18d'],
|
||||
[now - 65 * 86_400_000, '2mo'],
|
||||
[now - 400 * 86_400_000, '1y'],
|
||||
])('%d -> %s', (at, label) => {
|
||||
expect(formatRelativeTime(at, now)).toBe(label)
|
||||
})
|
||||
|
||||
it('clamps future timestamps to now', () => {
|
||||
expect(formatRelativeTime(now + 5_000, now)).toBe('now')
|
||||
})
|
||||
})
|
||||
40
packages/client/ui-sidebar/tsconfig.json
Normal file
40
packages/client/ui-sidebar/tsconfig.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"jsx": "react-jsx",
|
||||
"lib": [
|
||||
"ES2024",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
packages/client/ui-sidebar/tsdown.config.ts
Normal file
3
packages/client/ui-sidebar/tsdown.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../tsdown.client.ts'
|
||||
|
||||
export default clientBundle('@deepseek-ai/dsh-client-ui-sidebar', ['lib/types/index.js', 'lib/types/invariant.js'])
|
||||
Reference in New Issue
Block a user