Merge origin/master into web-permission-sandbox
39 conflicts resolved per the reattachment plan (missions worktree-projbiz 0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp, ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and client summary fields; the approval takeover, waitingApprovals tracking, and PendingApproval domain face carry over onto the master structure. The two new host specs follow the runtime->apiproxy rename. Dead PR-side wiring (ConversationInjected permissions/setPermission spread, InputBar controls prop, boot.ts sandbox composition) resolves to master and its replacement lands in follow-up commits.
This commit is contained in:
6
packages/client/ui-sidebar/README.i18n.yaml
Normal file
6
packages/client/ui-sidebar/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: 93a1f15a5802f94a0ebe930dda1dbd4fbc7343c9
|
||||
README.zh.md: 1ef636dbd00c894c8312ab1fbfa9a96af45956f0
|
||||
@@ -1,10 +1,14 @@
|
||||
# @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. Top-level New Session / New Workspace clear the selection onto `conversation.empty`; per-project "+" still create-then-opens. Collapse is a slide + crossfade into the layout-owned 56px rail (open / new session / new workspace / search — search expands and focuses the search box — plus the settings foot): the expanded content freezes at its width and fades in place while the column slides over it, then the rail — whale mark resting, panel icon on hover, tooltips on every control — crossfades in at settle as the wide content unmounts. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
`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.
|
||||
Sidebar plugin: real Host Workspaces in stable Host order, each containing its `sessionIds` in Workspace order with `parentId` nesting; Sessions outside every Workspace appear in a trailing `Ungrouped` section. Search, state dots, and collapse into the layout-owned 56px rail are presentation-local. Contract: the [slot system standard](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md).
|
||||
|
||||
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`.
|
||||
New Session starts the runtime's page-local frontend Session Intent; a real Workspace's "+" starts one targeted to that Workspace. The Workspace header "+" opens ui-workspace's shared picker, whose selection also targets a frontend Session. A Workspace Intent does not appear in the sidebar.
|
||||
|
||||
`SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared `sidebar.workspace` and `sidebar.settings` child slots, and injected `startSession`, `open`, and sidebar-toggle callbacks. There is no plugin store: `deriveGroups` consumes object-layer snapshots and component-local expansion/search state.
|
||||
|
||||
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
|
||||
|
||||
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).
|
||||
|
||||
|
||||
27
packages/client/ui-sidebar/README.zh.md
Normal file
27
packages/client/ui-sidebar/README.zh.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# @deepseek-ai/dsh-client-ui-sidebar
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
侧边栏插件:真实 Host Workspace 按稳定的 Host 顺序排列;每个 Workspace 按自身顺序包含其 `sessionIds`,并以 `parentId` 嵌套;不属于任何 Workspace 的 Session 显示在末尾的 `Ungrouped` 分区。搜索、状态点以及折叠到布局拥有的 56px 轨道,都只属于呈现层。契约:[slot 系统标准](../../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)。
|
||||
|
||||
New Session 会启动运行时的页面局部前端 Session Intent;真实 Workspace 的「+」会启动一项以该 Workspace 为目标的 Intent。Workspace 标题栏的「+」打开 ui-workspace 的共享选择器,选择结果同样以一个前端 Session 为目标。Workspace Intent 不会出现在侧边栏中。
|
||||
|
||||
`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 和 `useWorkspaces` hook、已声明的 `sidebar.workspace` 与 `sidebar.settings` 子 slot,以及注入的 `startSession`、`open` 和侧边栏切换回调。这里没有插件 store:`deriveGroups` 消费对象层快照与组件局部的展开/搜索状态。
|
||||
|
||||
页脚承载 `sidebar.settings`:侧边栏只渲染固定在底部的布局 slot,并共享其栏状态(`wide`);ui-settings 在此注册触发行和设置面板。
|
||||
|
||||
`/client` 导出表层只包含插件主体(`apply`/`inject`)及契约类型:SidebarRoot、行组件和树派生均属于内部实现(slot 注册通过闭包引用它们;测试直接导入 src 路径)。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无。侧边栏渲染浏览器会话列表;这里没有任何内容进入模型请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;该包既不组装也不发送提供方请求。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **状态点只有两种实时数据状态(running/none)**:done/error/amber 数据源随 P-II 审批与通知到来;四色原语已经接线。
|
||||
- **分组选单只提供按 Workspace 分组**:Update/Status 分组策略只有图稿而没有规范,暂缓实现。
|
||||
- **「New task completed」未读标记是本地查看状态**:完成时间 > 上次查看时间这一事实永远不会到达主机。
|
||||
@@ -1,204 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
|
||||
/* Two-line row: the leading slot (folder/chevron), title, and trailing
|
||||
actions all top-align on the 20px first text line (figma cell) — content
|
||||
is 42px (20 + 2 + 20), so 6px vertical padding centers the block. */
|
||||
.projectRow {
|
||||
height: 54px;
|
||||
align-items: flex-start;
|
||||
padding-top: 6px;
|
||||
padding-bottom: 6px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.projectRow .rowActions {
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Session cell (figma): pad 8, adjacent 16px twist + status slots, then a 4px
|
||||
gap to the title — the slots butt together, so the row gap is zeroed and
|
||||
the title carries its own margins. */
|
||||
.sessionRow {
|
||||
height: 34px;
|
||||
gap: 0;
|
||||
/* Mount fade: session rows appear by unfolding a group (or the tree
|
||||
mounting). Stable row keys keep already-visible rows from replaying it. */
|
||||
animation: row-in 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.sessionRow .title {
|
||||
margin: 0 6px 0 4px;
|
||||
}
|
||||
|
||||
@keyframes row-in {
|
||||
from { opacity: 0; }
|
||||
}
|
||||
|
||||
.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, expand arrow on row hover. */
|
||||
.projectRow .chevron { display: none; }
|
||||
.projectRow:hover .chevron { display: inline-flex; }
|
||||
.projectRow:hover .folder { display: none; }
|
||||
|
||||
/* Expand arrow (filled triangle): points right closed, rotates to point down open. */
|
||||
.arrow {
|
||||
transition: transform 150ms var(--ds-ease-in-out);
|
||||
}
|
||||
|
||||
.arrowOpen {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.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. Duplicates the .iconButton reset instead
|
||||
of `composes:` — the tsdown CSS-modules pipeline drops composes mappings, which
|
||||
left the raw UA button box showing. */
|
||||
.twist {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.twist:hover {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sessionRow,
|
||||
.arrow {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* 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 {
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTriangleRightFill14, 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: one 16px slot (figma session cell). */
|
||||
const INDENT_STEP = 16
|
||||
|
||||
/**
|
||||
* 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)}>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</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 session cell: pad 8, twist slot 16, status slot 16, gap 4 to
|
||||
// the title): both slots are always reserved so titles align whether or not
|
||||
// the twist/dot is lit. Extra depth rides the left padding.
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.sessionRow, selected && css.selected)}
|
||||
role="treeitem"
|
||||
aria-selected={selected}
|
||||
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
|
||||
style={{ paddingLeft: 8 + row.depth * INDENT_STEP }}
|
||||
onClick={onOpen}
|
||||
>
|
||||
{row.hasChildren
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.twist}
|
||||
aria-label={row.expanded ? 'Collapse' : 'Expand'}
|
||||
onClick={(e) => { e.stopPropagation(); onToggle() }}
|
||||
>
|
||||
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
|
||||
</button>
|
||||
)
|
||||
: <span className={css.slot} />}
|
||||
{/* Waiting-approval (amber) outranks the running ring: the session is
|
||||
blocked on the user, which is the more actionable fact. */}
|
||||
<span className={css.slot}>
|
||||
{row.waitingApproval ? <StateDot state="warning" /> : row.running && <StateDot state="ongoing" />}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -48,8 +48,7 @@
|
||||
refresh straight into the collapsed state renders statically. */
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
.railIn .footArea {
|
||||
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
|
||||
}
|
||||
|
||||
@@ -80,13 +79,19 @@
|
||||
|
||||
/* Brand group (figma I133:7632): the full wordmark rides the text ink
|
||||
(figma-flows ruling: main-screen instance is black; blue is brand
|
||||
emphasis only). */
|
||||
emphasis only). A button only in behavior (New Session shortcut): the
|
||||
pointer cursor is the sole affordance — no hover chrome on the mark. */
|
||||
.brand {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.iconButton {
|
||||
@@ -184,133 +189,9 @@
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
|
||||
the right-anchored new-workspace button is the row's rail survivor. */
|
||||
.sectionHeader {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
height: 36px;
|
||||
padding-left: 12px;
|
||||
margin-bottom: 4px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.collapsed .sectionHeader {
|
||||
height: 36px;
|
||||
padding-left: 0;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
|
||||
rail's search control. 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: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
|
||||
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);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(body[data-ds-dark-theme]) .search {
|
||||
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
|
||||
}
|
||||
|
||||
.collapsed .search {
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 0 12px;
|
||||
gap: 0;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* The capsule's leading icon, upgraded to the rail's search control. While
|
||||
expanded it is decorative: pointer-events off so clicks reach the label
|
||||
(native input focus); collapsed it becomes the hit target. */
|
||||
.searchButton {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.collapsed .searchButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
pointer-events: auto;
|
||||
cursor: pointer;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.collapsed .searchButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.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 seat: always mounted so the foot never moves; the tree content inside
|
||||
is wide-only and clips while the column squeezes. */
|
||||
.listArea {
|
||||
/* Region seat: always mounted so the foot never moves; the browser inside
|
||||
handles its own wide/rail content. */
|
||||
.regionArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
@@ -318,90 +199,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Relative for the bottom fade overlay. */
|
||||
.treeBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
/* Foot seat: a pure layout socket pinned under the region; the ui-settings
|
||||
trigger row inside owns its own geometry (49px wide row / 36px rail
|
||||
circle) and hover chrome. */
|
||||
.footArea {
|
||||
flex: none;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 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, 49 hug): the former 18/10 vertical
|
||||
margins fold into the row so the hover pill spans the full 49px. */
|
||||
.foot {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 49px;
|
||||
margin: 8px 0 0; /* + 49px row + root padBottom 6 keeps the old 57px band */
|
||||
padding: 0 2px 0 6px;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.foot:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Rail settings: the same 36x36 circle box as the other rail controls. */
|
||||
.collapsed .foot {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin: 18px 0 10px;
|
||||
justify-content: center;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.footLabel {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsed .footLabel {
|
||||
max-width: 0;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -409,8 +211,7 @@
|
||||
.fading > *,
|
||||
.railIn .iconButton,
|
||||
.railIn .newSession,
|
||||
.railIn .searchButton,
|
||||
.railIn .foot {
|
||||
.railIn .footArea {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@@ -1,149 +1,39 @@
|
||||
/**
|
||||
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
|
||||
* section header with the group-by menu, search, session tree list, 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).
|
||||
*
|
||||
* Collapse is a slide + crossfade: the content freezes at its expanded
|
||||
* width (inline style) and fades out in place while the sliding column
|
||||
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
|
||||
* the wide-only content (brand, labels, input, tree) unmounts, dropping
|
||||
* the sessions subscription, and the control rows snap to the 56px rail
|
||||
* (one icon each, same top-down order) fading in as the slide ends. Rail
|
||||
* search expands and focuses the search box.
|
||||
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
|
||||
* content freezes at its expanded width (inline style) and fades out in place
|
||||
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
|
||||
* mid-slide. At settle the wide-only content unmounts and the control rows
|
||||
* snap to the 56px rail (one icon each, same top-down order) fading in as the
|
||||
* slide ends. The workspace/session browsing region between the New Session
|
||||
* button and the foot is the `sidebar.workspaces` registrant's, and the foot
|
||||
* is the `sidebar.settings` registrant's; the shell hands them the wide flag
|
||||
* (plus an expand request callback for the browser).
|
||||
*/
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
BrandWordmark, FishLogo,
|
||||
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
|
||||
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
|
||||
Menu, Tooltip,
|
||||
IconNewChatOutline16, IconPanelLeftOutline16,
|
||||
Tooltip,
|
||||
} 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'
|
||||
|
||||
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
|
||||
const COLLAPSE_SETTLE_MS = 150
|
||||
|
||||
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
|
||||
const EXPAND_SLIDE_MS = 300
|
||||
|
||||
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 },
|
||||
]
|
||||
|
||||
/** 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]
|
||||
}
|
||||
|
||||
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
|
||||
function GroupByMenu() {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={GROUP_BY_ITEMS}
|
||||
selectedId="workspace"
|
||||
onSelect={() => { setOpen(false) }}
|
||||
align="end"
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.wide)}
|
||||
aria-label="Group by"
|
||||
onClick={() => { setOpen((v) => !v) }}
|
||||
>
|
||||
<IconPersonalizationOutline16 />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen' | 'onCreate'> & {
|
||||
/** Live search filter owned by the root (the query outlives the tree). */
|
||||
query: string
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
|
||||
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 rows = useMemo(
|
||||
() => deriveRows(list, { expandedProjects, expandedSessions, query }),
|
||||
[list, expandedProjects, expandedSessions, query],
|
||||
)
|
||||
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={clsx(css.treeBody, css.wide)}>
|
||||
<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={() => { setExpandedProjects((l) => toggled(l, row.key)) }}
|
||||
onCreate={() => { onCreate(row.cwd) }}
|
||||
/>
|
||||
</Fragment>
|
||||
)
|
||||
: (
|
||||
<SessionRowItem
|
||||
key={row.id}
|
||||
row={row}
|
||||
selected={row.id === current}
|
||||
now={now}
|
||||
onOpen={() => { onOpen(row.id) }}
|
||||
onToggle={() => { setExpandedSessions((l) => toggled(l, row.id)) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={css.fade} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sidebar column.
|
||||
* Render the sidebar column shell.
|
||||
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
|
||||
* @returns the sidebar element tree.
|
||||
*/
|
||||
export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, onToggleSidebar }: SidebarRootComponentProps) {
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
const searchInput = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
export function SidebarRoot({
|
||||
collapsed,
|
||||
width,
|
||||
startSession,
|
||||
toggleSidebar,
|
||||
renderSlot,
|
||||
}: SidebarRootComponentProps) {
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
||||
const [settled, setSettled] = useState(collapsed)
|
||||
@@ -165,29 +55,23 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
const everWide = useRef(!collapsed)
|
||||
if (!collapsed) everWide.current = true
|
||||
|
||||
// Rail search = expand + land in the search box: the flag arms before the
|
||||
// expand toggle; once expanded the input is mounted and takes focus.
|
||||
const [searchOnExpand, setSearchOnExpand] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!collapsed && searchOnExpand) {
|
||||
const timer = window.setTimeout(() => {
|
||||
searchInput.current?.focus({ preventScroll: true })
|
||||
setSearchOnExpand(false)
|
||||
}, EXPAND_SLIDE_MS)
|
||||
return () => { window.clearTimeout(timer) }
|
||||
}
|
||||
}, [collapsed, searchOnExpand])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
|
||||
style={wide ? { width: collapsed ? lastWideWidth.current : width } : undefined}
|
||||
>
|
||||
<div className={css.logoRow}>
|
||||
{/* Expanded, the wordmark doubles as a New Session shortcut; the
|
||||
collapsed rail's logo is the expand toggle below instead. */}
|
||||
{wide && (
|
||||
<span className={clsx(css.brand, css.wide)}>
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.brand, css.wide)}
|
||||
aria-label="New session"
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<BrandWordmark />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Rail resting state is the whale mark; hovering swaps in the panel
|
||||
icon (the expand affordance, figma sidebar-hover flow). */}
|
||||
@@ -196,7 +80,7 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
type="button"
|
||||
className={clsx(css.iconButton, css.toggle)}
|
||||
aria-label={collapsed ? 'Open sidebar' : 'Collapse sidebar'}
|
||||
onClick={() => { onToggleSidebar() }}
|
||||
onClick={() => { toggleSidebar() }}
|
||||
>
|
||||
{!wide && <FishLogo className={css.railFish} size={24} />}
|
||||
{/* Rail icons render at 18 (figma rail spec); expanded keeps the glyph-native sizes. */}
|
||||
@@ -210,73 +94,25 @@ export function SidebarRoot({ collapsed, width, useSessions, onOpen, onCreate, o
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label="New session"
|
||||
onClick={() => { onCreate() }}
|
||||
onClick={() => { startSession() }}
|
||||
>
|
||||
<IconNewChatOutline16 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>New Session</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<div className={css.sectionHeader}>
|
||||
{wide && <span className={clsx(css.sectionLabel, css.wide)}>WorkSpace</span>}
|
||||
{wide && <GroupByMenu />}
|
||||
<Tooltip label="New Workspace" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label="New workspace"
|
||||
onClick={() => { onCreate() }}
|
||||
>
|
||||
<IconProjectAddOutline16 size={wide ? 16 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{/* The browsing region fills the column between the controls and the
|
||||
foot in both states; its rail icon column rides the same slot. */}
|
||||
<div className={css.regionArea}>
|
||||
{renderSlot('sidebar.workspaces', {
|
||||
wide,
|
||||
expandSidebar: () => { if (collapsed) toggleSidebar() },
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Expanded: the row is a click-to-focus field (the leading icon is
|
||||
decorative). Collapsed: the icon is the rail's search control. */}
|
||||
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
|
||||
<Tooltip label="Search" disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.searchButton}
|
||||
aria-label="Search sessions"
|
||||
tabIndex={collapsed ? 0 : -1}
|
||||
onClick={() => { if (collapsed) { setSearchOnExpand(true); onToggleSidebar() } }}
|
||||
>
|
||||
<IconSearchOutline16 size={wide ? 14 : 18} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{wide && (
|
||||
<input
|
||||
ref={searchInput}
|
||||
className={clsx(css.searchInput, css.wide)}
|
||||
type="text"
|
||||
placeholder="Search name, keywords..."
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
{wide && query !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
className={clsx(css.clearButton, css.wide)}
|
||||
aria-label="Clear search"
|
||||
onClick={() => { setQuery('') }}
|
||||
>
|
||||
<IconCloseFill14 />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
|
||||
both states while the tree itself is wide-only. */}
|
||||
<div className={css.listArea}>
|
||||
{wide && <SessionTree useSessions={useSessions} onOpen={onOpen} onCreate={onCreate} query={query} />}
|
||||
</div>
|
||||
|
||||
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
|
||||
<IconSettingsOutline14 size={wide ? 14 : 18} />
|
||||
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}
|
||||
{/* Foot seat: ui-settings registers the trigger row + panel here. */}
|
||||
<div className={css.footArea}>
|
||||
{renderSlot('sidebar.settings', { wide })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,42 +1,74 @@
|
||||
/**
|
||||
* 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 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.
|
||||
* layout-owned `sidebar` slot, plus the holes this shell declares. The shell
|
||||
* owns column geometry (fold state machine, brand row, New Session);
|
||||
* everything between the section header and the list bottom is the
|
||||
* `sidebar.workspaces` registrant's (ui-workspace), and the foot is the
|
||||
* `sidebar.settings` registrant's (ui-settings).
|
||||
*/
|
||||
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PropsRenderSlots, 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 PropsRuntime<'sidebar'> resolves.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/**
|
||||
* Registrant-private injected share (arrives via the register inject
|
||||
* 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 = {
|
||||
/** Open (switch to) a session. */
|
||||
onOpen: (id: SessionId) => void
|
||||
/**
|
||||
* New-session affordance: no cwd clears selection onto the empty-state
|
||||
* launch; a cwd create-then-opens a session in that project group.
|
||||
*/
|
||||
onCreate: (cwd?: string) => void
|
||||
/** Collapse the sidebar column (layout service action; owner share stays {collapsed,width}). */
|
||||
onToggleSidebar: () => void
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
/**
|
||||
* The workspace/session browsing region: section header, search, the
|
||||
* grouped/flat session list, and every workspace dialog. Declared by this
|
||||
* package's 'sidebar' entry (declaring is claiming); ui-workspace
|
||||
* registers the browser.
|
||||
*/
|
||||
'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }
|
||||
/**
|
||||
* The settings seat at the sidebar foot. Declared by this package's
|
||||
* 'sidebar' entry; ui-settings registers its trigger row + modal panel.
|
||||
* The sidebar passes only its column state — it holds no settings state.
|
||||
*/
|
||||
'sidebar.settings': { kind: 'single'; scope: 'root'; owner: SidebarSettingsOwnerProps }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Owner share of the browser hole — the only facts crossing the shell/region
|
||||
* seam. Business data and actions arrive through the region's own inject.
|
||||
*/
|
||||
export type SidebarRootComponentProps = PropsRuntime<'sidebar'> & SidebarRootInjected
|
||||
export interface SidebarSectionOwnerProps {
|
||||
/** Shell fold-state output: wide renders the full browser, rail the icon column. */
|
||||
wide: boolean
|
||||
/** Rail icons request expansion; the browser rides the wide flip for focus. */
|
||||
expandSidebar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Owner share of the sidebar settings seat: the column display state the
|
||||
* occupant's trigger row must render against (wide row vs rail icon).
|
||||
*/
|
||||
export interface SidebarSettingsOwnerProps {
|
||||
/** Whether the sidebar renders wide content (false = 56px rail). */
|
||||
wide: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Registrant-private injected share (arrives via the register inject
|
||||
* factory). The shell keeps only its own controls: starting a Session from
|
||||
* the New Session button and toggling the column.
|
||||
*/
|
||||
export type SidebarRootInjected = {
|
||||
/**
|
||||
* Start a New Session: with a workspace, reuse-or-create its blank session
|
||||
* and open it; without one, clear the selection into the New Session pure
|
||||
* view state (the conversation.empty seat).
|
||||
*/
|
||||
startSession: (workspaceId?: WorkspaceId) => void
|
||||
/** Toggle the sidebar column through the layout service. */
|
||||
toggleSidebar: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Full component props: layout owner state/actions plus the declared holes'
|
||||
* render shares and this package's injected callbacks. No store is registered.
|
||||
*/
|
||||
export type SidebarRootComponentProps =
|
||||
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'> & SidebarRootInjected
|
||||
|
||||
@@ -1,47 +1,35 @@
|
||||
/**
|
||||
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
|
||||
* 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 { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
/** Registers the sidebar shell into the layout-owned slot. */
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } 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']
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
|
||||
|
||||
/**
|
||||
* 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.
|
||||
/** Registers the sidebar shell and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
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) => {
|
||||
// Top-level New Session / New Workspace: clear selection so AppFrame
|
||||
// shows conversation.empty (EmptyState + shared InputBar). Per-project
|
||||
// "+" still create-then-opens into that cwd until workspace seeding
|
||||
// reaches the empty-state picker.
|
||||
if (cwd === undefined) {
|
||||
ctx.sessions.clear()
|
||||
return
|
||||
}
|
||||
void ctx.sessions.create({ cwd })
|
||||
.then((id: SessionId) => { ctx.sessions.open(id) })
|
||||
},
|
||||
onToggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
// The shell's New Session button rides the runtime's shared action
|
||||
// (recent-Workspace targeting; explicit Workspace wins for scoped actions).
|
||||
startSession: (workspaceId) => { ctx.workspaces.startSession(workspaceId) },
|
||||
toggleSidebar: () => { ctx.layout.toggleSidebar() },
|
||||
})
|
||||
ctx.effect(
|
||||
() => ctx.slots.register({ name: 'sidebar', inject: injectProps }, SidebarRoot),
|
||||
() => ctx.slots.register({
|
||||
name: 'sidebar',
|
||||
// The shell owns geometry; ui-workspace registers the whole browsing
|
||||
// region (header, search, session list, workspace dialogs), ui-settings
|
||||
// registers the foot trigger + settings panel.
|
||||
children: {
|
||||
'sidebar.workspaces': { kind: 'single', scope: 'root' },
|
||||
'sidebar.settings': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: injectProps,
|
||||
}, SidebarRoot),
|
||||
'ui-sidebar: slot registration',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
/**
|
||||
* 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. 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'
|
||||
|
||||
/** 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
|
||||
/** An approval question is pending (amber warning dot outranks the running ring). */
|
||||
waitingApproval: boolean
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
/** One flat sidebar list row. */
|
||||
export type SidebarRow = ProjectRow | SessionRow
|
||||
|
||||
/** Viewing state consumed by the derivation — the component's local useState arrays, taken as-is. */
|
||||
export interface TreeView {
|
||||
expandedProjects: readonly string[]
|
||||
expandedSessions: readonly 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.displayTitle,
|
||||
depth,
|
||||
hasChildren,
|
||||
expanded,
|
||||
running: s.running,
|
||||
waitingApproval: s.waitingApproval,
|
||||
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.displayTitle.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 display-title substring): expansion state is ignored —
|
||||
* matched sessions and their ancestor chains are forced visible, groups
|
||||
* without a display-title or label hit are dropped, and a label-only hit keeps the
|
||||
* bare project row.
|
||||
* @param list - sessions list snapshot.
|
||||
* @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 = 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, 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`
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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 loader entry for the browser-only sidebar plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the sidebar plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -1,118 +1,61 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
|
||||
import { Context } from 'cordis'
|
||||
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
|
||||
|
||||
async function bench() {
|
||||
async function bench(declare = true) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const list = createSnapshotStore<SessionListState>({
|
||||
ids: [sid('a')],
|
||||
byId: { [sid('a')]: { id: sid('a'), title: 'alpha', displayTitle: 'alpha', cwd: '/proj', running: false, waitingApproval: false, updatedAt: 1 } },
|
||||
current: undefined,
|
||||
})
|
||||
const sessions = {
|
||||
list,
|
||||
create: vi.fn(async () => sid('minted')),
|
||||
open: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
}
|
||||
const layout = { toggleSidebar: vi.fn() }
|
||||
ctx.provide('sessions', sessions)
|
||||
const workspaces = { startSession: vi.fn() }
|
||||
const sessions = { open: vi.fn(), clear: vi.fn() }
|
||||
ctx.provide('layout', layout)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
ctx.provide('workspaces', workspaces as never)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// 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 }
|
||||
if (declare) {
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
)
|
||||
}
|
||||
return { ctx, slots, layout, workspaces, sessions }
|
||||
}
|
||||
|
||||
/** 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', () => {
|
||||
it('declares the services it binds', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions'])
|
||||
describe('ui-sidebar apply', () => {
|
||||
it('declares only the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
|
||||
})
|
||||
|
||||
it('fails loud when mounted without the inject declaration', async () => {
|
||||
// ctx.slots rides the cordis property proxy: reading it from a plugin
|
||||
// that never declared the dependency throws instead of yielding undefined.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
await expect(ctx.plugin({ apply })).rejects.toThrow(/without inject/)
|
||||
it('registers the shell and declares the browsing-region hole', async () => {
|
||||
const b = await bench()
|
||||
await b.ctx.plugin({ inject: [...inject], apply }).await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
|
||||
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
|
||||
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
|
||||
// Both arms delegate to the runtime's shared New Session action.
|
||||
injected.startSession('workspace' as never)
|
||||
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace')
|
||||
injected.startSession()
|
||||
expect(b.workspaces.startSession).toHaveBeenLastCalledWith(undefined)
|
||||
injected.toggleSidebar()
|
||||
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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('fails when no live owner declared the sidebar slot', async () => {
|
||||
const b = await bench(false)
|
||||
await expect(b.ctx.plugin({ inject: [...inject], apply })).rejects.toThrow(/not declared/)
|
||||
})
|
||||
|
||||
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()
|
||||
const injected = injectedOf(slots)
|
||||
|
||||
injected.onToggleSidebar()
|
||||
expect(layout.toggleSidebar).toHaveBeenCalledOnce()
|
||||
|
||||
injected.onOpen(sid('a'))
|
||||
expect(sessions.open).toHaveBeenCalledWith('a')
|
||||
|
||||
injected.onCreate()
|
||||
expect(sessions.clear).toHaveBeenCalledOnce()
|
||||
expect(sessions.create).not.toHaveBeenCalled()
|
||||
|
||||
injected.onCreate('/proj')
|
||||
expect(sessions.create).toHaveBeenCalledWith({ cwd: '/proj' })
|
||||
// create-then-open lands after the create promise resolves.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(sessions.open).toHaveBeenCalledWith('minted')
|
||||
})
|
||||
|
||||
it('teardown unregisters the slot entry', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
const fiber = ctx.plugin({ inject: [...inject], apply })
|
||||
it('removes the entry and child declaration on teardown', async () => {
|
||||
const b = await bench()
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(slots.entries('sidebar')).toHaveLength(1)
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,296 +1,98 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* 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, 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 type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
|
||||
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)))
|
||||
}
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
// The shell never reads the global hooks itself, but they ride the standard
|
||||
// props share; stub them as never-called functions.
|
||||
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
waitingApproval?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
title: init.title ?? init.id,
|
||||
displayTitle: init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
waitingApproval: init.waitingApproval ?? 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, current: undefined }
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function mount(...summaries: SessionSummary[]) {
|
||||
// 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()
|
||||
// The owner decides collapsed in production (AppFrame maps the preference);
|
||||
// the harness mirrors that loop so the toggle drives a re-render.
|
||||
let collapsed = false
|
||||
const view = (width: number) => (
|
||||
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
|
||||
const startSession = vi.fn()
|
||||
const toggleSidebar = vi.fn()
|
||||
let regionOwner: SidebarSectionOwnerProps | undefined
|
||||
let settingsOwner: SidebarSettingsOwnerProps | undefined
|
||||
let current = { collapsed, width }
|
||||
const root = () => (
|
||||
<SidebarRoot
|
||||
collapsed={collapsed}
|
||||
width={width}
|
||||
useSessions={hookOf(sessions)}
|
||||
onOpen={onOpen}
|
||||
onCreate={onCreate}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
collapsed={current.collapsed} width={current.width}
|
||||
useSessions={neverHook} useWorkspaces={neverHook}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar}
|
||||
renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
|
||||
if (key === 'sidebar.settings') {
|
||||
settingsOwner = owner
|
||||
return <div data-testid="settings-seat" data-wide={owner.wide} />
|
||||
}
|
||||
regionOwner = owner as SidebarSectionOwnerProps
|
||||
return <div data-testid="region" data-wide={owner.wide} />
|
||||
}) as SidebarRootComponentProps['renderSlot']}
|
||||
/>
|
||||
)
|
||||
const onToggleSidebar = vi.fn(() => {
|
||||
collapsed = !collapsed
|
||||
utils.rerender(view(collapsed ? 56 : 300))
|
||||
})
|
||||
const utils = render(view(300))
|
||||
return { sessions, onOpen, onCreate, onToggleSidebar, ...utils }
|
||||
const view = render(root())
|
||||
return {
|
||||
startSession,
|
||||
toggleSidebar,
|
||||
regionOwner: () => {
|
||||
if (regionOwner === undefined) throw new Error('region owner not rendered')
|
||||
return regionOwner
|
||||
},
|
||||
settingsOwner: () => {
|
||||
if (settingsOwner === undefined) throw new Error('settings owner not rendered')
|
||||
return settingsOwner
|
||||
},
|
||||
rerender(next: Partial<typeof current>) {
|
||||
current = { ...current, ...next }
|
||||
view.rerender(root())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 }),
|
||||
]
|
||||
|
||||
/** Flush the store's microtask-batched notification into React. */
|
||||
const flush = async () => { await act(async () => { await Promise.resolve() }) }
|
||||
|
||||
/** The brand wordmark is decorative svg (aria-hidden, no text); locate it by its native viewBox. */
|
||||
const wordmark = () => document.querySelector('svg[viewBox="0 0 182 24"]')
|
||||
|
||||
describe('SidebarRoot', () => {
|
||||
it('renders chrome and collapsed project rows', () => {
|
||||
mount(...projectData())
|
||||
expect(wordmark()).not.toBeNull()
|
||||
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()
|
||||
describe('SidebarRoot shell', () => {
|
||||
it('routes New Session (capsule + wordmark) and the column toggle', () => {
|
||||
const b = mountShell()
|
||||
// Expanded, both the wordmark and the capsule start a session.
|
||||
const starters = screen.getAllByRole('button', { name: 'New session' })
|
||||
expect(starters).toHaveLength(2)
|
||||
for (const button of starters) fireEvent.click(button)
|
||||
expect(b.startSession).toHaveBeenCalledTimes(2)
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
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('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
|
||||
const b = mountShell()
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
// The settings seat rides the same wide flag (ui-settings renders the row).
|
||||
expect(b.settingsOwner().wide).toBe(true)
|
||||
// Expanded: the request is a no-op (no accidental collapse).
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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(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')
|
||||
})
|
||||
|
||||
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 { onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('New Session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New workspace')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
// Per-project "+" is hover-revealed by CSS; still clickable in jsdom.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('New session here')[0]!) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith('/proj')
|
||||
})
|
||||
|
||||
it('collapse fades the wide content out, then the rail keeps the four controls', () => {
|
||||
it('keeps the region mounted through collapse and expands on its request', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar, onCreate } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledOnce()
|
||||
// Fade window: the wide chrome is still mounted while it fades.
|
||||
expect(wordmark()).not.toBeNull()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
// Settle: wide content unmounts, the rail controls remain.
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
expect(wordmark()).toBeNull()
|
||||
expect(screen.queryByText('New Session')).toBeNull()
|
||||
expect(screen.queryByRole('tree')).toBeNull()
|
||||
// Rail order mirrors the expanded rows: open, new session, new workspace, search.
|
||||
const rail = ['Open sidebar', 'New session', 'New workspace', 'Search sessions', 'Settings']
|
||||
.map((label) => screen.getByLabelText(label))
|
||||
for (let i = 1; i < rail.length; i++) {
|
||||
expect(rail[i - 1]!.compareDocumentPosition(rail[i]!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
}
|
||||
// Rail creation entries route like their expanded counterparts.
|
||||
act(() => { fireEvent.click(screen.getByLabelText('New session')) })
|
||||
expect(onCreate).toHaveBeenLastCalledWith()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
expect(screen.getByLabelText('Collapse sidebar')).toBeTruthy()
|
||||
expect(screen.getByText('New Session')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
const b = mountShell()
|
||||
b.rerender({ collapsed: true })
|
||||
// Wide content survives the crossfade window, then settles into the rail.
|
||||
expect(b.regionOwner().wide).toBe(true)
|
||||
vi.advanceTimersByTime(200)
|
||||
b.rerender({})
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByTestId('region')).toBeTruthy()
|
||||
b.regionOwner().expandSidebar()
|
||||
expect(b.toggleSidebar).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('rail search expands the sidebar and focuses the search box', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
// While expanded the search control is inert (the row click focuses instead).
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).not.toHaveBeenCalled()
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(onToggleSidebar).toHaveBeenCalledTimes(2)
|
||||
// Focus waits out the 300ms column slide (EXPAND_SLIDE_MS).
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
expect(document.activeElement).toBe(input)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('expanded search focuses without toggling the sidebar', () => {
|
||||
const { onToggleSidebar } = mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Search sessions')) })
|
||||
expect(document.activeElement).toBe(input)
|
||||
expect(onToggleSidebar).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('the search query survives a collapse/expand round trip', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
mount(...projectData())
|
||||
const input = screen.getByPlaceholderText('Search name, keywords...')
|
||||
act(() => { fireEvent.change(input, { target: { value: 'forked' } }) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Collapse sidebar')) })
|
||||
act(() => { vi.advanceTimersByTime(300) })
|
||||
act(() => { fireEvent.click(screen.getByLabelText('Open sidebar')) })
|
||||
const restored = screen.getByPlaceholderText('Search name, keywords...') as HTMLInputElement
|
||||
expect(restored.value).toBe('forked')
|
||||
expect(screen.getByText('forked child')).toBeTruthy()
|
||||
expect(screen.queryByText('elsewhere')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('group-by menu behaves', () => {
|
||||
mount(...projectData())
|
||||
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 { sessions } = mount(...projectData())
|
||||
act(() => {
|
||||
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 flush()
|
||||
expect(screen.getByText('fresh')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row "More" anchors swallow the click without opening or toggling', () => {
|
||||
const { onOpen } = mount(...projectData())
|
||||
act(() => { fireEvent.click(screen.getByText('proj')) })
|
||||
// Project-row anchor: must not collapse the project (rows stay visible).
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[0]!) })
|
||||
expect(screen.getByText('root work')).toBeTruthy()
|
||||
// Session-row anchor: must not open the session.
|
||||
act(() => { fireEvent.click(screen.getAllByLabelText('More')[1]!) })
|
||||
expect(onOpen).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()
|
||||
it('renders statically collapsed on a cold start (no crossfade classes)', () => {
|
||||
const b = mountShell({ collapsed: true })
|
||||
expect(b.regionOwner().wide).toBe(false)
|
||||
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('waiting-approval shows the amber warning dot and outranks the running ring', () => {
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
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 '../src/client/tree.ts'
|
||||
|
||||
const sid = (s: string) => s as SessionId
|
||||
|
||||
/** Bare-string init; brands ids and omits absent optional keys (exactOptionalPropertyTypes). */
|
||||
interface SummaryInit {
|
||||
id: string
|
||||
title?: string
|
||||
displayTitle?: string
|
||||
cwd?: string
|
||||
parentId?: string
|
||||
running?: boolean
|
||||
waitingApproval?: boolean
|
||||
updatedAt?: number
|
||||
}
|
||||
|
||||
function summary(init: SummaryInit): SessionSummary {
|
||||
const s: SessionSummary = {
|
||||
id: sid(init.id),
|
||||
displayTitle: init.displayTitle ?? init.title ?? init.id,
|
||||
running: init.running ?? false,
|
||||
waitingApproval: init.waitingApproval ?? false,
|
||||
updatedAt: init.updatedAt ?? 0,
|
||||
}
|
||||
if (init.title !== undefined) s.title = init.title
|
||||
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, current: undefined }
|
||||
}
|
||||
|
||||
const view = (partial: Partial<TreeView> = {}): TreeView => ({
|
||||
expandedProjects: partial.expandedProjects ?? [],
|
||||
expandedSessions: partial.expandedSessions ?? [],
|
||||
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: ['/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: ['/p'],
|
||||
expandedSessions: ['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: ['/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: ['/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: ['/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')
|
||||
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: ['/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: ['/p'], expandedSessions: ['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: ['/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)
|
||||
})
|
||||
|
||||
it('matches the effective display title when no durable title is available', () => {
|
||||
const fallback = listOf(summary({ id: 'raw-id', displayTitle: 'project fallback', cwd: '/elsewhere' }))
|
||||
const rows = deriveRows(fallback, view({ query: 'fallback' }))
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ type: 'project', key: '/elsewhere' }),
|
||||
expect.objectContaining({ type: 'session', id: 'raw-id', title: 'project fallback' }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user