Merge pull request #5002 from deepseek-harness/session-archieve-refine

feat(client): explicit three-way archived filter with empty-state refinements
This commit is contained in:
Yifffan authored and GitHub committed 2026-09-24 00:18:51 +08:00
commit f032bd2d04
23 files changed
+305 -70

No files matched your search

@@ -1,7 +1,7 @@
- dialog "Stop and archive this session?":
- heading "Stop and archive this session?" [level=2]
- button "Close"
- paragraph: “ARCHIVE_ACTIVE run the long command” 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.
- paragraph: “ARCHIVE_ACTIVE run the long command” 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.
- list "Work that will be stopped":
- listitem: The turn in progress
- listitem: "1 background job: node -e \"const fs=require('node:fs'); fs.writeFileSync('started.txt', 'started'); setInterval(() => { fs.appendFileSync('heartbeat.txt', '.') }, 200); setTimeout(() => { fs.writeFileSync('finished.txt', 'finished') }, 60000)\""
@@ -9,5 +9,6 @@
- menuitem "Last updated"
- separator
- text: Filter sessions
- menuitem "Show archived"
- menuitem "Hide archived"
- menuitem "All conversations (show archived)"
- menuitem "Archived only"
+1 -1
View File
@@ -218,7 +218,7 @@ describe.skipIf(MODE === 'record')('web e2e: archiving a running Session stops i
it('the restored Session continues the conversation from a regular log', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-session-archive-active-restore'))
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await expect.poll(() => sessionRow().count(), { timeout: 10_000 }).toBe(1)
await clickHoverAction(sessionRow(), 'Session actions for ')
await page.getByRole('menuitem', { name: 'Unarchive session' }).click()
+4 -2
View File
@@ -102,7 +102,7 @@ describe('web e2e: archived sessions are restored from the sidebar filter', () =
.toContain(SessionId(SEED_ID))
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await ungroupedSection()
await expect.poll(() => sessionRow.count(), { timeout: 10_000 }).toBe(1)
await clickHoverAction(sessionRow, `Session actions for ${title}`)
@@ -111,8 +111,10 @@ describe('web e2e: archived sessions are restored from the sidebar filter', () =
() => [...scaffold.ctx.workspaceRegistry.archivedSessionIds],
{ timeout: 10_000 },
).toEqual([])
// Back to the default filter: the restored row must be an ordinary row
// again, visible without any archived rows in the view.
await page.getByRole('button', { name: 'View options' }).click()
await page.getByRole('menuitem', { name: 'Show archived', exact: true }).click()
await page.getByRole('menuitem', { name: 'Hide archived', exact: true }).click()
await ungroupedSection()
await expect.poll(() => sessionRow.count(), { timeout: 15_000 }).toBe(1)
@@ -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 <NewChatOutlineArtwork size={size} className={className} strokeWidth={strokeWidth} />
case 'session': return <ChatLinesOutlineArtwork size={size} className={className} strokeWidth={strokeWidth} />
case 'file': return <BrowseOutlineArtwork size={size} className={className} strokeWidth={strokeWidth} />
case 'folder': return <FolderCloseArtwork size={size} className={className} strokeWidth={strokeWidth} />
}
@@ -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) => (
<IconSendOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconQueueOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M5 6.75H11" stroke="currentColor" />
<path d="M5 9H8" stroke="currentColor" />
<path d="M2.37067 11.2497C1.5872 9.89252 1.32042 8.29798 1.61945 6.7597C1.91847 5.22141 2.76317 3.84293 3.99801 2.87809C5.23285 1.91325 6.7747 1.427 8.33964 1.50888C9.90458 1.59076 11.3873 2.23526 12.5147 3.32369C13.6422 4.41232 14.3384 5.8717 14.4751 7.43304C14.6118 8.99438 14.1797 10.5525 13.2585 11.8205C12.3372 13.0885 10.9889 13.9809 9.4617 14.3334C8.18666 14.6277 6.8587 14.529 5.64964 14.0601C5.17095 13.8745 4.76937 13.4929 4.26509 13.3963C3.67389 13.2832 2.95232 13.5595 2.0377 14.3334" stroke="currentColor" />
</svg>
const IconQueueOutlineArtwork = ({ size = 14, ...rest }: WeightedIconProps) => (
<ChatLinesOutlineArtwork size={size} {...rest} />
)
/** Regular one-pixel IconQueueOutline artwork. */
@@ -1574,6 +1571,36 @@ export const IconChevronsUpDownOutlineMedium = (props: IconProps) => (
<IconChevronsUpDownOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
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 (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" strokeWidth={strokeWidth}>
<mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
<rect x="0" y="0" width="16" height="16" fill="white" stroke="none" />
<path d="m2.2 1.3 11.6 12.8" stroke="black" strokeWidth={strokeWidth + 3} />
</mask>
<g mask={`url(#${maskId})`}>
<rect x="1.9" y="2.1" width="12.2" height="3.4" rx="1.1" />
<path d="M2.95 5.7v4.8a2.9 2.9 0 0 0 2.9 2.9h4.3a2.9 2.9 0 0 0 2.9-2.9V5.7" />
</g>
<path d="m2.2 1.3 11.6 12.8" />
</svg>
)
}
/** Regular one-pixel IconArchiveOffOutline artwork. */
export const IconArchiveOffOutlineRegular = (props: IconProps) => (
<IconArchiveOffOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconArchiveOffOutline artwork with a 1.3px stroke. */
export const IconArchiveOffOutlineMedium = (props: IconProps) => (
<IconArchiveOffOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconArchiveCheckOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none"
stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" strokeWidth={strokeWidth}>
@@ -5,7 +5,7 @@ interface WeightedArtworkProps extends IconProps {
}
/**
* Render shared new-conversation geometry for product and reference icons.
* Render new-conversation geometry — the bubble around a plus — for product icons.
* @param props - Size, optional CSS class, and inherited stroke width.
* @returns The decorative SVG artwork.
*/
@@ -17,6 +17,20 @@ export const NewChatOutlineArtwork = ({ size = 16, className, strokeWidth }: Wei
</svg>
)
/**
* 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) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M5 6.75H11" stroke="currentColor" />
<path d="M5 9H8" stroke="currentColor" />
<path d="M2.37067 11.2497C1.5872 9.89252 1.32042 8.29798 1.61945 6.7597C1.91847 5.22141 2.76317 3.84293 3.99801 2.87809C5.23285 1.91325 6.7747 1.427 8.33964 1.50888C9.90458 1.59076 11.3873 2.23526 12.5147 3.32369C13.6422 4.41232 14.3384 5.8717 14.4751 7.43304C14.6118 8.99438 14.1797 10.5525 13.2585 11.8205C12.3372 13.0885 10.9889 13.9809 9.4617 14.3334C8.18666 14.6277 6.8587 14.529 5.64964 14.0601C5.17095 13.8745 4.76937 13.4929 4.26509 13.3963C3.67389 13.2832 2.95232 13.5595 2.0377 14.3334" stroke="currentColor" />
</svg>
)
/**
* Render shared globe geometry for product and link icons.
* @param props - Size, optional CSS class, and inherited stroke width.
@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('product icon set', () => {
it('exports regular and medium variants for all 93 public glyphs', () => {
expect(iconNames.length).toBe(186)
it('exports regular and medium variants for all 94 public glyphs', () => {
expect(iconNames.length).toBe(188)
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 745c8b9cb3858789
zh: fb7fdf5856dfba89
en: d49eb4c9b3158cfe
zh: 251be2fe3d1ac857
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
+1 -1
View File
@@ -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 — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); 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.
+1 -1
View File
@@ -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 递增后的标题——并在揭示时不显示省略号;指针离开后标题回到开头。
@@ -438,10 +438,15 @@ export type SessionArchiveConfirmProps =
& Omit<SessionArchiveConfirmInjected, 'hooks'>
& PropsHooks<SessionArchiveConfirmInjected['hooks']>
/** Props of the row toast entry in `shell.overlay`. */
/**
* Props of the row toast entry in `shell.overlay`. The declared viewing store
* carries the archived filter; the archived notice omits its filter action
* when archived rows are already visible.
*/
export type RowToastProps =
PropsRuntime<'shell.overlay'>
& PropsLocale<'workspace'>
& PropsStore<WorkspaceViewStoreHandle>
& Omit<RowToastInjected, 'hooks'>
& PropsHooks<RowToastInjected['hooks']>
@@ -298,8 +298,10 @@ export function apply(ctx: Context): void {
yield ctx.slots.register({
name: 'shell.overlay', id: 'workspace.session-archive', locale: NS, inject: archiveConfirmInjected,
}, SessionArchiveConfirmDialog)
// The toast shares the browser's viewing store: it reads the archived
// filter to drop the archived notice's filter action once rows are visible.
yield ctx.slots.register({
name: 'shell.overlay', id: 'workspace.row-toast', locale: NS, inject: rowToastInjected,
name: 'shell.overlay', id: 'workspace.row-toast', locale: NS, store: viewStore, inject: rowToastInjected,
}, RowActionToast)
})
ctx.slots.inject('conversation.hero.workspace', () => ctx.slots.register(
@@ -27,11 +27,14 @@ export const zh = {
'orderBy.manual': '手动排序',
'orderBy.updated': '最近更新',
'filterBy.label': '筛选会话',
'viewOptions.showArchived': '显示已归档',
'viewOptions.hideArchived': '隐藏已归档',
'viewOptions.showArchived': '全部对话(显示已归档)',
'viewOptions.onlyArchived': '仅显示已归档',
'sessions.expand': '展开其余 {n} 个会话',
'sessions.collapse': '收起',
'empty.none': '暂无会话',
'empty.noneArchived': '暂无已归档会话',
'empty.viewOthers': '查看其他会话',
'empty.noMatches': '无匹配结果',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
@@ -65,7 +68,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,11 +147,14 @@ 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',
'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',
@@ -182,7 +188,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}',
@@ -84,6 +84,11 @@
color: var(--dsw-alias-label-tertiary);
}
/* The 40% share only protects a snippet; a snippetless match keeps the row. */
.searchResultWorkspace:only-child {
max-width: 100%;
}
.searchResultSnippet {
flex: 1;
min-width: 0;
@@ -519,6 +519,33 @@
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 {
margin-bottom: 4px;
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;
@@ -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, IconArchiveOutlineRegular,
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: <IconClockOutlineRegular /> },
{ 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: <IconArchiveOutlineRegular /> },
{ id: 'hide-archived', label: t('viewOptions.hideArchived'), icon: <IconArchiveOffOutlineRegular /> },
{ id: 'show-archived', label: t('viewOptions.showArchived'), icon: <IconQueueOutlineRegular /> },
{ id: 'only-archived', label: t('viewOptions.onlyArchived'), icon: <IconArchiveCheckOutlineRegular /> },
]}
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"
@@ -247,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. */
@@ -259,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<SessionTreeProps, 'rowState' | 'onLeaveArchivedOnly' | 't'>) {
const archivedOnly = rowState.archivedFilter === 'only'
return (
<div className={css.emptyState} data-row-key="empty">
{archivedOnly ? <IconArchiveOutlineRegular size={24} /> : <IconQueueOutlineRegular size={24} />}
<div>{archivedOnly ? t('empty.noneArchived') : t('empty.none')}</div>
{archivedOnly && (
<button type="button" className={css.emptyAction} onClick={onLeaveArchivedOnly}>
{t('empty.viewOthers')}
</button>
)}
</div>
)
}
/** The scrolling session tree; unmounting drops the sessions subscription and local row limits. */
function SessionTree({
list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds,
rowState,
rowState, onLeaveArchivedOnly,
workspaceReady, animationResetKey, usePanelInfo,
onRenameRequest, onDeleteRequest, onSessionRenameRequest,
renderSlot,
@@ -375,9 +392,13 @@ function SessionTree({
})
}
const childrenByParent = useMemo(() => {
const rendered = new Set(groups.map(group => group.key))
const children = new Map<string | undefined, GroupNode[]>()
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)
@@ -594,7 +615,7 @@ function SessionTree({
resetKey={JSON.stringify([animationResetKey, sessionLimits])}
>
{groups.length === 0 && (
<div className={css.empty} data-row-key="empty">{t('empty.none')}</div>
<EmptySessions rowState={rowState} onLeaveArchivedOnly={onLeaveArchivedOnly} t={t} />
)}
{groupRows}
</AnimatedRows>
@@ -605,7 +626,7 @@ function SessionTree({
/** The flat "In one list" body: every session is one draggable top-level row. */
function FlatList({
list, sessionIds, rowState, useSessionStatus, open, onSessionRenameRequest,
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest,
usePanelInfo, setSessionOrder, workspaceReady, animationResetKey,
revealSessionId, onSessionRevealed, renderSlot, t,
}: Pick<
@@ -621,6 +642,7 @@ function FlatList({
| 'revealSessionId'
| 'onSessionRevealed'
| 'rowState'
| 'onLeaveArchivedOnly'
| 't'
> & {
list: SessionListState
@@ -656,7 +678,7 @@ function FlatList({
resetKey={animationResetKey}
>
{rows.length === 0 && (
<div className={css.empty} data-row-key="empty">{t('empty.none')}</div>
<EmptySessions rowState={rowState} onLeaveArchivedOnly={onLeaveArchivedOnly} t={t} />
)}
{rows.map((node) => {
const active = drag !== null && drag.pinned === node.pinned
@@ -875,6 +897,7 @@ export function WorkspaceBrowser({
}
open(sessionId)
}
const leaveArchivedOnly = (): void => { actions.setArchivedFilter('default') }
const workspaceReady = workspacePhase === 'ready' && workspaceStreamState !== 'loading'
const mainSessionId = Object.values(list.byId)
.find(session => (session.retainedBy.mainView ?? 0) > 0)?.id
@@ -1327,6 +1350,7 @@ export function WorkspaceBrowser({
list={list}
sessionIds={orderedFlatSessionIds}
rowState={rowState}
onLeaveArchivedOnly={leaveArchivedOnly}
workspaceReady={workspaceReady}
animationResetKey={`${groupBy}/${orderBy}/${archivedFilter}`}
useSessionStatus={useSessionStatus}
@@ -1356,6 +1380,7 @@ export function WorkspaceBrowser({
setGroupExpanded={actions.setGroupExpanded}
setSessionOrder={saveSessionOrder}
rowState={rowState}
onLeaveArchivedOnly={leaveArchivedOnly}
startSession={startSession}
open={guardedOpen}
insertWorkspaceBefore={insertWorkspaceBefore}
@@ -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 hook, the shared viewing store, 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, useStore, dismissToast, undoArchive, showArchived, t }: RowToastProps) {
const toast = useToast(current => current)
const archivedRowsVisible = useStore(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}
/>
@@ -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}).
@@ -251,13 +251,17 @@ describe('ui-workspace apply', () => {
['workspace.session-archive', undefined, SessionArchiveConfirmDialog, 'workspace'],
['workspace.row-toast', undefined, RowActionToast, 'workspace'],
])
// Only the browser declares the viewing store; its handle hands out the
// one instance the injected callbacks write view state through. The
// row actions and overlay surfaces declare none.
// The browser and the row toast declare the same viewing-store handle,
// which hands out one instance: the browser's injected callbacks write
// view state through it and the toast reads the archived filter from it.
// The row actions and the other overlay surfaces declare none.
const browser = b.slots.entries('sidebar.workspaces')[0]!
expect(browser.store).toBeDefined()
expect(viewInstance(b.slots)).toBe(viewInstance(b.slots))
const rowToastEntry = entry(b.slots, 'shell.overlay', 'workspace.row-toast')
expect(rowToastEntry.store).toBe(browser.store)
for (const registration of [...b.slots.entries(MENU_ITEM), ...b.slots.entries(ROW_ACTION), ...b.slots.entries('shell.overlay')]) {
if (registration === rowToastEntry) continue
expect(registration.store).toBeUndefined()
}
// Each share raises its own notices inside its callbacks.
@@ -30,6 +30,7 @@ import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.t
import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx'
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { en, zh } from '../src/client/locales.ts'
import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
import type { ShortcutCommandId } from '@deepseek-ai/dsh-client-shortcuts/client'
@@ -489,8 +490,15 @@ 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(viewState: { archivedFilter?: 'default' | 'show' | 'only' } = { archivedFilter: 'default' }) {
const toast = createSnapshotStore<RowToastState | null>(null)
const instance = createWorkspaceViewStore().create()
// A v5 snapshot hydrates without the filter key; mirror it by dropping the
// fresh store's default rather than writing an explicit undefined.
const state = { ...instance.store.getSnapshot() }
if (viewState.archivedFilter === undefined) delete state.archivedFilter
else state.archivedFilter = viewState.archivedFilter
const view = createSnapshotStore(state)
const dismissToast = vi.fn(() => { toast.set(null) })
const undoArchive = vi.fn()
const showArchived = vi.fn()
@@ -498,6 +506,8 @@ describe('RowActionToast', () => {
<RowActionToast
{...overlay}
useToast={bindSnapshotSelector(toast)}
useStore={bindSnapshotSelector(view)}
actions={instance.actions}
dismissToast={dismissToast}
undoArchive={undoArchive}
showArchived={showArchived}
@@ -544,6 +554,20 @@ describe('RowActionToast', () => {
expect(screen.queryByRole('alert')).toBeNull()
})
it('treats a view without a persisted filter as the hidden default and keeps the filter action', () => {
const { notify } = toastSurface({})
notify({ kind: 'archived', sessionId: sid('one') })
expect(screen.getByRole('alert').textContent).toBe('会话已归档,可撤销或筛选已归档会话')
})
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', '取消置顶失败,请稍后重试'],
@@ -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)
@@ -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,88 @@ 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('the empty 仅显示已归档 view names its filter and offers the way back', () => {
const b = mount({
useSessions: hook(sessionState([summary('kept', 2)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['kept'])])),
})
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '仅显示已归档' }))
expect(screen.getByText('暂无已归档会话')).toBeTruthy()
expect(screen.queryByText('暂无会话')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '查看其他会话' }))
expect(b.store.getSnapshot().archivedFilter).toBe('default')
fireEvent.click(screen.getByText('alpha'))
expect(screen.getByText('kept')).toBeTruthy()
expect(screen.queryByText('暂无已归档会话')).toBeNull()
})
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<HTMLElement>('[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 +609,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 +1216,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() })