feat(ui-workspace): archive session from the row menu
The visual-only Delete session placeholder becomes a wired Archive session action: no confirmation dialog (non-destructive), failures stay console diagnostics. tree.ts hides archived sessions in every derivation (workspace groups, Ungrouped, search, flat list) through the sessionVisible predicate. The workspace-management e2e pins the archive round trip across reload.
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
// Web e2e scenarios: workspace management — the create-by-name dialog, the
|
||||
// rename round trip over the real wire (workspace.rename RPC + durable
|
||||
// registry), duplicate-name pre-check, the flat "In one list" view with its
|
||||
// persisted group-by preference, and the session hover card. Zero model
|
||||
// calls: workspace.create/rename are host RPCs with no model involvement,
|
||||
// and the one session row the flat/hover scenarios need comes from a seeded
|
||||
// fixture (the seeded-history seed reused verbatim — no new recording).
|
||||
// persisted group-by preference, the session hover card, and the session
|
||||
// archive round trip (row menu → workspace.archiveSession RPC → durable
|
||||
// global set → row hidden across reload). Zero model calls:
|
||||
// workspace.create/rename/archiveSession are host RPCs with no model
|
||||
// involvement, and the one session row the flat/hover/archive scenarios need
|
||||
// comes from a seeded fixture (the seeded-history seed reused verbatim — no
|
||||
// new recording).
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
@@ -413,6 +416,47 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('archives the seeded session from its row menu, hiding it durably across reload', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-archive'))
|
||||
// The seeded session lives under Ungrouped (expanded by the hover-card
|
||||
// test's gesture; converge again for order independence).
|
||||
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
// Row menu: hover reveals the actions button; Archive session commits
|
||||
// without a confirmation dialog (non-destructive: log + accounting stay).
|
||||
await sessionRow.hover()
|
||||
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
|
||||
await page.getByRole('menuitem', { name: 'Archive session' }).click()
|
||||
// The row disappears on the archive-set echo; with no other visible
|
||||
// stray, the whole Ungrouped bucket withdraws.
|
||||
await expect.poll(() => page.getByText(rowTitle, { exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
await expect.poll(() => page.getByText('Ungrouped', { exact: true }).count(), { timeout: 10_000 }).toBe(0)
|
||||
// Durable on the host: the registry-global set carries the id while the
|
||||
// session log itself stays in persistence untouched.
|
||||
expect([...scaffold.ctx.workspace.archivedSessionIds]).toEqual([SessionId(SEED_ID)])
|
||||
expect((await scaffold.ctx.sessionPersistence.list()).map(header => header.id)).toContain(SessionId(SEED_ID))
|
||||
// Reload: the hidden state is rebuilt from the workspace.list baseline.
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
acknowledgeReloadConnectionLoss(tripwire, warningStart)
|
||||
await expect.poll(() => page.getByText('Workspaces', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
|
||||
// The archived row must not resurface (the Ungrouped bucket itself may
|
||||
// reappear if selection restore lands on another stray — not this test's
|
||||
// concern).
|
||||
expect(await page.getByText(rowTitle, { exact: true }).count()).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 90_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', async () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
// The directory-browser aria golden is this spec's one owned artifact;
|
||||
|
||||
@@ -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: f71bfa09c795bd69e1f49c8f6dffffd5959dbe47
|
||||
README.zh.md: 80b53d85eb210b0e7a7ace1699d6bbfc9a836606
|
||||
README.md: cc73214a281c6950acf8846f0bae3214c8726934
|
||||
README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534
|
||||
@@ -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 the Workspace create/rename and in-Workspace reorder flows. 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. 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 **Open local folder...** 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). 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. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. 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 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. 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 **Open local folder...** 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). 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. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. 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.
|
||||
|
||||
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
|
||||
|
||||
@@ -23,5 +23,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
|
||||
- **No Session deletion control** — the Session menu's Delete row remains visual-only; Workspace registration deletion does not delete Sessions.
|
||||
- **No Session deletion or unarchive control** — archiving replaces the former Delete placeholder; archived sessions have no viewing or unarchive surface yet, and Workspace registration deletion does not delete Sessions.
|
||||
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, fixture-only or remote browser deployments cannot open a local operating-system dialog; platform failures are shown in a retryable modal. Remote-capable picking is the `-browse` composition's in-app flow.
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 创建/重命名和 Workspace 内的重排序流程。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 code unit 内且不会拆分 surrogate pair,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
|
||||
该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
@@ -23,5 +23,5 @@ Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
|
||||
- **没有 Session 删除控件**:Session 菜单的 Delete 行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
|
||||
- **没有 Session 删除与取消归档控件**:归档取代了原先的 Delete 占位;已归档会话尚无查看或取消归档入口;删除 Workspace 注册记录不会删除 Session。
|
||||
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。
|
||||
@@ -102,18 +102,22 @@ type SessionTreeProps = Pick<
|
||||
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
|
||||
> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
/** Registry-global archive set (hidden rows). */
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
/** Open the browser-owned rename dialog for a real Workspace group. */
|
||||
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
|
||||
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
|
||||
/** Open the browser-owned session rename dialog. */
|
||||
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
|
||||
/** Archive a session (row menu action; the row disappears on the state echo). */
|
||||
onSessionArchive: (sessionId: SessionNode['id']) => void
|
||||
}
|
||||
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({
|
||||
useSessions, startSession, open, forkSession, workspaces,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
|
||||
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
|
||||
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
|
||||
}: SessionTreeProps) {
|
||||
const list = useSessions(s => s)
|
||||
const current = list.current
|
||||
@@ -129,8 +133,8 @@ function SessionTree({
|
||||
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
|
||||
}, [current, currentGroup])
|
||||
const groups = useMemo(
|
||||
() => deriveGroups(list, workspaces, { expandedProjects }),
|
||||
[list, workspaces, expandedProjects],
|
||||
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
|
||||
[list, workspaces, archivedSessionIds, expandedProjects],
|
||||
)
|
||||
const now = Date.now()
|
||||
|
||||
@@ -209,6 +213,7 @@ function SessionTree({
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
drag={dragProps}
|
||||
t={t}
|
||||
/>
|
||||
@@ -223,9 +228,11 @@ function SessionTree({
|
||||
}
|
||||
|
||||
/** The flat "In one list" body: every session a top-level row, newest-first. */
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
|
||||
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
|
||||
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
|
||||
>) {
|
||||
const list = useSessions(s => s)
|
||||
const rows = useMemo(() => deriveFlat(list), [list])
|
||||
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
|
||||
const now = Date.now()
|
||||
return (
|
||||
<div className={clsx(css.treeBody, css.wide)}>
|
||||
@@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<S
|
||||
onOpen={open}
|
||||
onRename={onSessionRename}
|
||||
onFork={forkSession}
|
||||
onArchive={onSessionArchive}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -263,12 +271,14 @@ function SearchResults({
|
||||
useSessions,
|
||||
open,
|
||||
workspaces,
|
||||
archivedSessionIds,
|
||||
query,
|
||||
remote,
|
||||
resultLimit,
|
||||
t,
|
||||
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
query: string
|
||||
remote: RemoteSearchState
|
||||
resultLimit: number
|
||||
@@ -278,8 +288,8 @@ function SearchResults({
|
||||
? remote
|
||||
: { query, status: 'loading' as const, items: [], hasMore: false }
|
||||
const results = useMemo(
|
||||
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
|
||||
[list, workspaces, query, currentRemote, resultLimit],
|
||||
() => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
|
||||
[list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
|
||||
)
|
||||
const pending = currentRemote.status === 'loading'
|
||||
const failed = currentRemote.status === 'error'
|
||||
@@ -337,6 +347,7 @@ export function WorkspaceBrowser({
|
||||
forkSession,
|
||||
renameWorkspace,
|
||||
deleteWorkspace,
|
||||
archiveSession,
|
||||
insertSessionBefore,
|
||||
createWorkspace,
|
||||
searchSessions,
|
||||
@@ -346,6 +357,7 @@ export function WorkspaceBrowser({
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
const groupBy = useStore(s => s.groupBy)
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
@@ -475,6 +487,16 @@ export function WorkspaceBrowser({
|
||||
setSessionRenameError(null)
|
||||
}
|
||||
|
||||
// Archive is dialog-free: not destructive (the log and the accounting slot
|
||||
// remain), so the menu action commits directly; the row disappears when the
|
||||
// archive-set echo lands. Failures are non-fatal console diagnostics, the
|
||||
// same posture as reorder rejections.
|
||||
const onSessionArchive = (sessionId: SessionNode['id']) => {
|
||||
archiveSession(sessionId).catch((reason: unknown) => {
|
||||
console.warn('session archive rejected:', reason)
|
||||
})
|
||||
}
|
||||
|
||||
// Delete dialog is separate from the row so a successful removal can
|
||||
// unmount that row without tearing down the in-flight confirmation state.
|
||||
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
|
||||
@@ -597,6 +619,7 @@ export function WorkspaceBrowser({
|
||||
useSessions={useSessions}
|
||||
open={open}
|
||||
workspaces={workspaces}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
query={normalizedQuery}
|
||||
remote={remoteSearch}
|
||||
resultLimit={searchResultLimit}
|
||||
@@ -607,15 +630,18 @@ export function WorkspaceBrowser({
|
||||
? (
|
||||
<FlatList
|
||||
useSessions={useSessions} open={open} forkSession={forkSession}
|
||||
onSessionRename={onSessionRename} t={t}
|
||||
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
|
||||
archivedSessionIds={archivedSessionIds} t={t}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useSessions={useSessions}
|
||||
onSessionRename={onSessionRename}
|
||||
onSessionArchive={onSessionArchive}
|
||||
forkSession={forkSession}
|
||||
workspaces={workspaces}
|
||||
archivedSessionIds={archivedSessionIds}
|
||||
startSession={startSession}
|
||||
open={open}
|
||||
insertSessionBefore={insertSessionBefore}
|
||||
|
||||
@@ -113,6 +113,12 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
|
||||
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
|
||||
/** Delete only a Host Workspace registration; directory and Session logs remain. */
|
||||
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
|
||||
/**
|
||||
* Archive a Session into the registry-global set: hidden from grouping
|
||||
* surfaces, log and accounting slot retained. Archiving the current
|
||||
* session clears the selection into the New Session view state.
|
||||
*/
|
||||
archiveSession: (sessionId: SessionId) => Promise<void>
|
||||
/**
|
||||
* Reorder a session inside its Workspace account (DOM-insertBefore
|
||||
* semantics: omitted anchor appends to the end). The view refreshes from
|
||||
|
||||
@@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void {
|
||||
},
|
||||
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
|
||||
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
|
||||
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
|
||||
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
|
||||
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ export const zh = {
|
||||
'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
|
||||
'delete.pending': '正在删除工作区…',
|
||||
'menu.fork': '分叉会话',
|
||||
'menu.deleteSession': '删除会话',
|
||||
'menu.archiveSession': '归档会话',
|
||||
'sessions.count.one': '{n} 个会话',
|
||||
'sessions.count.other': '{n} 个会话',
|
||||
'actions.workspace.aria': '工作区“{name}”的操作',
|
||||
@@ -108,7 +108,7 @@ export const en = {
|
||||
'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
|
||||
'delete.pending': 'Deleting workspace…',
|
||||
'menu.fork': 'Fork session',
|
||||
'menu.deleteSession': 'Delete session',
|
||||
'menu.archiveSession': 'Archive session',
|
||||
'sessions.count.one': '{n} session',
|
||||
'sessions.count.other': '{n} sessions',
|
||||
'actions.workspace.aria': 'Workspace actions for {name}',
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
* Workspace browser 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. Row ... menus are visual-only
|
||||
* except workspace Rename/Delete and session Rename/Fork; the session and
|
||||
* workspace hover cards are suppressed while a menu is open.
|
||||
* except workspace Rename/Delete and session Rename/Fork/Archive; the session
|
||||
* and workspace hover cards are suppressed while a menu is open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
|
||||
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16,
|
||||
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
|
||||
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
@@ -243,7 +243,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
|
||||
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
|
||||
node: SessionNode
|
||||
currentId: string | undefined
|
||||
now: number
|
||||
@@ -252,6 +252,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
onRename: (id: SessionNode['id'], currentTitle: string) => void
|
||||
/** Fork a session at its last completed turn (row menu action). */
|
||||
onFork: (id: SessionNode['id']) => void
|
||||
/** Archive this session (row menu action; commits without a dialog). */
|
||||
onArchive: (id: SessionNode['id']) => void
|
||||
/** Present only on draggable rows (workspace-group sessions outside search). */
|
||||
drag?: RowDragProps | undefined
|
||||
t: RowTranslate
|
||||
@@ -260,10 +262,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
const title = displayTitle(node, t)
|
||||
const selected = node.id === currentId
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
// Archive replaces the former Delete placeholder: it hides the row through
|
||||
// the registry-global archive set and never touches the session log, so it
|
||||
// is not styled as destructive and needs no confirmation dialog.
|
||||
const sessionMenuItems = [
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
|
||||
{ id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
|
||||
{ id: 'delete', label: t('menu.deleteSession'), icon: <IconTrashOutline16 />, danger: true },
|
||||
{ id: 'archive', label: t('menu.archiveSession'), icon: <IconDownloadOutline16 /> },
|
||||
]
|
||||
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
|
||||
const ownRow = (
|
||||
@@ -310,7 +315,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
|
||||
onSelect={(id) => {
|
||||
setMenuOpen(false)
|
||||
if (id === 'rename') onRename(node.id, row.title)
|
||||
if (id === 'fork') onFork(node.id) // delete stays visual-only.
|
||||
if (id === 'fork') onFork(node.id)
|
||||
if (id === 'archive') onArchive(node.id)
|
||||
}}
|
||||
portal
|
||||
closeOnPointerLeave
|
||||
|
||||
@@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
|
||||
return a.id < b.id ? -1 : 1
|
||||
}
|
||||
|
||||
/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
|
||||
function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
|
||||
return !session.blank || session.id === current
|
||||
/**
|
||||
* Ordinary sessions are visible; among blank sessions, only the current one
|
||||
* is visible; archived sessions are visible nowhere (their accounting slots
|
||||
* remain, so unarchiving restores position).
|
||||
*/
|
||||
function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
|
||||
return !archived.has(session.id) && (!session.blank || session.id === current)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +130,11 @@ function buildGroup(
|
||||
* order, with members resolved from sessionIds in their stored order. Sessions
|
||||
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
|
||||
*/
|
||||
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
|
||||
function groupByWorkspace(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archived: ReadonlySet<SessionId>,
|
||||
): Group[] {
|
||||
const groups: Group[] = []
|
||||
const accounted = new Set<SessionId>()
|
||||
for (const workspace of workspaces) {
|
||||
@@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
const summary = list.byId[id]
|
||||
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
|
||||
accounted.add(id)
|
||||
if (!sessionVisible(summary, list.current)) continue
|
||||
if (!sessionVisible(summary, list.current, archived)) continue
|
||||
members.push(summary)
|
||||
}
|
||||
groups.push(buildGroup(
|
||||
@@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
|
||||
const stray = list.ids
|
||||
.map(id => list.byId[id])
|
||||
.filter((s): s is SessionSummary =>
|
||||
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
|
||||
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, 'recency'))
|
||||
}
|
||||
@@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode {
|
||||
*
|
||||
* Every group shows; sessions populate under expanded groups, preserving
|
||||
* Host account order. Blank sessions are excluded except for the selected
|
||||
* provisional New Session row. Content search lives outside this derivation
|
||||
* provisional New Session row; archived sessions are excluded everywhere.
|
||||
* Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot (`current` feeds containsCurrent).
|
||||
* @param workspaces - real workspaces in stable Host order.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @param view - local expansion arrays.
|
||||
* @returns group sections in render order.
|
||||
*/
|
||||
export function deriveGroups(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
view: TreeView,
|
||||
): GroupNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const expandedProjects = new Set(view.expandedProjects)
|
||||
const currentGroup = list.current === undefined
|
||||
? undefined
|
||||
: (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)) {
|
||||
for (const g of groupByWorkspace(list, workspaces, archived)) {
|
||||
const expanded = expandedProjects.has(g.key)
|
||||
groups.push({
|
||||
key: g.key,
|
||||
@@ -209,13 +221,15 @@ export function deriveGroups(
|
||||
* no parent/child adjacency. Content search lives outside this derivation
|
||||
* (see {@link deriveSearchResults}).
|
||||
* @param list - sessions list snapshot.
|
||||
* @param archivedSessionIds - registry-global archive set.
|
||||
* @returns flat rows in render order.
|
||||
*/
|
||||
export function deriveFlat(list: SessionListState): SessionNode[] {
|
||||
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
|
||||
const archived = new Set(archivedSessionIds)
|
||||
const rows: SessionSummary[] = []
|
||||
for (const id of list.ids) {
|
||||
const s = list.byId[id]
|
||||
if (s === undefined || !sessionVisible(s, list.current)) continue
|
||||
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
|
||||
rows.push(s)
|
||||
}
|
||||
rows.sort(byRecency)
|
||||
@@ -238,6 +252,7 @@ export interface RelativeTime {
|
||||
* @param list - session metadata authority.
|
||||
* @param workspaces - Workspace membership and display labels.
|
||||
* @param query - caller text; surrounding whitespace is ignored.
|
||||
* @param archivedSessionIds - registry-global archive set (members never match).
|
||||
* @param content - ranked Host content-search page.
|
||||
* @param limit - protocol-owned maximum merged row count.
|
||||
* @returns bounded deduplicated flat rows and a refine-query hint bit.
|
||||
@@ -246,11 +261,13 @@ export function deriveSearchResults(
|
||||
list: SessionListState,
|
||||
workspaces: readonly WorkspaceView[],
|
||||
query: string,
|
||||
archivedSessionIds: readonly SessionId[],
|
||||
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
|
||||
limit: number,
|
||||
): SearchResultSet {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (q === '') return { items: [], hasMore: false }
|
||||
const archived = new Set(archivedSessionIds)
|
||||
|
||||
const workspaceBySession = new Map<SessionId, string>()
|
||||
for (const workspace of workspaces) {
|
||||
@@ -270,7 +287,7 @@ export function deriveSearchResults(
|
||||
const summary = list.byId[id]
|
||||
// Blank placeholders never match a query (their canonical title displays
|
||||
// localized, so matching it would tie search to one language).
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
|
||||
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
|
||||
if (
|
||||
sessionTitle(summary).toLowerCase().includes(q)
|
||||
|| labelOf(summary).toLowerCase().includes(q)
|
||||
@@ -290,7 +307,7 @@ export function deriveSearchResults(
|
||||
for (const summary of local) include(summary)
|
||||
for (const item of content.items) {
|
||||
const summary = list.byId[item.sessionId]
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
|
||||
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('workspace browser rows', () => {
|
||||
const onOpen = vi.fn()
|
||||
render(
|
||||
<SessionNodeItem node={node} currentId={node.id} now={0} onOpen={onOpen}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />,
|
||||
)
|
||||
|
||||
const row = screen.getByRole('treeitem')
|
||||
@@ -157,18 +157,20 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('button', { name: /工作区/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row menu opens without opening the session and dispatches rename and fork', () => {
|
||||
it('session row menu opens without opening the session and dispatches rename, fork, and archive', () => {
|
||||
const onOpen = vi.fn()
|
||||
const onRename = vi.fn()
|
||||
const onFork = vi.fn()
|
||||
const onArchive = vi.fn()
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={onOpen}
|
||||
onRename={onRename} onFork={onFork} t={t} />)
|
||||
onRename={onRename} onFork={onFork} onArchive={onArchive} t={t} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('menuitem', { name: '删除会话' }).className).toMatch(/danger/)
|
||||
// Archive is not destructive (log and accounting slot remain): no danger styling.
|
||||
expect(screen.getByRole('menuitem', { name: '归档会话' }).className).not.toMatch(/danger/)
|
||||
// Rename dispatches with the current display title (dialog prefill).
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '重命名' }))
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
@@ -177,10 +179,12 @@ describe('workspace browser rows', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '分叉会话' }))
|
||||
expect(onFork).toHaveBeenCalledWith(node.id)
|
||||
// Delete stays visual-only.
|
||||
// Archive dispatches without opening the session.
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '删除会话' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
expect(onArchive).toHaveBeenCalledWith(node.id)
|
||||
expect(onRename).toHaveBeenCalledOnce()
|
||||
expect(onOpen).not.toHaveBeenCalled()
|
||||
// Escape closes without selecting (Menu onClose path).
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“One”的操作' }))
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
@@ -194,7 +198,7 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Hovered', blank: false, running: true, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={60_000} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
@@ -220,7 +224,7 @@ describe('workspace browser rows', () => {
|
||||
id: sid('s1'), title: 'Quiet', blank: false, running: false, updatedAt: 0,
|
||||
}
|
||||
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} t={t} />)
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} t={t} />)
|
||||
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('空闲')).toBeTruthy()
|
||||
@@ -237,7 +241,7 @@ describe('workspace browser rows', () => {
|
||||
const inactive = dragProps()
|
||||
const { rerender } = render(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={inactive} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={inactive} t={t} />,
|
||||
)
|
||||
const row = screen.getByRole('treeitem')
|
||||
stubRect(row)
|
||||
@@ -255,7 +259,7 @@ describe('workspace browser rows', () => {
|
||||
const active = dragProps({ active: true, marker: 'before' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={active} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={active} t={t} />,
|
||||
)
|
||||
stubRect(screen.getByRole('treeitem'))
|
||||
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
|
||||
@@ -269,7 +273,7 @@ describe('workspace browser rows', () => {
|
||||
const after = dragProps({ active: true, marker: 'after' })
|
||||
rerender(
|
||||
<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()}
|
||||
onRename={vi.fn()} onFork={vi.fn()} drag={after} t={t} />,
|
||||
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} drag={after} t={t} />,
|
||||
)
|
||||
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
|
||||
})
|
||||
|
||||
@@ -26,19 +26,21 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
const view = (expandedProjects: readonly string[] = []) => ({
|
||||
expandedProjects,
|
||||
})
|
||||
const noArchive: readonly SessionId[] = []
|
||||
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
|
||||
|
||||
describe('deriveGroups', () => {
|
||||
it('keeps Host Workspace and sessionIds order without Client recency sorting', () => {
|
||||
const sessions = list(summary('newer', 20), summary('older', 10))
|
||||
const workspaces = [workspace('first', ['older', 'newer']), workspace('empty', [])]
|
||||
const groups = deriveGroups(sessions, workspaces, view(['first']))
|
||||
const groups = deriveGroups(sessions, workspaces, noArchive, view(['first']))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', 'empty'])
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([sid('older'), sid('newer')])
|
||||
})
|
||||
|
||||
it('puts only real unaccounted Sessions in the trailing Ungrouped group', () => {
|
||||
const sessions = list(summary('owned', 1, '/projects/first'), summary('loose', 9, '/other'))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], view([UNGROUPED_KEY]))
|
||||
const groups = deriveGroups(sessions, [workspace('first', ['owned'])], noArchive, view([UNGROUPED_KEY]))
|
||||
expect(groups.map(group => group.key)).toEqual(['first', UNGROUPED_KEY])
|
||||
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
|
||||
})
|
||||
@@ -52,7 +54,7 @@ describe('deriveGroups', () => {
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], view(['first']),
|
||||
sessions, [workspace('first', ['shown', 'current-blank', 'stale-blank'])], noArchive, view(['first']),
|
||||
)
|
||||
expect(groups[0]!.sessions.map(session => session.id)).toEqual([real.id, currentBlank.id])
|
||||
const blankNode = groups[0]!.sessions.find(session => session.id === currentBlank.id)!
|
||||
@@ -63,7 +65,7 @@ describe('deriveGroups', () => {
|
||||
expect(groups[0]!.sessions.find(session => session.id === real.id)!.blank).toBe(false)
|
||||
expect(groups[0]!.sessionCount).toBe(2)
|
||||
// A non-current blank stray never surfaces an Ungrouped bucket either.
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], view())
|
||||
const strayGroups = deriveGroups(list({ ...summary('stray', 2), blank: true }), [workspace('first', [])], noArchive, view())
|
||||
expect(strayGroups.map(group => group.key)).toEqual(['first'])
|
||||
})
|
||||
|
||||
@@ -80,6 +82,7 @@ describe('deriveGroups', () => {
|
||||
const groups = deriveGroups(
|
||||
list(parent, oldChild, newChild, tieB, tieA, self, orphan, cycleA, cycleB),
|
||||
[],
|
||||
noArchive,
|
||||
{ expandedProjects: [UNGROUPED_KEY] },
|
||||
)
|
||||
|
||||
@@ -90,7 +93,7 @@ describe('deriveGroups', () => {
|
||||
])
|
||||
|
||||
// Equal timestamps use ids as a deterministic tiebreak in either input order.
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], view([UNGROUPED_KEY]))[0]!
|
||||
expect(deriveGroups(list(summary('tie-a', 1), summary('tie-b', 1)), [], noArchive, view([UNGROUPED_KEY]))[0]!
|
||||
.sessions.map(node => node.id)).toEqual([sid('tie-a'), sid('tie-b')])
|
||||
})
|
||||
|
||||
@@ -100,17 +103,32 @@ describe('deriveGroups', () => {
|
||||
ids: [sid('present')],
|
||||
byId: { [sid('present')]: summary('present', 1) },
|
||||
}
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], view(['project']))
|
||||
const groups = deriveGroups(partial, [workspace('project', ['missing', 'present'])], noArchive, view(['project']))
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('hides archived sessions from workspace groups and Ungrouped', () => {
|
||||
const kept = summary('kept', 1, '/projects/first')
|
||||
const gone = summary('gone', 2, '/projects/first')
|
||||
const looseGone = summary('loose-gone', 3, '/other')
|
||||
const sessions = list(kept, gone, looseGone)
|
||||
const groups = deriveGroups(
|
||||
sessions, [workspace('first', ['kept', 'gone'])], archived('gone', 'loose-gone'), view(['first', UNGROUPED_KEY]),
|
||||
)
|
||||
// The archived member drops from its group AND the archived stray never
|
||||
// surfaces an Ungrouped bucket; counts follow the visible rows.
|
||||
expect(groups.map(group => group.key)).toEqual(['first'])
|
||||
expect(groups[0]!.sessions.map(node => node.id)).toEqual([kept.id])
|
||||
expect(groups[0]!.sessionCount).toBe(1)
|
||||
})
|
||||
|
||||
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
|
||||
const owned = summary('owned', 1)
|
||||
const loose = summary('loose', 2)
|
||||
const ws = workspace('project', ['owned'])
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], view())
|
||||
const ownedGroups = deriveGroups({ ...list(owned, loose), current: owned.id }, [ws], noArchive, view())
|
||||
expect(ownedGroups.find(group => group.key === 'project')!.containsCurrent).toBe(true)
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], view())
|
||||
const looseGroups = deriveGroups({ ...list(owned, loose), current: loose.id }, [ws], noArchive, view())
|
||||
expect(looseGroups.find(group => group.key === UNGROUPED_KEY)!.containsCurrent).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -121,13 +139,13 @@ describe('deriveFlat', () => {
|
||||
const child = { ...summary('child', 30), parentId: parent.id }
|
||||
const tieB = summary('tie-b', 20)
|
||||
const tieA = summary('tie-a', 20)
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA))
|
||||
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive)
|
||||
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
|
||||
})
|
||||
|
||||
it('tolerates ids whose summary has not landed yet', () => {
|
||||
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
|
||||
expect(deriveFlat(partial).map(row => row.id)).toEqual([sid('present')])
|
||||
expect(deriveFlat(partial, noArchive).map(row => row.id)).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('shows only the current blank session and excludes blanks from search', () => {
|
||||
@@ -137,11 +155,35 @@ describe('deriveFlat', () => {
|
||||
...list(summary('real', 1), currentBlank, staleBlank),
|
||||
current: currentBlank.id,
|
||||
}
|
||||
const rows = deriveFlat(sessions)
|
||||
const rows = deriveFlat(sessions, noArchive)
|
||||
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
|
||||
expect(rows.map(row => row.title)).toEqual(['New Session', 'real'])
|
||||
expect(rows.map(row => row.blank)).toEqual([true, false])
|
||||
})
|
||||
|
||||
it('hides archived sessions in flat mode', () => {
|
||||
const kept = summary('kept', 1)
|
||||
const gone = summary('gone', 2)
|
||||
expect(deriveFlat(list(kept, gone), archived('gone')).map(row => row.id)).toEqual([kept.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults archive filtering', () => {
|
||||
it('archived sessions never match — not by title and not via a backend content hit', () => {
|
||||
const hit = summary('hit', 2)
|
||||
hit.displayTitle = 'Needle row'
|
||||
const gone = summary('gone', 1)
|
||||
gone.displayTitle = 'Needle archived'
|
||||
const result = deriveSearchResults(
|
||||
list(hit, gone),
|
||||
[],
|
||||
'needle',
|
||||
archived('gone'),
|
||||
{ items: [{ sessionId: gone.id, snippet: 'needle body' }], hasMore: false },
|
||||
10,
|
||||
)
|
||||
expect(result.items.map(item => item.id)).toEqual([hit.id])
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
@@ -160,6 +202,7 @@ describe('deriveSearchResults', () => {
|
||||
workspace('duplicate-owner', ['title-hit'], 'Ignored duplicate owner'),
|
||||
],
|
||||
' NEEDLE ',
|
||||
noArchive,
|
||||
{
|
||||
items: [
|
||||
{ sessionId: contentHit.id, snippet: 'body needle excerpt' },
|
||||
@@ -212,6 +255,7 @@ describe('deriveSearchResults', () => {
|
||||
sessions,
|
||||
[workspace('first', ['opaque-current', 'new session stale'])],
|
||||
'new session',
|
||||
noArchive,
|
||||
{
|
||||
items: [
|
||||
{ sessionId: staleBlank.id, snippet: 'stale body' },
|
||||
@@ -234,6 +278,7 @@ describe('deriveSearchResults', () => {
|
||||
list(...rows),
|
||||
[],
|
||||
'needle',
|
||||
noArchive,
|
||||
{ items: [], hasMore: false },
|
||||
3,
|
||||
)
|
||||
@@ -244,12 +289,13 @@ describe('deriveSearchResults', () => {
|
||||
list(summary('body', 1)),
|
||||
[],
|
||||
'needle',
|
||||
noArchive,
|
||||
{ items: [{ sessionId: sid('body'), snippet: 'needle' }], hasMore: true },
|
||||
3,
|
||||
)
|
||||
expect(backendMore.items).toHaveLength(1)
|
||||
expect(backendMore.hasMore).toBe(true)
|
||||
expect(deriveSearchResults(list(), [], ' ', { items: [], hasMore: true }, 3))
|
||||
expect(deriveSearchResults(list(), [], ' ', noArchive, { items: [], hasMore: true }, 3))
|
||||
.toEqual({ items: [], hasMore: false })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,8 +35,8 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
})
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
const workspaceState = (items: readonly WorkspaceView[], archivedSessionIds: ReadonlySet<SessionId> = new Set()): WorkspaceListState => ({
|
||||
items, archivedSessionIds, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function hook<T>(snapshot: T) {
|
||||
@@ -68,6 +68,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
|
||||
forkSession: vi.fn(),
|
||||
renameWorkspace: vi.fn(async () => {}),
|
||||
deleteWorkspace: vi.fn(async () => {}),
|
||||
archiveSession: vi.fn(async () => {}),
|
||||
insertSessionBefore: vi.fn(async () => {}),
|
||||
createWorkspace: vi.fn(async () => workspace('created', [])),
|
||||
useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }),
|
||||
@@ -135,6 +136,50 @@ describe('WorkspaceBrowser', () => {
|
||||
expect(screen.queryByText('alpha-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('archives a session from the row menu and hides archived rows in both modes', async () => {
|
||||
const archiveSession = vi.fn(async () => {})
|
||||
const b = mount({
|
||||
useSessions: hook(sessionState([summary('kept-s', 2), summary('gone-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])])),
|
||||
archiveSession,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“gone-s”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
expect(archiveSession).toHaveBeenCalledWith(sid('gone-s'))
|
||||
|
||||
// The archive-set echo hides the row in grouped mode (count included) and flat mode.
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], new Set([sid('gone-s')]))) })
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
expect(screen.getByText('1 个会话')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
|
||||
expect(screen.getByText('kept-s')).toBeTruthy()
|
||||
expect(screen.queryByText('gone-s')).toBeNull()
|
||||
})
|
||||
|
||||
it('logs and keeps the tree when the archive call rejects', async () => {
|
||||
const rejection = new Error('archive exploded')
|
||||
const archiveSession = vi.fn(async () => { throw rejection })
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
try {
|
||||
mount({
|
||||
useSessions: hook(sessionState([summary('alpha-s', 1)])),
|
||||
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
|
||||
archiveSession,
|
||||
})
|
||||
fireEvent.click(screen.getByText('alpha'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '会话“alpha-s”的操作' }))
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '归档会话' }))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(warn).toHaveBeenCalledWith('session archive rejected:', rejection)
|
||||
expect(screen.getByText('alpha-s')).toBeTruthy()
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders a fork child as a top-level row without a session twist', () => {
|
||||
const parent = summary('parent-s', 2)
|
||||
const child = { ...summary('child-s', 1), parentId: parent.id }
|
||||
|
||||
@@ -32,7 +32,7 @@ const sessions: SessionListState = {
|
||||
ids: [], byId: {}, current: undefined, phase: 'ready',
|
||||
}
|
||||
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
|
||||
items, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
items, archivedSessionIds: new Set(), state: 'idle', phase: 'ready', error: null, baselinesReady: true,
|
||||
recentWorkspaceId: items[0]?.workspaceId,
|
||||
})
|
||||
function anchor(): { current: HTMLElement } {
|
||||
|
||||
Reference in New Issue
Block a user