Просмотр исходного кода

fix(client): refine workspace view controls

_Kerman 2 месяцев назад
Родитель
Сommit
5b1da441d5

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -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

+ 1 - 1
packages/client/ui-workspace/README.md

@@ -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.
 

+ 1 - 1
packages/client/ui-workspace/README.zh.md

@@ -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 时既不会清除查询,也不会跳转至特定事件。
 

+ 15 - 14
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -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}

+ 4 - 2
packages/client/ui-workspace/src/client/locales.ts

@@ -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…',

+ 3 - 3
packages/client/ui-workspace/src/client/stores.ts

@@ -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>>
 }
 

+ 51 - 19
packages/client/ui-workspace/tests/workspace-browser.spec.tsx

@@ -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)
   })