fix(client): refine workspace view controls

This commit is contained in:
_Kerman
2026-08-11 16:46:52 +08:00
parent 34e90dc3fe
commit 5b1da441d5
7 changed files with 77 additions and 42 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: f54b8b0b2070c81089be1703b536492522d38774
README.zh.md: b1977e7f6a67fa7bcb6751d7b214cfff4bfdbb4f
README.md: 24d73beba56f527eb57276accb2693255bad9020
README.zh.md: 85a5ce9210dd084e6e6e0746eb19aac12d98d54d

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. View options combine grouping with Session order: **Manual** follows the Host Workspace account, while **Last updated** keeps a browser-local editable order and moves a Session to the front whenever a newer `updatedAt` arrives. 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 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. 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.
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 an empty query, 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 后会恢复为五条。视图选项把分组方式和 Session 顺序放在一起:**手动排序**遵循 Host Workspace 记账顺序,**最近更新**则维护可编辑的浏览器本地顺序,并在收到更大的 `updatedAt` 时把该 Session 移到首位。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session打开后默认显示五条 Session其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序Workspace 拖拽顺序都由 Host 持久化。
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起空查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。

View File

@@ -118,11 +118,11 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
// be cut off at the header's bounds.
portal
anchor={(
<Tooltip label={t('groupBy.label')} side="bottom" delayMs={500}>
<Tooltip label={t('viewOptions.label')} side="bottom" delayMs={500}>
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label={t('groupBy.label')}
aria-label={t('viewOptions.label')}
onClick={() => { setOpen(v => !v) }}
>
<IconPersonalizationOutline16 />
@@ -163,13 +163,13 @@ type SessionTreeProps = Pick<
workspaceExpansion: Readonly<Record<string, boolean>>
/** Persist one Workspace group's zero-or-five-session state. */
setWorkspaceExpanded: (key: string, expanded: boolean) => void
/** Editable orders used by recent-update mode. */
/** Shared editable orders used by both Session order modes. */
recentSessionOrder: Readonly<Record<string, readonly string[]>>
/** Last update timestamps observed by recent-update mode. */
/** Last update timestamps observed for one-time recent-update promotions. */
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
/** Replace one recent-mode order and its observed timestamps. */
/** Replace one shared order and its observed timestamps. */
syncRecentSessions: (workspaceKey: string, order: string[], updatedAt: Record<string, number>) => void
/** Apply a manual drag inside one recent-mode order. */
/** Apply a drag to one shared order. */
setRecentSessionOrder: (workspaceKey: string, order: string[]) => void
/** Registry-global archive set (hidden rows). */
archivedSessionIds: readonly SessionNode['id'][]
@@ -181,7 +181,7 @@ type SessionTreeProps = Pick<
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
/** Session visual order; manual mode drags durable order, updated mode drags its view order. */
/** Session order behavior: fixed after edits, or additionally promoted by user activity. */
orderBy: SessionOrderBy
}
@@ -201,6 +201,7 @@ function SessionTree({
const sessionDropCommitted = useRef(false)
const [workspaceDrag, setWorkspaceDrag] = useState<WorkspaceDragState | null>(null)
const workspaceDropCommitted = useRef(false)
const previousOrderBy = useRef(orderBy)
const nativeDragActive = drag !== null || workspaceDrag !== null
useEffect(() => {
if (!nativeDragActive) return
@@ -232,16 +233,18 @@ function SessionTree({
[workspaceExpansion],
)
useEffect(() => {
if (orderBy !== 'updated' || list.phase !== 'ready') return
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 previousOrder = recentSessionOrder[key]
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
if (previousOrder === undefined) {
if (orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated)) {
nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId))
} else {
} else if (orderBy === 'updated') {
const promoted = sessionIds
.filter((id) => {
const session = list.byId[id]
@@ -270,14 +273,12 @@ function SessionTree({
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, workspaces])
const orderedWorkspaces = useMemo(() => {
if (orderBy !== 'updated') return workspaces
return workspaces.map((workspace) => {
const stored = recentSessionOrder[workspace.workspaceId as string]
const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
if (stored === undefined) sessionIds.sort((a, b) => compareSessionRecency(a, b, list.byId))
return { ...workspace, sessionIds }
})
}, [list.byId, orderBy, recentSessionOrder, workspaces])
}, [recentSessionOrder, workspaces])
const groups = useMemo(
() => deriveGroups(list, orderedWorkspaces, archivedSessionIds, { expandedProjects }, 'manual'),
[list, orderedWorkspaces, archivedSessionIds, expandedProjects],
@@ -846,7 +847,7 @@ export function WorkspaceBrowser({
searchInput.current?.focus()
}}
>
<Tooltip label={t('search')} disabled={searchExpanded}>
<Tooltip label={t('search')} side="bottom" delayMs={500} disabled={searchExpanded}>
<button
type="button"
className={css.searchButton}

View File

@@ -10,6 +10,7 @@ export const zh = {
'session.new': '新会话',
'section.workspaces': '工作区',
'section.sessions': '会话',
'viewOptions.label': '视图选项',
'groupBy.label': '分组方式',
'groupBy.workspace': '按工作区',
'groupBy.flat': '单列表',
@@ -22,7 +23,7 @@ export const zh = {
'empty.noMatches': '无匹配结果',
'workspace.add': '添加工作区',
'search.sessions.aria': '搜索会话',
'search.placeholder': '搜索名称、关键词…',
'search.placeholder': '搜索会话…',
'search.clear': '清除搜索',
'search.results.aria': '搜索结果',
'search.pending': '正在搜索会话历史…',
@@ -78,6 +79,7 @@ export const en = {
'session.new': 'New Session',
'section.workspaces': 'Workspaces',
'section.sessions': 'Sessions',
'viewOptions.label': 'View options',
'groupBy.label': 'Group by',
'groupBy.workspace': 'WorkSpace',
'groupBy.flat': 'In one list',
@@ -90,7 +92,7 @@ export const en = {
'empty.noMatches': 'No matches',
'workspace.add': 'Add workspace',
'search.sessions.aria': 'Search sessions',
'search.placeholder': 'Search name, keywords...',
'search.placeholder': 'Search sessions...',
'search.clear': 'Clear search',
'search.results.aria': 'Search results',
'search.pending': 'Searching session history…',

View File

@@ -9,7 +9,7 @@ import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-run
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Session order: durable Workspace order or an activity-promoted editable order. */
/** Session order: user-arranged only, or user-arranged plus activity promotion. */
export type WorkspaceOrderBy = 'manual' | 'updated'
/** Workspace browser viewing state persisted across surface remounts and reloads. */
@@ -18,9 +18,9 @@ type WorkspaceViewState = {
orderBy: WorkspaceOrderBy
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
workspaceExpansion: Record<string, boolean>
/** Editable per-Workspace order used by recent-update mode. */
/** Shared editable per-Workspace order; recent-update mode may promote rows within it. */
recentSessionOrder: Record<string, string[]>
/** Last observed update timestamps used to detect promotion events. */
/** Last observed update timestamps used to detect one-time promotion events. */
recentSessionUpdatedAt: Record<string, Record<string, number>>
}

View File

@@ -105,7 +105,7 @@ describe('WorkspaceBrowser', () => {
// Sessions hidden while their group is folded.
expect(screen.queryByText('alpha-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
expect(screen.getByRole('menuitem', { name: '手动排序' }).querySelector('svg')).toBeTruthy()
expect(screen.queryByText('创建时间')).toBeNull()
@@ -118,13 +118,13 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByText('beta-s')).toBeTruthy()
// Back to workspace grouping through the same menu.
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('工作区')).toBeTruthy()
// Escape closes the menu without picking.
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.store.getSnapshot().groupBy).toBe('workspace')
@@ -169,14 +169,14 @@ describe('WorkspaceBrowser', () => {
expect(screen.getByRole('button', { name: '展开其余 2 个会话' })).toBeTruthy()
})
it('keeps recent-update order editable and promotes a Session when its timestamp advances', async () => {
it('shares one editable order across modes and promotes only while Last updated is active', async () => {
const initial = sessionState([summary('one', 3), summary('two', 2)])
const b = mount({
useSessions: hook(initial),
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
const rows = screen.getAllByRole('treeitem').slice(1)
@@ -192,12 +192,44 @@ describe('WorkspaceBrowser', () => {
fireDrag(two, 'drop', 180)
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
// User activity updates the timestamp baseline in Manual mode without
// changing the shared visual order.
const updated = sessionState([summary('one', 4), summary('two', 2)])
rerender(b, { useSessions: hook(updated) })
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionUpdatedAt.alpha).toEqual({ one: 4, two: 2 })
})
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
// Entering Last updated performs one complete recency sort.
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['one', 'two'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
})
// A later user activity timestamp promotes that Session once while the
// mode remains active.
const promoted = sessionState([summary('one', 4), summary('two', 5)])
rerender(b, { useSessions: hook(promoted) })
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
b.view.unmount()
const restored = mount({
useSessions: hook(promoted),
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
})
expect(restored.store.getSnapshot().recentSessionOrder.alpha).toEqual(['two', 'one'])
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
it('archives a session from the row menu and hides archived rows in both modes', async () => {
@@ -215,7 +247,7 @@ describe('WorkspaceBrowser', () => {
// The archive-set echo hides the row in grouped and flat modes.
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['kept-s', 'gone-s'])], [sid('gone-s')])) })
expect(screen.queryByText('gone-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '分组方式' }))
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
expect(screen.getByText('kept-s')).toBeTruthy()
expect(screen.queryByText('gone-s')).toBeNull()
@@ -322,9 +354,9 @@ describe('WorkspaceBrowser', () => {
expect(screen.getAllByText('新会话')).toHaveLength(1)
// Search excludes blank rows entirely — neither the canonical stored
// title nor the localized display label participates in matching.
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'new session' } })
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'new session' } })
expect(screen.queryByText('新会话')).toBeNull()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: '新会话' } })
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: '新会话' } })
expect(screen.queryByText('新会话')).toBeNull()
})
@@ -340,7 +372,7 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
fireEvent.change(input, { target: { value: 'needle' } })
const resultTree = screen.getByRole('tree', { name: '搜索结果' })
expect(screen.getByText('Needle row')).toBeTruthy()
@@ -372,7 +404,7 @@ describe('WorkspaceBrowser', () => {
expect(search.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(search)
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
fireEvent.change(input, { target: { value: 'kept' } })
fireEvent.click(document.body)
expect(search.getAttribute('aria-expanded')).toBe('true')
@@ -397,7 +429,7 @@ describe('WorkspaceBrowser', () => {
open,
searchSessions,
})
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
fireEvent.change(input, { target: { value: 'waterfall token' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
expect(screen.queryByText('Research notes')).toBeNull()
@@ -422,7 +454,7 @@ describe('WorkspaceBrowser', () => {
try {
const searchSessions = vi.fn(async () => ({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索名称、关键词…')
const input = screen.getByPlaceholderText<HTMLInputElement>('搜索会话…')
expect(input.maxLength).toBe(500)
fireEvent.change(input, { target: { value: 'y'.repeat(501) } })
expect(input.value).toBe('y'.repeat(500))
@@ -453,7 +485,7 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: hook(workspaceState([workspace('alpha', ['local-hit'])])),
searchSessions,
})
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), {
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), {
target: { value: 'needle' },
})
expect(screen.getByText('Needle title')).toBeTruthy()
@@ -490,7 +522,7 @@ describe('WorkspaceBrowser', () => {
])),
searchSessions,
})
const input = screen.getByPlaceholderText('搜索名称、关键词…')
const input = screen.getByPlaceholderText('搜索会话…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
const firstSignal = searchSessions.mock.calls[0]?.[1] as AbortSignal
@@ -524,7 +556,7 @@ describe('WorkspaceBrowser', () => {
? first
: Promise.resolve({ items: [], hasMore: false }))
mount({ searchSessions })
const input = screen.getByPlaceholderText('搜索名称、关键词…')
const input = screen.getByPlaceholderText('搜索会话…')
fireEvent.change(input, { target: { value: 'first' } })
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
@@ -547,7 +579,7 @@ describe('WorkspaceBrowser', () => {
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('暂无会话')).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'x' } })
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'x' } })
expect(screen.getByText('正在搜索会话历史…')).toBeTruthy()
await act(async () => { await vi.advanceTimersByTimeAsync(250) })
expect(screen.getByText('无匹配会话')).toBeTruthy()
@@ -563,12 +595,12 @@ describe('WorkspaceBrowser', () => {
const b = mount({ wide: false, expandSidebar })
// No wide chrome in rail state.
expect(screen.queryByText('工作区')).toBeNull()
expect(screen.queryByPlaceholderText('搜索名称、关键词…')).toBeNull()
expect(screen.queryByPlaceholderText('搜索会话…')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('搜索名称、关键词…')
const input = screen.getByPlaceholderText('搜索会话…')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
// Wide search button is decorative (tabIndex -1, no expand call).
@@ -933,7 +965,7 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
})
fireEvent.change(screen.getByPlaceholderText('搜索名称、关键词…'), { target: { value: 'needle' } })
fireEvent.change(screen.getByPlaceholderText('搜索会话…'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.hasAttribute('draggable')).toBe(false)
})