refactor(client): remove dead sidebar ordering surfaces

This commit is contained in:
_Kerman
2026-08-11 17:51:18 +08:00
parent ed39516c20
commit 7222a99e33
29 changed files with 71 additions and 123 deletions

View File

@@ -2,5 +2,5 @@
# 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 packages/client/ui-workspace/README.md
README.md: 24d73beba56f527eb57276accb2693255bad9020
README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d
README.md: 359979fa26b0e12c7e28b929029fb7d4e9fca324
README.zh.md: 2139ea3cf7ec3ae84aaa84e181d2f36a60c9ba60

View File

@@ -6,7 +6,7 @@ Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only an empty query, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.
The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Distinct canonical paths remain separate id-keyed Workspaces when their basenames and display titles match; the sidebar hover detail exposes the full path. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Add workspace...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default, under which the sidebar header drops its add button rather than offering a dead one). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands.

View File

@@ -6,7 +6,7 @@
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session打开后默认显示五条 Session其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。不同的规范化路径即使 basename 和显示标题相同,仍会作为由 id 区分的独立 Workspace侧边栏的悬停详情会显示完整路径。每个注册各自声明一个**目录流子 slot**`single` kind`conversation.hero.workspace.directoryFlow``sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **添加工作区…** 操作仅在当前界面的 slot 被占用时渲染每次菜单渲染读取占用状态slot 为空意味着该组合没有目录选择能力——seam 文档化的无流程默认行为,此时侧边栏区头直接不渲染添加按钮,而非留下一个点了没反应的按钮)。本包持有触发与接纳:占用方通过 slot 的属主交互约定(`open`/`busy`/`onPicked`/`onCancel`/`onError`每次打开上报一个所选路径owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace取消操作不会显示提示错误落入可重试的文件夹对话框**重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框并以该行的显示标题预填客户端不设名称冲突规则host 负责规范化,可能以 `title-invalid` 拒绝错误渲染在对话框告警区确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档归档集合回声落地后该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失失败只作为控制台诊断输出树保持不变。空白的「新会话」行只是占位符不渲染行菜单和时间标签其中还没有发生任何事重命名、fork 和归档都从首条提示词落地后才可用。

View File

@@ -38,19 +38,6 @@
background: var(--dsw-alias-interactive-bg-hover);
}
.viewOptionLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.viewOptionCheck {
flex: none;
color: var(--dsw-alias-label-primary);
}
/* Section header: title, an inline search control, and the two trailing
actions. Expanding search collapses the action cluster and takes its room. */
.sectionHeader {

View File

@@ -12,7 +12,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconCheckOutline16, IconCloseFill14, IconPersonalizationOutline16,
Button, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
@@ -89,24 +89,19 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
t: WorkspaceBrowserProps['t']
}) {
const [open, setOpen] = useState(false)
const optionLabel = (label: string, selected: boolean) => (
<span className={css.viewOptionLabel}>
<span>{label}</span>
{selected && <IconCheckOutline16 className={css.viewOptionCheck} />}
</span>
)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={[
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: optionLabel(t('groupBy.workspace'), groupBy === 'workspace') },
{ id: 'flat', label: optionLabel(t('groupBy.flat'), groupBy === 'flat') },
{ id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: t('groupBy.flat') },
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
{ id: 'manual', label: optionLabel(t('orderBy.manual'), orderBy === 'manual'), disabled: groupBy !== 'workspace' },
{ id: 'updated', label: optionLabel(t('orderBy.updated'), orderBy === 'updated') },
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
{ id: 'updated', label: t('orderBy.updated') },
]}
selectedIds={[groupBy, orderBy]}
onSelect={(id) => {
if (id === 'workspace' || id === 'flat') onGroupPick(id)
else if (id === 'manual' || id === 'updated') onOrderPick(id)
@@ -280,7 +275,7 @@ function SessionTree({
})
}, [recentSessionOrder, workspaces])
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'),
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects],
)
const now = Date.now()
@@ -684,12 +679,12 @@ export function WorkspaceBrowser({
const onClick = (event: MouseEvent): void => {
if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
searchInput.current?.blur()
if (query !== '') return
if (normalizedQuery !== '') return
setSearchExpanded(false)
}
document.addEventListener('click', onClick)
return () => { document.removeEventListener('click', onClick) }
}, [query, wide, searchExpanded])
}, [normalizedQuery, wide, searchExpanded])
useEffect(() => {
if (normalizedQuery === '') {

View File

@@ -29,7 +29,6 @@ export interface SessionNode {
runningSubagentCount: number
/** Finished running while not selected and not yet opened (the green "done" reminder dot). */
completed: boolean
createdAt: number
updatedAt: number
}
@@ -108,10 +107,6 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
function sortSessions(sessions: SessionSummary[]): void {
sessions.sort(byRecency)
}
/**
* Ordinary sessions are visible; among blank sessions, only the current one
* is visible. Subagent children use their parent header catalog; archived
@@ -141,10 +136,12 @@ function buildGroup(
createdAt: number | undefined,
label: string,
members: readonly SessionSummary[],
orderBy: SessionOrderBy,
order: 'account' | 'recency',
): Group {
const sessions = [...members]
if (orderBy !== 'manual') sortSessions(sessions)
// Workspace order is the caller-selected sessionIds; only Ungrouped lacks
// an account order and therefore falls back to recency.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
@@ -157,7 +154,6 @@ function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
orderBy: SessionOrderBy,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
@@ -172,7 +168,7 @@ function groupByWorkspace(
}
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path,
Date.parse(workspace.createdAt), workspace.title, members, orderBy,
Date.parse(workspace.createdAt), workspace.title, members, 'account',
))
}
const stray = list.ids
@@ -180,10 +176,7 @@ function groupByWorkspace(
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(
UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray,
orderBy === 'manual' ? 'updated' : orderBy,
))
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
return groups
}
@@ -199,7 +192,6 @@ function sessionNode(
running: s.running,
runningSubagentCount: descendants.get(s.id)?.runningCount ?? 0,
completed: s.completed === true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
...(s.pendingInteraction === undefined ? {} : { pendingInteraction: s.pendingInteraction }),
}
@@ -217,7 +209,6 @@ function sessionNode(
* @param workspaces - real workspaces in stable Host order.
* @param archivedSessionIds - registry-global archive set.
* @param view - local expansion arrays.
* @param orderBy - local session ordering mode.
* @returns group sections in render order.
*/
export function deriveGroups(
@@ -225,7 +216,6 @@ export function deriveGroups(
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
view: TreeView,
orderBy: SessionOrderBy = 'manual',
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
@@ -235,7 +225,7 @@ export function deriveGroups(
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces, archived, orderBy)) {
for (const g of groupByWorkspace(list, workspaces, archived)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
@@ -273,7 +263,7 @@ export function deriveFlat(
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s)
}
sortSessions(rows)
rows.sort(byRecency)
return rows.map(session => sessionNode(session, descendants))
}

View File

@@ -116,7 +116,7 @@ describe('workspace browser rows', () => {
it('renders and opens a selected running Session row', () => {
const node: SessionNode = {
id: sid('session'), title: 'Session', blank: false, running: true,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const onOpen = vi.fn()
render(
@@ -137,7 +137,7 @@ describe('workspace browser rows', () => {
<SessionNodeItem
node={{
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0, ...over,
runningSubagentCount: 0, completed: false, updatedAt: 0, ...over,
}}
currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t}
@@ -169,7 +169,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: false,
runningSubagentCount: 2, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 2, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -191,7 +191,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('owner'), title: 'Delegating', blank: false, running: true,
runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 1, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -212,7 +212,7 @@ describe('workspace browser rows', () => {
it('keeps child activity as a secondary status while user attention is primary', () => {
const node: SessionNode = {
id: sid('owner'), title: 'Needs input', blank: false, pendingInteraction: 'question',
running: false, runningSubagentCount: 1, completed: false, createdAt: 0, updatedAt: 0,
running: false, runningSubagentCount: 1, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -303,7 +303,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('s-blank'), title: 'ignored', blank: true, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -330,7 +330,7 @@ describe('workspace browser rows', () => {
const onArchive = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
@@ -364,7 +364,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', blank: false, running: true,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -395,7 +395,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid(pendingInteraction), title: 'Needs input', blank: false,
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -422,7 +422,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -440,7 +440,7 @@ describe('workspace browser rows', () => {
try {
const node: SessionNode = {
id: sid('s1'), title: 'Done', blank: false, running: false,
runningSubagentCount: 0, completed: true, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
@@ -456,7 +456,7 @@ describe('workspace browser rows', () => {
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', blank: false, running: false,
runningSubagentCount: 0, completed: false, createdAt: 0, updatedAt: 0,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(

View File

@@ -12,7 +12,7 @@ const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, cwd?: string): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false,
createdAt: updatedAt, updatedAt, ...(cwd === undefined ? {} : { cwd }),
updatedAt, ...(cwd === undefined ? {} : { cwd }),
})
const list = (...items: SessionSummary[]): SessionListState => ({
ids: items.map(item => item.id),

View File

@@ -22,7 +22,7 @@ const t: WorkspaceBrowserProps['t'] = makeTranslate(zh, commonZh)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, blank: false, createdAt: updatedAt, updatedAt, ...overrides,
id: sid(id), displayTitle: id, running: false, blank: false, updatedAt, ...overrides,
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),
@@ -107,8 +107,11 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'按工作区', '单列表', '手动排序', '最近更新',
])
expect(screen.getByRole('menuitem', { name: '按工作区' }).querySelector('svg')).toBeTruthy()
expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
expect(screen.queryByText('创建时间')).toBeNull()
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat')
@@ -405,6 +408,11 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(search)
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
fireEvent.change(input, { target: { value: ' ' } })
fireEvent.click(document.body)
expect(search.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(search)
fireEvent.change(input, { target: { value: 'kept' } })
fireEvent.click(document.body)
expect(search.getAttribute('aria-expanded')).toBe('true')