From 82b8c476e62c43ad1132aa62c3ca221a31d0095d Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 23 Sep 2026 11:16:57 +0800
Subject: [PATCH 01/10] feat(client): add archive-off glyph and reuse
chat-lines for session references
---
.../ui-primitives/src/ReferenceIcon.tsx | 4 +-
.../client/ui-primitives/src/icons/index.tsx | 43 +++++++++++++++----
.../src/icons/shared-artwork.tsx | 16 ++++++-
3 files changed, 52 insertions(+), 11 deletions(-)
diff --git a/packages/client/ui-primitives/src/ReferenceIcon.tsx b/packages/client/ui-primitives/src/ReferenceIcon.tsx
index 0f3baad8b3..ca566c40f6 100644
--- a/packages/client/ui-primitives/src/ReferenceIcon.tsx
+++ b/packages/client/ui-primitives/src/ReferenceIcon.tsx
@@ -2,7 +2,7 @@ import type { ReactNode } from 'react'
import { ICON_MEDIUM_STROKE, ICON_REGULAR_STROKE } from './icons/index.tsx'
import type { IconProps } from './icons/props.ts'
import {
- BrowseOutlineArtwork, FolderCloseArtwork, NewChatOutlineArtwork,
+ BrowseOutlineArtwork, ChatLinesOutlineArtwork, FolderCloseArtwork,
} from './icons/shared-artwork.tsx'
/** Reference domains with distinct composer and transcript glyphs. */
@@ -24,7 +24,7 @@ interface WeightedReferenceIconProps extends ReferenceIconProps {
*/
function ReferenceIconArtwork({ kind, size = 16, className, strokeWidth }: WeightedReferenceIconProps): ReactNode {
switch (kind) {
- case 'session': return
+ case 'session': return
case 'file': return
case 'folder': return
}
diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx
index 56b7fac3a4..ce7e15f198 100644
--- a/packages/client/ui-primitives/src/icons/index.tsx
+++ b/packages/client/ui-primitives/src/icons/index.tsx
@@ -2,10 +2,11 @@
* Shared current-color product icons. Names identify the glyph and weight;
* rendered size remains a prop instead of part of the component name.
*/
+import { useId } from 'react'
import type { IconProps } from './props.ts'
import {
- BrowseOutlineArtwork, CodeBracketsArtwork, FolderCloseArtwork, GlobeOutlineArtwork,
- NewChatOutlineArtwork,
+ BrowseOutlineArtwork, ChatLinesOutlineArtwork, CodeBracketsArtwork, FolderCloseArtwork,
+ GlobeOutlineArtwork, NewChatOutlineArtwork,
} from './shared-artwork.tsx'
export type { IconProps } from './props.ts'
@@ -1109,12 +1110,8 @@ export const IconSendOutlineMedium = (props: IconProps) => (
)
-const IconQueueOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
-
+const IconQueueOutlineArtwork = ({ size = 14, ...rest }: WeightedIconProps) => (
+
)
/** Regular one-pixel IconQueueOutline artwork. */
@@ -1574,6 +1571,36 @@ export const IconChevronsUpDownOutlineMedium = (props: IconProps) => (
)
+const IconArchiveOffOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => {
+ // The mask carves whitespace along the slash out of the box strokes, so the
+ // id must stay unique per rendered instance.
+ const maskId = `dsh-archive-off-${useId().replaceAll(':', '')}`
+ return (
+
+ )
+}
+
+/** Regular one-pixel IconArchiveOffOutline artwork. */
+export const IconArchiveOffOutlineRegular = (props: IconProps) => (
+
+)
+
+/** Medium IconArchiveOffOutline artwork with a 1.3px stroke. */
+export const IconArchiveOffOutlineMedium = (props: IconProps) => (
+
+)
+
const IconArchiveCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
)
+/**
+ * Render shared conversation geometry — the chat bubble around two text
+ * lines — for the queue product icon and session reference icons.
+ * @param props - Size, optional CSS class, and inherited stroke width.
+ * @returns The decorative SVG artwork.
+ */
+export const ChatLinesOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedArtworkProps) => (
+
+)
+
/**
* Render shared globe geometry for product and link icons.
* @param props - Size, optional CSS class, and inherited stroke width.
From 9775a95315a95d6d3dc8a2169044e6d6a8a70902 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 23 Sep 2026 11:17:41 +0800
Subject: [PATCH 02/10] feat(client): make archived filtering an explicit
three-way menu choice
The view-options filter is now hide/all/only radio items, the
archived-only view drops Workspaces without an archived Session, and
tree children of a dropped Workspace nest under the nearest shown
ancestor (#4854).
---
packages/client/ui-workspace/README.i18n.yaml | 4 +-
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
.../client/ui-workspace/src/client/locales.ts | 10 ++-
.../src/client/rows/WorkspaceBrowser.tsx | 23 +++---
.../client/ui-workspace/src/client/tree.ts | 8 +-
.../ui-workspace/tests/tree.client.spec.ts | 23 ++++++
.../tests/workspace-browser.client.spec.tsx | 77 +++++++++++++++----
8 files changed, 113 insertions(+), 36 deletions(-)
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 373ff6d361..1de79a4099 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
- en: 745c8b9cb3858789
- zh: fb7fdf5856dfba89
+ en: 3c297ecd96033099
+ zh: 4864420a37633b52
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index d4d4e0df8f..3d1e75e7b9 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -49,7 +49,7 @@ Collapsed search is one header action beside the view and add actions: activatin
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
-Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility: the default hides archived Sessions, Show archived includes them, and Archived only hides ordinary Sessions. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and a "show archived" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
+Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index cf84f9e703..a3481f2f53 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -49,7 +49,7 @@ kind: "package-reference"
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
-对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项控制显隐:默认隐藏已归档 Session,显示已归档会将其纳入列表,仅显示已归档则隐藏普通 Session。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"和"筛选已归档会话"两个动作,后者直接把筛选切到显示已归档;停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
+对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"和"筛选已归档会话"两个动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts
index 0cbb0efd5d..3e35c52e63 100644
--- a/packages/client/ui-workspace/src/client/locales.ts
+++ b/packages/client/ui-workspace/src/client/locales.ts
@@ -27,7 +27,8 @@ export const zh = {
'orderBy.manual': '手动排序',
'orderBy.updated': '最近更新',
'filterBy.label': '筛选会话',
- 'viewOptions.showArchived': '显示已归档',
+ 'viewOptions.hideArchived': '隐藏已归档',
+ 'viewOptions.showArchived': '全部对话(显示已归档)',
'viewOptions.onlyArchived': '仅显示已归档',
'sessions.expand': '展开其余 {n} 个会话',
'sessions.collapse': '收起',
@@ -65,7 +66,7 @@ export const zh = {
'toast.archived': '会话已归档,可',
'toast.stoppedAndArchived': '已停止并归档,可',
'archive.confirm.title': '停止并归档此会话?',
- 'archive.confirm.desc': '“{title}”仍有正在进行的工作。归档会先停止这些工作;之后可在侧栏“显示已归档”中恢复会话,被停止的工作不会自动继续。',
+ 'archive.confirm.desc': '“{title}”仍有正在进行的工作。归档会先停止这些工作;之后可在侧栏筛选“全部对话(显示已归档)”中恢复会话,被停止的工作不会自动继续。',
'archive.confirm.activity': '将被停止的工作',
'archive.confirm.turn': '进行中的回合',
'archive.confirm.subagents.one': '{n} 个运行中的子智能体:{names}',
@@ -144,7 +145,8 @@ export const en = {
'orderBy.manual': 'Manual',
'orderBy.updated': 'Last updated',
'filterBy.label': 'Filter sessions',
- 'viewOptions.showArchived': 'Show archived',
+ 'viewOptions.hideArchived': 'Hide archived',
+ 'viewOptions.showArchived': 'All conversations (show archived)',
'viewOptions.onlyArchived': 'Archived only',
'sessions.expand': 'Show {n} more sessions',
'sessions.collapse': 'Show less',
@@ -182,7 +184,7 @@ export const en = {
'toast.archived': 'Session archived. You can ',
'toast.stoppedAndArchived': 'Session stopped and archived. You can ',
'archive.confirm.title': 'Stop and archive this session?',
- 'archive.confirm.desc': '“{title}” still has work in progress. Archiving stops it first; you can restore the session later from “Show archived” in the sidebar, and the stopped work will not resume on its own.',
+ 'archive.confirm.desc': '“{title}” still has work in progress. Archiving stops it first; you can restore the session later from the “All conversations (show archived)” filter in the sidebar, and the stopped work will not resume on its own.',
'archive.confirm.activity': 'Work that will be stopped',
'archive.confirm.turn': 'The turn in progress',
'archive.confirm.subagents.one': '{n} running subagent: {names}',
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
index 7de46dc0e9..9b64a932e8 100644
--- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
+++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
@@ -17,10 +17,10 @@
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
- Button, IconArchiveCheckOutlineRegular, IconArchiveOutlineRegular,
+ Button, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular,
IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular,
IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular,
- IconSearchOutlineRegular, IconSlidersTwoOutlineRegular,
+ IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular,
IconWorkspaceTreeOutlineRegular, Menu, Modal, Toast, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type {
@@ -127,22 +127,21 @@ function ViewOptionsMenu({ groupBy, orderBy, archivedFilter, onGroupPick, onOrde
{ id: 'updated', label: t('orderBy.updated'), icon: },
{ type: 'separator' as const, id: 'archived-filter-separator' },
{ type: 'label' as const, id: 'filter-by', text: t('filterBy.label') },
- { id: 'show-archived', label: t('viewOptions.showArchived'), icon: },
+ { id: 'hide-archived', label: t('viewOptions.hideArchived'), icon: },
+ { id: 'show-archived', label: t('viewOptions.showArchived'), icon: },
{ id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: },
]}
selectedIds={[
groupBy,
orderBy,
- ...archivedFilter === 'show' ? ['show-archived'] : [],
- ...archivedFilter === 'only' ? ['only-archived'] : [],
+ { default: 'hide-archived', show: 'show-archived', only: 'only-archived' }[archivedFilter],
]}
onSelect={(id) => {
if (id === 'workspace' || id === 'workspace-tree' || id === 'flat') onGroupPick(id)
else if (id === 'manual' || id === 'updated') onOrderPick(id)
- // The two archived items are mutually exclusive; re-picking the
- // selected one returns to the default hide-archived view.
- else if (id === 'show-archived') onArchivedFilterPick(archivedFilter === 'show' ? 'default' : 'show')
- else if (id === 'only-archived') onArchivedFilterPick(archivedFilter === 'only' ? 'default' : 'only')
+ else if (id === 'hide-archived') onArchivedFilterPick('default')
+ else if (id === 'show-archived') onArchivedFilterPick('show')
+ else if (id === 'only-archived') onArchivedFilterPick('only')
setOpen(false)
}}
align="end"
@@ -375,9 +374,13 @@ function SessionTree({
})
}
const childrenByParent = useMemo(() => {
+ const rendered = new Set(groups.map(group => group.key))
const children = new Map()
for (const group of groups) {
- const parent = parents.get(group.key)
+ // The archived-only view drops empty groups, so an ancestor may be
+ // absent; nest under the nearest rendered one.
+ let parent = parents.get(group.key)
+ while (parent !== undefined && !rendered.has(parent)) parent = parents.get(parent)
const siblings = children.get(parent)
if (siblings === undefined) children.set(parent, [group])
else siblings.push(group)
diff --git a/packages/client/ui-workspace/src/client/tree.ts b/packages/client/ui-workspace/src/client/tree.ts
index 8036af75d8..0be603f662 100644
--- a/packages/client/ui-workspace/src/client/tree.ts
+++ b/packages/client/ui-workspace/src/client/tree.ts
@@ -349,6 +349,9 @@ function groupByWorkspace(
if (!sessionVisible(summary, current, archived, archivedFilter)) continue
members.push(summary)
}
+ // The archived-only view lists archives, not the Workspace inventory, so
+ // a Workspace without archived Sessions contributes no group.
+ if (archivedFilter === 'only' && members.length === 0) continue
groups.push(buildGroup(
workspace.workspaceId, workspace.workspaceId, workspace.path,
Date.parse(workspace.createdAt), workspace.title, members,
@@ -416,8 +419,9 @@ function sessionNode(
/**
* Derive the workspace browser groups with every session as a top-level row.
*
- * Every group shows; sessions populate under expanded groups with pinned rows
- * leading in the selected local order. Blank sessions are
+ * Every group shows, except that the archived-only filter drops groups
+ * without visible members; sessions populate under expanded groups with
+ * pinned rows leading in the selected local order. Blank sessions are
* excluded except for the selected provisional New Session row; archived
* sessions keep their slots and appear per the archived filter. Content
* search lives outside this derivation (see {@link deriveSearchResults}).
diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts
index a04bb01814..f5dc42aa81 100644
--- a/packages/client/ui-workspace/tests/tree.client.spec.ts
+++ b/packages/client/ui-workspace/tests/tree.client.spec.ts
@@ -483,6 +483,29 @@ describe('deriveGroups', () => {
])
})
+ it('drops Workspaces without visible members under the only filter', () => {
+ const sessions = list(summary('stored', 2), summary('live', 1))
+ const groups = deriveGroups(
+ sessions, [workspace('full', ['stored', 'live']), workspace('empty', ['live'])],
+ rowState({ archived: ['stored'], archivedFilter: 'only' }),
+ noAttention, view(['full', 'empty']),
+ )
+ expect(groups.map(group => group.key)).toEqual(['full'])
+ expect(groups[0]!.sessions.map(node => node.id)).toEqual([sid('stored')])
+ })
+
+ it.each(['default', 'show'] as const)('keeps memberless Workspaces under the %s filter', (archivedFilter) => {
+ const sessions = list(summary('stored', 1))
+ const groups = deriveGroups(
+ sessions, [workspace('empty', ['stored'])],
+ rowState({ archived: ['stored'], archivedFilter }),
+ noAttention, view(['empty']),
+ )
+ expect(groups.map(group => [group.key, group.sessionCount])).toEqual([
+ ['empty', archivedFilter === 'show' ? 1 : 0],
+ ])
+ })
+
it('marks selected Workspace and Ungrouped sessions without relying on an Intent', () => {
const owned = summary('owned', 1)
const loose = summary('loose', 2)
diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
index 2b6c30ffd7..ccb2d43dac 100644
--- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
+++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx
@@ -436,7 +436,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByText('alive')).toBeTruthy()
expect(screen.queryByText('gone')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '全部对话(显示已归档)' }))
expect(b.store.getSnapshot().archivedFilter).toBe('show')
expect(screen.getByText('gone')).toBeTruthy()
} finally {
@@ -461,7 +461,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
expect(screen.getAllByRole('separator')).toHaveLength(2)
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()
@@ -487,7 +487,7 @@ describe('WorkspaceBrowser', () => {
expect(b.store.getSnapshot().groupBy).toBe('workspace')
})
- it('picking 显示已归档 keeps existing rows and reveals archived ones in place', () => {
+ it('picking 全部对话(显示已归档) keeps existing rows and reveals archived ones in place', () => {
mount({
useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])),
@@ -497,12 +497,12 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('stored')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '全部对话(显示已归档)' }))
expect(screen.getByText('kept')).toBeTruthy()
expect(screen.getByText('stored')).toBeTruthy()
})
- it('the two archived filters are mutually exclusive and re-picking returns to default', () => {
+ it('the archived filters are one exclusive choice and re-picking keeps it', () => {
const b = mount({
useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept', 'stored'])], [sid('stored')])),
@@ -519,26 +519,71 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('kept')).toBeNull()
expect(screen.getByText('stored')).toBeTruthy()
- // Picking the other filter replaces it: everything visible.
- pick('显示已归档')
+ // Picking another filter replaces it: everything visible.
+ pick('全部对话(显示已归档)')
expect(b.store.getSnapshot().archivedFilter).toBe('show')
expect(screen.getByText('kept')).toBeTruthy()
expect(screen.getByText('stored')).toBeTruthy()
- // Re-picking the selected filter returns to the default hidden view.
- pick('显示已归档')
- expect(b.store.getSnapshot().archivedFilter).toBe('default')
+ // Re-picking the selected filter keeps it selected.
+ pick('全部对话(显示已归档)')
+ expect(b.store.getSnapshot().archivedFilter).toBe('show')
expect(screen.getByText('kept')).toBeTruthy()
- expect(screen.queryByText('stored')).toBeNull()
+ expect(screen.getByText('stored')).toBeTruthy()
- // The same toggle-off applies to 仅显示已归档.
- pick('仅显示已归档')
- pick('仅显示已归档')
+ // 隐藏已归档 is the explicit way back to the default hidden view.
+ pick('隐藏已归档')
expect(b.store.getSnapshot().archivedFilter).toBe('default')
expect(screen.getByText('kept')).toBeTruthy()
expect(screen.queryByText('stored')).toBeNull()
})
+ it('仅显示已归档 hides Workspaces without archived Sessions', () => {
+ mount({
+ useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])),
+ useWorkspaces: hook(workspaceState(
+ [workspace('alpha', ['kept', 'stored']), workspace('beta', ['kept'])],
+ [sid('stored')],
+ )),
+ })
+ expect(screen.getByText('alpha')).toBeTruthy()
+ expect(screen.getByText('beta')).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '仅显示已归档' }))
+ expect(screen.getByText('alpha')).toBeTruthy()
+ expect(screen.queryByText('beta')).toBeNull()
+
+ fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '隐藏已归档' }))
+ expect(screen.getByText('beta')).toBeTruthy()
+ })
+
+ it('仅显示已归档 nests tree children of hidden Workspaces under the nearest shown ancestor', () => {
+ mount({
+ useSessions: hook(sessionState([summary('live', 2), summary('stored', 1)])),
+ useWorkspaces: hook(workspaceState(
+ [
+ { ...workspace('root', ['live'], 'Projects'), path: '/projects' },
+ workspace('child', ['stored'], 'Child'),
+ ],
+ [sid('stored')],
+ )),
+ })
+ const choose = (name: string) => {
+ fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
+ fireEvent.click(screen.getByRole('menuitem', { name }))
+ }
+ choose('按工作区树')
+ const parentSection = screen.getByText('Projects').closest('[class*="groupSection"]')!
+ expect(within(parentSection).getByText('Child')).toBeTruthy()
+
+ // Projects keeps no archived Sessions, so Child rises to the top level.
+ choose('仅显示已归档')
+ expect(screen.queryByText('Projects')).toBeNull()
+ expect(screen.getByText('Child')).toBeTruthy()
+ })
+
it('keeps the picked archived filter across remounts through the view store', () => {
const seats = {
useSessions: hook(sessionState([summary('kept', 2), summary('stored', 1)])),
@@ -547,7 +592,7 @@ describe('WorkspaceBrowser', () => {
const b = mount(seats)
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '全部对话(显示已归档)' }))
expect(b.store.getSnapshot().archivedFilter).toBe('show')
cleanup()
@@ -1154,7 +1199,7 @@ describe('WorkspaceBrowser', () => {
unarchiveSession,
})
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
- fireEvent.click(screen.getByRole('menuitem', { name: '显示已归档' }))
+ fireEvent.click(screen.getByRole('menuitem', { name: '全部对话(显示已归档)' }))
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
fireEvent.change(screen.getByPlaceholderText('搜索会话名称'), { target: { value: 'e' } })
await act(async () => { await Promise.resolve() })
From 62bd4363e6fdbee8a0ee358174c57cff9347acd0 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 23 Sep 2026 11:36:32 +0800
Subject: [PATCH 03/10] feat(client): omit the toast filter action while
archived rows are visible
---
packages/client/ui-workspace/README.i18n.yaml | 4 ++--
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
.../ui-workspace/src/client/contract/slots.ts | 3 +++
packages/client/ui-workspace/src/client/index.ts | 2 +-
.../client/session-actions/RowActionToast.tsx | 16 ++++++++++------
.../tests/session-actions.client.spec.tsx | 12 +++++++++++-
7 files changed, 29 insertions(+), 12 deletions(-)
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 1de79a4099..6f8a991408 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
- en: 3c297ecd96033099
- zh: 4864420a37633b52
+ en: 101e6b868f1c0e9d
+ zh: a8147e127e375223
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 3d1e75e7b9..30cc37a6c9 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -49,7 +49,7 @@ Collapsed search is one header action beside the view and add actions: activatin
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
-Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
+Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index a3481f2f53..bf16ebfdb9 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -49,7 +49,7 @@ kind: "package-reference"
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
-对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"和"筛选已归档会话"两个动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
+对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts
index de4d9099d4..fa4913203a 100644
--- a/packages/client/ui-workspace/src/client/contract/slots.ts
+++ b/packages/client/ui-workspace/src/client/contract/slots.ts
@@ -53,6 +53,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
import type { WorkspaceShortcutState } from '../shortcuts.ts'
import type { createWorkspaceViewStore } from '../stores.ts'
+import type { ArchivedFilter } from '../tree.ts'
/**
* Owner share of the directory-flow holes: the complete conversation between
@@ -415,6 +416,8 @@ export interface RowToastInjected {
hooks: {
/** The notice on display, or none. */
toast: HostObservable
+ /** The viewing state's archived filter; the archived notice omits its filter action when archived rows are already visible. */
+ view: HostObservable<{ archivedFilter?: ArchivedFilter }>
}
/** Take the notice down. */
dismissToast: () => void
diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts
index 61ca586a8f..8eff957df0 100644
--- a/packages/client/ui-workspace/src/client/index.ts
+++ b/packages/client/ui-workspace/src/client/index.ts
@@ -220,7 +220,7 @@ export function apply(ctx: Context): void {
renameSession,
})
const rowToastInjected = (): RowToastInjected => ({
- hooks: { toast: rowToast },
+ hooks: { toast: rowToast, view: viewInstance.store },
dismissToast: () => { rowToast.set(null) },
undoArchive: unarchiveSession,
showArchived: () => { viewInstance.actions.setArchivedFilter('show') },
diff --git a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx
index dec2712cc2..2ba5b71ee7 100644
--- a/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx
+++ b/packages/client/ui-workspace/src/client/session-actions/RowActionToast.tsx
@@ -15,14 +15,16 @@ const LONG_TOAST_HOLD_MS = 6000
/**
* Render the current notice: the archived and stopped-and-archived notices
- * with their undo and show-archived actions on a 6 s hold, a refused Session
- * creation with the Host's reason on the same hold, or a plain warning for a
- * failed pin, an archived row that was clicked, or default Workspace creation.
- * @param props - the notice hook, its dismissal, the two archived-notice actions, and the locale seat.
+ * with their undo action — plus the show-archived action while archived rows
+ * are hidden — on a 6 s hold, a refused Session creation with the Host's
+ * reason on the same hold, or a plain warning for a failed pin, an archived
+ * row that was clicked, or default Workspace creation.
+ * @param props - the notice and view hooks, the notice dismissal, the two archived-notice actions, and the locale seat.
* @returns the notice on display, or null.
*/
-export function RowActionToast({ useToast, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
+export function RowActionToast({ useToast, useView, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
const toast = useToast(current => current)
+ const archivedRowsVisible = useView(state => (state.archivedFilter ?? 'default') !== 'default')
if (toast === null) return null
if (toast.kind === 'archived' || toast.kind === 'stoppedAndArchived') {
const { sessionId } = toast
@@ -34,7 +36,9 @@ export function RowActionToast({ useToast, dismissToast, undoArchive, showArchiv
holdMs={LONG_TOAST_HOLD_MS}
actions={[
{ label: t('toast.archivedUndo'), onClick: () => { dismissToast(); undoArchive(sessionId) } },
- { prefix: t('toast.archivedOr'), label: t('toast.archivedFilter'), onClick: () => { dismissToast(); showArchived() } },
+ ...archivedRowsVisible ? [] : [
+ { prefix: t('toast.archivedOr'), label: t('toast.archivedFilter'), onClick: () => { dismissToast(); showArchived() } },
+ ],
]}
onDone={dismissToast}
/>
diff --git a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx
index ca8c1925ae..75603a43ec 100644
--- a/packages/client/ui-workspace/tests/session-actions.client.spec.tsx
+++ b/packages/client/ui-workspace/tests/session-actions.client.spec.tsx
@@ -489,8 +489,9 @@ declare module '@deepseek-ai/dsh-workspace/types' {
describe('RowActionToast', () => {
/** The notice surface over a test-owned notice source; dismissal clears the notice the way apply does. */
- function toastSurface() {
+ function toastSurface(archivedFilter: 'default' | 'show' | 'only' = 'default') {
const toast = createSnapshotStore(null)
+ const view = createSnapshotStore<{ archivedFilter?: 'default' | 'show' | 'only' }>({ archivedFilter })
const dismissToast = vi.fn(() => { toast.set(null) })
const undoArchive = vi.fn()
const showArchived = vi.fn()
@@ -498,6 +499,7 @@ describe('RowActionToast', () => {
{
expect(screen.queryByRole('alert')).toBeNull()
})
+ it.each(['show', 'only'] as const)('omits the filter action while the %s filter already shows archived rows', (archivedFilter) => {
+ const { notify } = toastSurface(archivedFilter)
+ notify({ kind: 'archived', sessionId: sid('one') })
+ expect(screen.getByRole('alert').textContent).toBe('会话已归档,可撤销')
+ expect(screen.queryByRole('button', { name: '筛选已归档会话' })).toBeNull()
+ expect(screen.getByRole('button', { name: '撤销' })).toBeTruthy()
+ })
+
it.each([
['pinFailed', '置顶失败,请稍后重试'],
['unpinFailed', '取消置顶失败,请稍后重试'],
From 2129486048f830b3c76aa44672c50d42245d32b1 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Wed, 23 Sep 2026 12:04:21 +0800
Subject: [PATCH 04/10] feat(client): give the session list an iconed empty
state with an archived-only way back
---
packages/client/ui-workspace/README.i18n.yaml | 4 +--
packages/client/ui-workspace/README.md | 2 +-
packages/client/ui-workspace/README.zh.md | 2 +-
.../client/ui-workspace/src/client/locales.ts | 4 +++
.../client/rows/WorkspaceBrowser.module.css | 26 ++++++++++++++++
.../src/client/rows/WorkspaceBrowser.tsx | 31 ++++++++++++++++---
.../tests/workspace-browser.client.spec.tsx | 17 ++++++++++
7 files changed, 77 insertions(+), 9 deletions(-)
diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml
index 6f8a991408..037d9f071a 100644
--- a/packages/client/ui-workspace/README.i18n.yaml
+++ b/packages/client/ui-workspace/README.i18n.yaml
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
- en: 101e6b868f1c0e9d
- zh: a8147e127e375223
+ en: ca18d098d32fb755
+ zh: 251be2fe3d1ac857
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md
index 30cc37a6c9..5d0fac32ab 100644
--- a/packages/client/ui-workspace/README.md
+++ b/packages/client/ui-workspace/README.md
@@ -49,7 +49,7 @@ Collapsed search is one header action beside the view and add actions: activatin
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action forks at the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, and Archive are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
-Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position.
+Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — an "all conversations" action that switches the filter directly; a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
A Session title wider than its row is clipped with an ellipsis at rest. Hovering the row scrolls the title to its far edge — the incremented title of a fork, for example — and reveals it without the ellipsis; leaving the row returns the title to its start.
diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md
index bf16ebfdb9..eaa28029fa 100644
--- a/packages/client/ui-workspace/README.zh.md
+++ b/packages/client/ui-workspace/README.zh.md
@@ -49,7 +49,7 @@ kind: "package-reference"
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 在源会话最后一个已完成轮次处 fork,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
-对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。
+对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。
标题宽于所在行时,静止状态以省略号裁切。把指针停在行上,标题会滚动到远端——例如 fork 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts
index 3e35c52e63..b94557b924 100644
--- a/packages/client/ui-workspace/src/client/locales.ts
+++ b/packages/client/ui-workspace/src/client/locales.ts
@@ -33,6 +33,8 @@ export const zh = {
'sessions.expand': '展开其余 {n} 个会话',
'sessions.collapse': '收起',
'empty.none': '暂无会话',
+ 'empty.noneArchived': '暂无已归档会话',
+ 'empty.viewOthers': '查看其他会话',
'empty.noMatches': '无匹配结果',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
@@ -151,6 +153,8 @@ export const en = {
'sessions.expand': 'Show {n} more sessions',
'sessions.collapse': 'Show less',
'empty.none': 'No sessions yet',
+ 'empty.noneArchived': 'No archived sessions yet',
+ 'empty.viewOthers': 'View other sessions',
'empty.noMatches': 'No matches',
'workspace.add': 'Add workspace',
'search.sessions.aria': 'Search sessions',
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css
index c85844415f..ca428cd908 100644
--- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css
+++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.module.css
@@ -519,6 +519,32 @@
font-size: 13px;
}
+.emptyState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ margin-top: 80px;
+ padding: 0 12px;
+ color: var(--dsw-alias-label-tertiary);
+ font-size: 13px;
+ line-height: 20px;
+}
+
+.emptyState > svg {
+ color: var(--dsw-alias-label-caption);
+}
+
+.emptyAction {
+ padding: 0;
+ border: none;
+ background: none;
+ cursor: pointer;
+ font-size: 13px;
+ line-height: 20px;
+ color: var(--dsw-alias-link);
+}
+
/* Rename dialog form (same figma dialog family as the create modals). */
.renameInput {
box-sizing: border-box;
diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
index 9b64a932e8..d3dee550cb 100644
--- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
+++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx
@@ -17,7 +17,7 @@
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
- Button, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular,
+ Button, IconArchiveCheckOutlineRegular, IconArchiveOffOutlineRegular, IconArchiveOutlineRegular,
IconChevronsUpDownOutlineRegular, IconClockOutlineRegular, IconCloseFillRegular,
IconFlatListOutlineRegular, IconFolderCloseRegular, IconProjectAddOutlineRegular,
IconQueueOutlineRegular, IconSearchOutlineRegular, IconSlidersTwoOutlineRegular,
@@ -246,6 +246,8 @@ type SessionTreeProps = Pick<
setSessionOrder: (accountKey: string, order: readonly string[]) => void
/** Registry-global pin and archive sets plus the archived-visibility choice. */
rowState: SessionRowState
+ /** Switch the archived filter back to the default hide-archived view. */
+ onLeaveArchivedOnly: () => void
/** 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. */
@@ -258,10 +260,26 @@ type SessionTreeProps = Pick<
onSessionRevealed: (sessionId: SessionId) => void
}
+/** The list-empty placeholder — a glyph over the text; the archived-only view names its filter and offers the way back. */
+function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick) {
+ const archivedOnly = rowState.archivedFilter === 'only'
+ return (
+