feat(ui-workspace): reorder ungrouped sessions

This commit is contained in:
_Kerman
2026-08-11 19:14:57 +08:00
parent 94897de821
commit 5bb70d7cf2
10 changed files with 156 additions and 43 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
README.md: 3308044abe32ba36d45b02ddacba93772265d315
README.zh.md: 38ccb89e0a1fa03ce2d2bab08e7605d8c60f2472
README.md: 98edb22dd173d8d6f119a4494baeb7e16cec98f6
README.zh.md: 4f0a8f1950889bb9247899ea14d062bd0ee07c4d

View File

@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
Shared Workspace browser and picker plugin. `WorkspaceBrowser` fills the sidebar's `sidebar.workspaces` slot, while `WorkspacePicker` fills the page-local Session Intent hero's `conversation.hero.workspace` slot; both surfaces use the same Workspace menu and add flow.
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus in-Workspace Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped. View options combine grouping with one browser-persisted Session order: entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags also update the Host Workspace account. Workspace drag order is Host-durable in either Session order mode.
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus grouped Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped. View options combine grouping with one browser-persisted Session order per group: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped initializes from recency. Entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped order remains browser-local because no Workspace owns those Sessions. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.

View File

@@ -4,7 +4,7 @@
共享 Workspace 浏览器与选择器插件。`WorkspaceBrowser` 填充侧边栏的 `sidebar.workspaces` slot`WorkspacePicker` 则填充页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot两个界面使用同一套 Workspace 菜单和添加流程。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session打开后默认显示五条 Session其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id 和 Ungrouped。视图选项把分组方式和一份浏览器持久化 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及分组内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session打开后默认显示五条 Session其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id 和 Ungrouped。视图选项把分组方式和每个分组各自的一份浏览器持久化 Session 顺序放在一起:真实 Workspace 从 `WorkspaceView.sessionIds` 初始化Ungrouped 则从最近更新时间顺序初始化。进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序;真实 Workspace 在手动模式下的拖拽还会更新 Host Session 记账,而 Ungrouped 因没有 Workspace 持有其中的 Session其顺序始终只保存在浏览器本地。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。

View File

@@ -130,7 +130,8 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
/** In-flight root-row drag: source identity plus the current insert marker. */
interface DragState {
workspaceId: WorkspaceId
/** Workspace id, or {@link UNGROUPED_KEY} for the browser-local loose-session account. */
workspaceKey: string
sessionId: SessionNode['id']
/** Row the marker sits on and which half (insert above/below it). */
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
@@ -227,13 +228,22 @@ function SessionTree({
() => Object.entries(workspaceExpansion).filter(([, expanded]) => expanded).map(([key]) => key),
[workspaceExpansion],
)
const ungroupedSessionIds = useMemo(() => {
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
}, [list, workspaces])
useEffect(() => {
if (list.phase !== 'ready') return
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
previousOrderBy.current = orderBy
for (const workspace of workspaces) {
const key = workspace.workspaceId as string
const sessionIds = workspace.sessionIds.filter(id => list.byId[id] !== undefined)
const accounts = [
...workspaces.map(workspace => ({
key: workspace.workspaceId as string,
sessionIds: workspace.sessionIds.filter(id => list.byId[id] !== undefined),
})),
{ key: UNGROUPED_KEY, sessionIds: ungroupedSessionIds },
]
for (const { key, sessionIds } of accounts) {
const previousOrder = recentSessionOrder[key]
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
@@ -266,7 +276,7 @@ function SessionTree({
syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt)
}
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces])
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
const orderedWorkspaces = useMemo(() => {
return workspaces.map((workspace) => {
const stored = recentSessionOrder[workspace.workspaceId as string]
@@ -274,16 +284,25 @@ function SessionTree({
return { ...workspace, sessionIds }
})
}, [recentSessionOrder, workspaces])
const orderedUngroupedSessionIds = useMemo(
() => reconciledSessionOrder(ungroupedSessionIds, recentSessionOrder[UNGROUPED_KEY]),
[recentSessionOrder, ungroupedSessionIds],
)
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects],
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
expandedProjects,
...(recentSessionOrder[UNGROUPED_KEY] === undefined
? {}
: { ungroupedOrder: recentSessionOrder[UNGROUPED_KEY] }),
}),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects, recentSessionOrder],
)
const now = Date.now()
const commitSessionDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (sessionDropCommitted.current) return
sessionDropCommitted.current = true
setDrag(null)
const group = groups.find(candidate => candidate.workspaceId === activeDrag.workspaceId)
const group = groups.find(candidate => candidate.key === activeDrag.workspaceKey)
if (group === undefined) return
const targetIndex = group.sessions.findIndex(session => session.id === over.id)
if (targetIndex === -1) return
@@ -294,14 +313,16 @@ function SessionTree({
? group.sessions.length
: group.sessions.findIndex(session => session.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
const account = orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceId)
if (account === undefined) return
const nextOrder = account.sessionIds.filter(id => id !== activeDrag.sessionId)
const accountSessionIds = activeDrag.workspaceKey === UNGROUPED_KEY
? orderedUngroupedSessionIds
: orderedWorkspaces.find(workspace => workspace.workspaceId === activeDrag.workspaceKey)?.sessionIds
if (accountSessionIds === undefined) return
const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
setRecentSessionOrder(activeDrag.workspaceId, nextOrder.map(id => id as string))
if (orderBy === 'updated') return
insertSessionBefore(activeDrag.workspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
setRecentSessionOrder(activeDrag.workspaceKey, nextOrder.map(id => id as string))
if (orderBy === 'updated' || activeDrag.workspaceKey === UNGROUPED_KEY) return
insertSessionBefore(activeDrag.workspaceKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
})
}
@@ -427,15 +448,13 @@ function SessionTree({
? group.sessions
: group.sessions.slice(0, COLLAPSED_SESSION_LIMIT)
).map((node) => {
// Draggable: real-workspace session rows. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
// Session drag never leaves its group. Ungrouped writes only the
// browser-local account; real Workspaces may also write Host order.
const sameGroupDrag = drag !== null && drag.workspaceKey === group.key
const dragProps = {
start: () => {
sessionDropCommitted.current = false
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
setDrag({ workspaceKey: group.key, sessionId: node.id, over: null })
},
active: sameGroupDrag,
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,

View File

@@ -78,6 +78,8 @@ export interface SearchResultSet {
/** Viewing state consumed by the derivation. */
export interface TreeView {
expandedProjects: readonly string[]
/** Browser-local order for Sessions without a backing Workspace account. */
ungroupedOrder?: readonly string[]
}
interface Group {
@@ -139,21 +141,41 @@ function buildGroup(
order: 'account' | 'recency',
): Group {
const sessions = [...members]
// Workspace order is the caller-selected sessionIds; only Ungrouped lacks
// an account order and therefore falls back to recency.
// Real Workspace order comes from sessionIds. Ungrouped falls back to
// recency until the browser supplies its persisted local order.
if (order === 'recency') sessions.sort(byRecency)
return { key, workspaceId, cwd, createdAt, label, sessions }
}
/** Apply a stored Ungrouped order and append newly loose Sessions by recency. */
function orderedUngrouped(members: readonly SessionSummary[], stored: readonly string[]): SessionSummary[] {
const byId = new Map(members.map(session => [session.id as string, session]))
const included = new Set<string>()
const ordered: SessionSummary[] = []
for (const key of stored) {
const session = byId.get(key)
if (session === undefined || included.has(key)) continue
ordered.push(session)
included.add(key)
}
for (const session of [...members].sort(byRecency)) {
if (included.has(session.id)) continue
ordered.push(session)
}
return ordered
}
/**
* Group Sessions by Host Workspace: one group per entity in stable Host
* order, with members resolved from sessionIds in their stored order. Sessions
* outside every Workspace trail in the recency-ordered Ungrouped bucket.
* outside every Workspace trail in the browser-local Ungrouped order, which
* falls back to recency before that order is initialized.
*/
function groupByWorkspace(
list: SessionListState,
workspaces: readonly WorkspaceView[],
archived: ReadonlySet<SessionId>,
ungroupedOrder: readonly string[] | undefined,
): Group[] {
const groups: Group[] = []
const accounted = new Set<SessionId>()
@@ -176,7 +198,15 @@ function groupByWorkspace(
.filter((s): s is SessionSummary =>
s !== undefined && !accounted.has(s.id) && sessionVisible(s, list.current, archived))
if (stray.length > 0) {
groups.push(buildGroup(UNGROUPED_KEY, undefined, undefined, undefined, UNGROUPED_LABEL, stray, 'recency'))
groups.push(buildGroup(
UNGROUPED_KEY,
undefined,
undefined,
undefined,
UNGROUPED_LABEL,
ungroupedOrder === undefined ? stray : orderedUngrouped(stray, ungroupedOrder),
ungroupedOrder === undefined ? 'recency' : 'account',
))
}
return groups
}
@@ -225,7 +255,7 @@ export function deriveGroups(
: (workspaces.find(w => w.sessionIds.includes(list.current as SessionId))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
const groups: GroupNode[] = []
for (const g of groupByWorkspace(list, workspaces, archived)) {
for (const g of groupByWorkspace(list, workspaces, archived, view.ungroupedOrder)) {
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,

View File

@@ -24,8 +24,9 @@ const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const view = (expandedProjects: readonly string[] = []) => ({
const view = (expandedProjects: readonly string[] = [], ungroupedOrder?: readonly string[]) => ({
expandedProjects,
...(ungroupedOrder === undefined ? {} : { ungroupedOrder }),
})
const noArchive: readonly SessionId[] = []
const archived = (...ids: string[]): readonly SessionId[] => ids.map(sid)
@@ -54,6 +55,19 @@ describe('deriveGroups', () => {
expect(groups[1]!.sessions.map(session => session.id)).toEqual([sid('loose')])
})
it('applies stored Ungrouped order and appends new loose Sessions by recency', () => {
const sessions = list(summary('one', 3), summary('two', 2), summary('new', 4))
const groups = deriveGroups(
sessions,
[],
noArchive,
view([UNGROUPED_KEY], ['two', 'stale', 'two']),
)
expect(groups[0]!.sessions.map(session => session.id)).toEqual([
sid('two'), sid('new'), sid('one'),
])
})
it('shows only the current blank session in its Workspace count and tree', () => {
const currentBlank = { ...summary('current-blank', 5), blank: true }
const staleBlank = { ...summary('stale-blank', 4), blank: true }

View File

@@ -9,6 +9,7 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { zh } from '../src/client/locales.ts'
@@ -111,8 +112,8 @@ describe('WorkspaceBrowser', () => {
rerender(b, { useWorkspaces: hook(workspaceState([])) })
await waitFor(() => {
expect(b.store.getSnapshot().workspaceExpansion).toEqual({})
expect(b.store.getSnapshot().recentSessionOrder).toEqual({})
expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({})
expect(b.store.getSnapshot().recentSessionOrder).toEqual({ [UNGROUPED_KEY]: [] })
expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({ [UNGROUPED_KEY]: {} })
})
})
@@ -776,6 +777,55 @@ describe('WorkspaceBrowser', () => {
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
})
it('persists Ungrouped drag order in both modes without writing a Host Workspace account', async () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('未分组'))
const dragAfter = (sourceTitle: string, targetTitle: string): void => {
const source = screen.getByText(sourceTitle).closest('[role="treeitem"]') as HTMLElement
const target = screen.getByText(targetTitle).closest('[role="treeitem"]') as HTMLElement
target.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
})
fireEvent.dragStart(source, { dataTransfer: dragData() })
fireDrag(target, 'drop', 180)
}
dragAfter('one', 'three')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
dragAfter('two', 'one')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
expect(insertSessionBefore).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['one', 'two', 'three'])
})
dragAfter('one', 'three')
expect(b.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(insertSessionBefore).not.toHaveBeenCalled()
b.view.unmount()
const restored = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([])),
insertSessionBefore,
})
expect(restored.store.getSnapshot().recentSessionOrder[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
expect.stringContaining('two'),
expect.stringContaining('three'),
expect.stringContaining('one'),
])
})
it('still sends the reorder when the dragged row left the group mid-drag', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 2), summary('two', 1)])