feat(ui-workspace): archive session from the row menu

The visual-only Delete session placeholder becomes a wired Archive
session action: no confirmation dialog (non-destructive), failures stay
console diagnostics. tree.ts hides archived sessions in every
derivation (workspace groups, Ungrouped, search, flat list) through the
sessionVisible predicate. The workspace-management e2e pins the archive
round trip across reload.
This commit is contained in:
imccyu
2026-07-31 02:40:39 +08:00
committed by imccyu
parent c764ed7e64
commit e235be2bac
14 changed files with 261 additions and 66 deletions
@@ -102,18 +102,22 @@ type SessionTreeProps = Pick<
'useSessions' | 'startSession' | 'open' | 'forkSession' | 'insertSessionBefore' | 't'
> & {
workspaces: readonly WorkspaceView[]
/** Registry-global archive set (hidden rows). */
archivedSessionIds: readonly SessionNode['id'][]
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned delete-confirmation dialog for a real Workspace group. */
onDeleteRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
/** Open the browser-owned session rename dialog. */
onSessionRename: (sessionId: SessionNode['id'], currentTitle: string) => void
/** Archive a session (row menu action; the row disappears on the state echo). */
onSessionArchive: (sessionId: SessionNode['id']) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({
useSessions, startSession, open, forkSession, workspaces,
onRenameRequest, onDeleteRequest, onSessionRename, insertSessionBefore, t,
useSessions, startSession, open, forkSession, workspaces, archivedSessionIds,
onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertSessionBefore, t,
}: SessionTreeProps) {
const list = useSessions(s => s)
const current = list.current
@@ -129,8 +133,8 @@ function SessionTree({
setExpandedProjects(l => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects }),
[list, workspaces, expandedProjects],
() => deriveGroups(list, workspaces, archivedSessionIds, { expandedProjects }),
[list, workspaces, archivedSessionIds, expandedProjects],
)
const now = Date.now()
@@ -209,6 +213,7 @@ function SessionTree({
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
onArchive={onSessionArchive}
drag={dragProps}
t={t}
/>
@@ -223,9 +228,11 @@ function SessionTree({
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 't'>) {
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
>) {
const list = useSessions(s => s)
const rows = useMemo(() => deriveFlat(list), [list])
const rows = useMemo(() => deriveFlat(list, archivedSessionIds), [list, archivedSessionIds])
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
@@ -242,6 +249,7 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, t }: Pick<S
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
onArchive={onSessionArchive}
t={t}
/>
))}
@@ -263,12 +271,14 @@ function SearchResults({
useSessions,
open,
workspaces,
archivedSessionIds,
query,
remote,
resultLimit,
t,
}: Pick<SessionTreeProps, 'useSessions' | 'open' | 't'> & {
workspaces: readonly WorkspaceView[]
archivedSessionIds: readonly SessionNode['id'][]
query: string
remote: RemoteSearchState
resultLimit: number
@@ -278,8 +288,8 @@ function SearchResults({
? remote
: { query, status: 'loading' as const, items: [], hasMore: false }
const results = useMemo(
() => deriveSearchResults(list, workspaces, query, currentRemote, resultLimit),
[list, workspaces, query, currentRemote, resultLimit],
() => deriveSearchResults(list, workspaces, query, archivedSessionIds, currentRemote, resultLimit),
[list, workspaces, query, archivedSessionIds, currentRemote, resultLimit],
)
const pending = currentRemote.status === 'loading'
const failed = currentRemote.status === 'error'
@@ -337,6 +347,7 @@ export function WorkspaceBrowser({
forkSession,
renameWorkspace,
deleteWorkspace,
archiveSession,
insertSessionBefore,
createWorkspace,
searchSessions,
@@ -346,6 +357,7 @@ export function WorkspaceBrowser({
t,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
const groupBy = useStore(s => s.groupBy)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
@@ -475,6 +487,16 @@ export function WorkspaceBrowser({
setSessionRenameError(null)
}
// Archive is dialog-free: not destructive (the log and the accounting slot
// remain), so the menu action commits directly; the row disappears when the
// archive-set echo lands. Failures are non-fatal console diagnostics, the
// same posture as reorder rejections.
const onSessionArchive = (sessionId: SessionNode['id']) => {
archiveSession(sessionId).catch((reason: unknown) => {
console.warn('session archive rejected:', reason)
})
}
// Delete dialog is separate from the row so a successful removal can
// unmount that row without tearing down the in-flight confirmation state.
const [deleteTarget, setDeleteTarget] = useState<{ workspaceId: WorkspaceId; title: string } | null>(null)
@@ -597,6 +619,7 @@ export function WorkspaceBrowser({
useSessions={useSessions}
open={open}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
query={normalizedQuery}
remote={remoteSearch}
resultLimit={searchResultLimit}
@@ -607,15 +630,18 @@ export function WorkspaceBrowser({
? (
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} t={t}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds} t={t}
/>
)
: (
<SessionTree
useSessions={useSessions}
onSessionRename={onSessionRename}
onSessionArchive={onSessionArchive}
forkSession={forkSession}
workspaces={workspaces}
archivedSessionIds={archivedSessionIds}
startSession={startSession}
open={open}
insertSessionBefore={insertSessionBefore}
@@ -113,6 +113,12 @@ export type WorkspaceBrowserInjected = DirectoryPickingInjected & {
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/** Delete only a Host Workspace registration; directory and Session logs remain. */
deleteWorkspace: (workspaceId: WorkspaceId) => Promise<void>
/**
* Archive a Session into the registry-global set: hidden from grouping
* surfaces, log and accounting slot retained. Archiving the current
* session clears the selection into the New Session view state.
*/
archiveSession: (sessionId: SessionId) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
@@ -92,6 +92,7 @@ export function apply(ctx: ClientContext): void {
},
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
deleteWorkspace: async (workspaceId) => { await ctx.workspaces.delete(workspaceId) },
archiveSession: async (sessionId) => { await ctx.workspaces.archiveSession(sessionId) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
@@ -45,7 +45,7 @@ export const zh = {
'delete.desc': '将把“{name}”从工作区列表中移除。文件夹与会话记录会保留,其会话将显示在“未分组”下。',
'delete.pending': '正在删除工作区…',
'menu.fork': '分叉会话',
'menu.deleteSession': '删除会话',
'menu.archiveSession': '归档会话',
'sessions.count.one': '{n} 个会话',
'sessions.count.other': '{n} 个会话',
'actions.workspace.aria': '工作区“{name}”的操作',
@@ -108,7 +108,7 @@ export const en = {
'delete.desc': 'This removes “{name}” from the workspace list. The folder and session logs will be kept. Its sessions will appear under Ungrouped.',
'delete.pending': 'Deleting workspace…',
'menu.fork': 'Fork session',
'menu.deleteSession': 'Delete session',
'menu.archiveSession': 'Archive session',
'sessions.count.one': '{n} session',
'sessions.count.other': '{n} sessions',
'actions.workspace.aria': 'Workspace actions for {name}',
@@ -2,14 +2,14 @@
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename/Delete and session Rename/Fork; the session and
* workspace hover cards are suppressed while a menu is open.
* except workspace Rename/Delete and session Rename/Fork/Archive; the session
* and workspace hover cards are suppressed while a menu is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
import {
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
HoverCard, IconBranchOutline16, IconDownloadOutline16, IconEditOutline16,
IconEllipsisOutline16, IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceBrowserProps } from '../contract/slots.ts'
@@ -243,7 +243,7 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, drag, t }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
node: SessionNode
currentId: string | undefined
now: number
@@ -252,6 +252,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
onRename: (id: SessionNode['id'], currentTitle: string) => void
/** Fork a session at its last completed turn (row menu action). */
onFork: (id: SessionNode['id']) => void
/** Archive this session (row menu action; commits without a dialog). */
onArchive: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
t: RowTranslate
@@ -260,10 +262,13 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const title = displayTitle(node, t)
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
// Archive replaces the former Delete placeholder: it hides the row through
// the registry-global archive set and never touches the session log, so it
// is not styled as destructive and needs no confirmation dialog.
const sessionMenuItems = [
{ id: 'rename', label: t('rename'), icon: <IconEditOutline16 /> },
{ id: 'fork', label: t('menu.fork'), icon: <IconBranchOutline16 /> },
{ id: 'delete', label: t('menu.deleteSession'), icon: <IconTrashOutline16 />, danger: true },
{ id: 'archive', label: t('menu.archiveSession'), icon: <IconDownloadOutline16 /> },
]
// Figma session cell: pad 8, status slot 16, then a 4px title gap.
const ownRow = (
@@ -310,7 +315,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename(node.id, row.title)
if (id === 'fork') onFork(node.id) // delete stays visual-only.
if (id === 'fork') onFork(node.id)
if (id === 'archive') onArchive(node.id)
}}
portal
closeOnPointerLeave
+29 -12
View File
@@ -90,9 +90,13 @@ function byRecency(a: SessionSummary, b: SessionSummary): number {
return a.id < b.id ? -1 : 1
}
/** Ordinary sessions are visible; among blank sessions, only the current one is visible. */
function sessionVisible(session: SessionSummary, current: SessionId | undefined): boolean {
return !session.blank || session.id === current
/**
* Ordinary sessions are visible; among blank sessions, only the current one
* is visible; archived sessions are visible nowhere (their accounting slots
* remain, so unarchiving restores position).
*/
function sessionVisible(session: SessionSummary, current: SessionId | undefined, archived: ReadonlySet<SessionId>): boolean {
return !archived.has(session.id) && (!session.blank || session.id === current)
}
/**
@@ -126,7 +130,11 @@ function buildGroup(
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
*/
function groupByWorkspace(list: SessionListState, workspaces: readonly WorkspaceView[]): Group[] {
function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
for (const workspace of workspaces) {
@@ -135,7 +143,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const summary = list.byId[id]
if (summary === undefined) continue // account may lead the list pull; the row appears when the summary lands
accounted.add(id)
if (!sessionVisible(summary, list.current)) continue
if (!sessionVisible(summary, list.current, archived)) continue
members.push(summary)
}
groups.push(buildGroup(
@@ -146,7 +154,7 @@ function groupByWorkspace(list: SessionListState, workspaces: readonly Workspace
const stray = list.ids
.map(id => list.byId[id])
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current))
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
}
@@ -168,25 +176,29 @@ function sessionNode(s: SessionSummary): SessionNode {
*
* Every group shows; sessions populate under expanded groups, preserving
* Host account order. Blank sessions are excluded except for the selected
* provisional New Session row. Content search lives outside this derivation
* provisional New Session row; archived sessions are excluded everywhere.
* Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot (`current` feeds containsCurrent).
* @param workspaces - real workspaces in stable Host order.
* @param archivedSessionIds - registry-global archive set.
* @param view - local expansion arrays.
* @returns group sections in render order.
*/
export function deriveGroups(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archivedSessionIds: readonly SessionId[],
view: TreeView,
): GroupNode[] {
const archived = new Set(archivedSessionIds)
const expandedProjects = new Set(view.expandedProjects)
const currentGroup = list.current === undefined
? undefined
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces)) {
for (const g of groupByWorkspace(list, workspaces, archived)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
@@ -209,13 +221,15 @@ export function deriveGroups(
* no parent/child adjacency. Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* @param list - sessions list snapshot.
* @param archivedSessionIds - registry-global archive set.
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState): SessionNode[] {
export function deriveFlat(list: SessionListState, archivedSessionIds: readonly SessionId[]): SessionNode[] {
const archived = new Set(archivedSessionIds)
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined || !sessionVisible(s, list.current)) continue
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
rows.push(s)
}
rows.sort(byRecency)
@@ -238,6 +252,7 @@ export interface RelativeTime {
* @param list - session metadata authority.
* @param workspaces - Workspace membership and display labels.
* @param query - caller text; surrounding whitespace is ignored.
* @param archivedSessionIds - registry-global archive set (members never match).
* @param content - ranked Host content-search page.
* @param limit - protocol-owned maximum merged row count.
* @returns bounded deduplicated flat rows and a refine-query hint bit.
@@ -246,11 +261,13 @@ export function deriveSearchResults(
list: SessionListState,
workspaces: readonly WorkspaceView[],
query: string,
archivedSessionIds: readonly SessionId[],
content: { items: readonly SessionSearchResultItem[]; hasMore: boolean },
limit: number,
): SearchResultSet {
const q = query.trim().toLowerCase()
if (q === '') return { items: [], hasMore: false }
const archived = new Set(archivedSessionIds)
const workspaceBySession = new Map<SessionId, string>()
for (const workspace of workspaces) {
@@ -270,7 +287,7 @@ export function deriveSearchResults(
const summary = list.byId[id]
// Blank placeholders never match a query (their canonical title displays
// localized, so matching it would tie search to one language).
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current)) continue
if (summary === undefined || summary.blank || !sessionVisible(summary, list.current, archived)) continue
if (
sessionTitle(summary).toLowerCase().includes(q)
|| labelOf(summary).toLowerCase().includes(q)
@@ -290,7 +307,7 @@ export function deriveSearchResults(
for (const summary of local) include(summary)
for (const item of content.items) {
const summary = list.byId[item.sessionId]
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current)) include(summary)
if (summary !== undefined && !summary.blank && sessionVisible(summary, list.current, archived)) include(summary)
}
return {