fix(workspace): pause current ordering in manual mode

This commit is contained in:
Dudu-0223 committed 2026-09-10 21:25:09 +08:00
1 parent 6e94ee4fb9
commit 4857614be0
12 files changed
+179 -118

No files matched your search

@@ -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 .agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md
2026-09-10-derived-workspace-recency.md: e25e8f26f00f31af8ffcbfb2e33d622ed64b5605
2026-09-10-derived-workspace-recency.zh.md: dc7d8bc4dd6b74c7948f0a7525762acf2e9960d1
2026-09-10-derived-workspace-recency.md: d1a9565e6d638928e3503573ee4165fde83c884e
2026-09-10-derived-workspace-recency.zh.md: 620d293e2dc4b13b88696a7745fc33fffbe5d853
@@ -12,7 +12,7 @@ An editable activity-promoted list can disagree with its displayed timestamps wi
Last updated is a pure projection of current Session summaries: ordinary rows sort by descending `updatedAt`, with Session id as the tie-break. The selected blank New Session precedes ordinary rows until its first prompt. Grouped, Ungrouped, and flat views use the same policy. Reloads and delayed summaries require no observed-timestamp history or promotion events in the view store.
Only Manual uses the browser-persisted order. A Session drag saves the visible ordering after the move and selects Manual atomically. Real Workspace drags also call the existing Host reorder operation; Workspace-group order remains Host-owned. Returning to Last updated leaves saved manual positions intact. Returning to Manual restores them. Initial manual orders use Host membership order for real Workspaces and recency for Ungrouped and flat lists.
Only Manual uses the browser-persisted order. A Session drag saves the visible ordering after the move and selects Manual atomically. Real Workspace drags also call the existing Host reorder operation; Workspace-group order remains Host-owned. Entering Manual freezes the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Every account initializes from recency. Newly encountered blank rows enter first; their saved manual positions survive the first prompt and reloads.
The existing persistence key retains grouping, expansion, and saved positions. Legacy mixed orders remain usable as manual positions but cannot affect Last updated. The obsolete observed-timestamp field has no consumer. Current metadata still owns timestamp accuracy: a cold summary without prompt metadata can fall back to creation time, independently of view ordering.
@@ -20,16 +20,18 @@ The existing persistence key retains grouping, expansion, and saved positions. L
**Keep activity promotion and sort again on reconnect.** Late summaries and metadata corrections also arrive within a connection. A reconnect-only repair still allows chronology to depend on observation order.
**Restore a separate manual layout on mode switches.** Replacing the visible list with old positions makes entering Manual surprising. Resetting the layout on a mode change gives Manual the simpler meaning of pausing the current ordering.
**Persist a second chronological order.** Current timestamps already determine that order. A second ledger retains synchronization and recovery work without representing an independent user choice.
**Disable dragging in Last updated.** Switching to Manual on a committed drag keeps the existing affordance and gives the edited order an explicit mode. A cancelled or ineffective drag leaves the selected mode unchanged.
## Consequences
Manual and Last updated have distinct meanings; switching modes may move rows to their saved manual positions. Chronological browsing gives up persistent drag exceptions. The view store retains manual-order membership reconciliation and blank-row creation placement, but owns no activity timestamps.
Manual pauses automatic sorting of the current list. The default is Last updated, and reloads retain the selected mode and current manual positions. Chronological browsing gives up persistent drag exceptions. The view store retains manual-order membership reconciliation and blank-row creation placement, but owns no activity timestamps.
The archived sidebar note remains frozen historical evidence; its folding and Workspace-order decisions are outside this change. No active note owns the superseded shared-order policy.
## Testing
Component regressions cover older first arrivals, corrected and decreasing timestamps, saved-order isolation, reloads, and automatic Manual selection on drag. The recorded-session Web scenario exercises the shipped composition with legacy persisted positions, native dragging, grouped and flat views, and reloads. Existing folding cases retain the provisional blank quota.
Component regressions cover older first arrivals, corrected and decreasing timestamps, discarded-layout isolation, mode-switch cycles, blank insertion, reloads, and automatic Manual selection on drag. The recorded-session Web scenario exercises the shipped composition with legacy persisted positions, native dragging, grouped and flat views, and reloads. Existing folding cases retain the provisional blank quota.
@@ -12,7 +12,7 @@ Status: implemented
最近更新是当前 Session 摘要的纯投影:普通行按 `updatedAt` 降序排列,时间相同时按 Session id 排序。当前选中的空白新会话在首条提示词落地前排在普通行之前。分组、Ungrouped 和单列表视图采用相同策略。重新加载和摘要迟到不需要视图存储中的已观察时间历史或提升事件。
只有手动排序使用浏览器持久化顺序。拖拽 Session 会保存移动后的可见排列,并原子地选中手动排序。真实 Workspace 的拖拽还会调用既有的 Host 重排序操作;Workspace 分组顺序仍由 Host 拥有。返回最近更新会保留已保存的手动位置,返回手动排序则恢复这些位置。初始手动顺序中,真实 Workspace 使用 Host 成员顺序,Ungrouped 和单列表使用最近更新时间顺序。
只有手动排序使用浏览器持久化顺序。拖拽 Session 会保存移动后的可见排列,并原子地选中手动排序。真实 Workspace 的拖拽还会调用既有的 Host 重排序操作;Workspace 分组顺序仍由 Host 拥有。进入手动排序会固定当前的时间顺序。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。所有记账都从最近更新时间顺序初始化。新出现的空白行放在首位;其保存的手动位置在首条提示词落地和重新加载后保留。
既有持久化 key 保留分组方式、展开状态和已保存位置。旧的混合顺序仍可用作手动位置,但无法影响最近更新。废弃的已观察时间字段没有消费者。时间准确性仍由当前元数据负责:缺少提示词元数据的冷摘要可能回退到创建时间,这与视图排序相互独立。
@@ -20,16 +20,18 @@ Status: implemented
**保留活动提升,并在重连时再次排序。** 摘要迟到和元数据修正也会发生在一次连接内。仅在重连时修复仍会让时间顺序依赖观察先后。
**切换模式时恢复独立的手动布局。** 用旧位置替换可见列表会让进入手动排序的结果出乎预期。在模式变化时重置布局,让手动排序只表示暂停当前排列。
**持久化第二份时间顺序。** 当前时间戳已经决定该顺序。第二份账本保留了同步和恢复工作,却没有表达独立的用户选择。
**禁止在最近更新中拖拽。** 在提交拖拽时切到手动排序,可以保留既有操作入口,并为编辑后的顺序提供明确模式。取消或未产生有效移动的拖拽保持所选模式不变。
## 后果
手动排序和最近更新具有不同含义;切换模式可能将行移回保存的手动位置。按时间浏览放弃持久化的拖拽例外。视图存储保留手动顺序的成员对齐和空白行创建时的位置处理,但不拥有活动时间戳。
手动排序暂停当前列表的自动排列。默认模式为最近更新,重新加载保留所选模式和当前手动位置。按时间浏览放弃持久化的拖拽例外。视图存储保留手动顺序的成员对齐和空白行创建时的位置处理,但不拥有活动时间戳。
已归档的侧边栏记录保持为冻结的历史证据;其折叠和 Workspace 顺序决策不在本次修改范围内。没有活动记录拥有被替代的共享顺序策略。
## 测试
组件回归覆盖较旧记录首次到达、时间修正和回退、保存顺序的隔离、重新加载,以及拖拽后自动选中手动排序。录制会话 Web 场景在实际发布组合中验证旧的持久化位置、原生拖拽、分组与单列表视图及重新加载。既有折叠用例保留临时空白行配额。
组件回归覆盖较旧记录首次到达、时间修正和回退、已丢弃布局的隔离、模式往返切换、空白行插入、重新加载,以及拖拽后自动选中手动排序。录制会话 Web 场景在实际发布组合中验证旧的持久化位置、原生拖拽、分组与单列表视图及重新加载。既有折叠用例保留临时空白行配额。
+29 -5
View File
@@ -1,6 +1,6 @@
/** Recency and saved manual order through the shipped Web composition. */
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { chromium, type Browser, type Page } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
@@ -59,7 +59,7 @@ describe('web e2e: workspace recency', () => {
await scaffold?.close()
})
it('ignores legacy order, saves a drag as Manual, and restores each mode after reload', async () => {
it('pauses current recency in Manual, preserves drags on reload, and resets on Last updated', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-recency'))
const titles = () => page.locator('[role="treeitem"]:not([aria-expanded]) [class*="title"]').allTextContents()
const pick = async (name: string): Promise<void> => {
@@ -72,7 +72,7 @@ describe('web e2e: workspace recency', () => {
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), MODE,
)
await pick('Manual')
await expect.poll(titles).toEqual([...TITLES].reverse())
await expect.poll(titles).toEqual(TITLES)
await pick('Last updated')
await expect.poll(titles).toEqual(TITLES)
const source = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[2]!, { exact: true }) })
@@ -93,8 +93,32 @@ describe('web e2e: workspace recency', () => {
await expect.poll(titles).toEqual(TITLES)
acknowledgeReloadConnectionLoss(tripwire, recencyWarningStart)
await pick('Manual')
await expect.poll(titles).toEqual([...TITLES].reverse())
await assertFixtureInventory(SNAPSHOT_DIR, ['sidebar.expected.md'])
await expect.poll(titles).toEqual(TITLES)
await pick('Last updated')
await pick('WorkSpace')
const workspaceTitle = basename(scaffold.workspaceCwd)
await page.getByRole('treeitem').filter({ has: page.getByText(workspaceTitle, { exact: true }) }).hover()
await page.getByRole('button', { name: `New session in ${workspaceTitle}` }).click()
await expect.poll(titles).toEqual(['New Session', ...TITLES])
await pick('Manual')
await expect.poll(titles).toEqual(['New Session', ...TITLES])
await compareOrRefreshGolden(
join(SNAPSHOT_DIR, 'manual-blank.expected.md'),
await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), MODE,
)
const blank = page.getByRole('treeitem').filter({ has: page.getByText('New Session', { exact: true }) })
const oldest = page.getByRole('treeitem').filter({ has: page.getByText(TITLES[2]!, { exact: true }) })
await blank.dragTo(oldest, { targetPosition: { x: 30, y: 30 } })
await expect.poll(titles).toEqual([...TITLES, 'New Session'])
const blankWarningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await expect.poll(titles).toEqual([...TITLES, 'New Session'])
acknowledgeReloadConnectionLoss(tripwire, blankWarningStart)
await pick('Last updated')
await expect.poll(titles).toEqual(['New Session', ...TITLES])
await pick('Manual')
await expect.poll(titles).toEqual(['New Session', ...TITLES])
await assertFixtureInventory(SNAPSHOT_DIR, ['sidebar.expected.md', 'manual-blank.expected.md'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
@@ -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: f2e157609456c1120e03e7f19c22696c4067d2c6
README.zh.md: a708586f78e4239fa98dc618f7e93876cf25024f
README.md: 3822bd793ed378e7252e9853a7ff1dcd022c03dd
README.zh.md: 908b07662622851f1c865986a9aa5bb95fdb0db2
+1 -1
View File
@@ -29,7 +29,7 @@ Use the sidebar to browse Workspaces and their Sessions, reorder them, and start
### Reordering and view options
**Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. The selected blank **New Session** stays first until its first prompt. **Manual** restores the saved order and keeps existing positions when activity changes. Dragging a Session applies the move to the visible order, saves it, and selects Manual; real Workspace drags also update the Host Session account, while Ungrouped and flat-list orders remain browser-local. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Workspace drag order is Host-durable in either Session order mode.
**Last updated** orders ordinary Sessions by their latest user prompt or steer time, newest first, in both grouped and flat views. The selected blank **New Session** stays first until its first prompt. **Manual** keeps the current order and holds positions when activity changes. New blank Sessions start first in either mode; Manual preserves their dragged positions through the first prompt and reloads. Returning to Last updated discards manual positions and sorts by time. The browser defaults to Last updated and remembers the selected mode across reloads. Dragging a Session applies the move to the visible order, saves it, and selects Manual; real Workspace drags also update the Host Session account, while Ungrouped and flat-list orders remain browser-local. In a collapsed group, drag boundaries follow rendered rows and place the source before intervening hidden rows, so a drag cannot hide its source. Workspace drag order is Host-durable in either Session order mode.
### Search
+1 -1
View File
@@ -29,7 +29,7 @@ kind: "package-reference"
### 重排序与视图选项
**最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。当前选中的空白**新会话**在首条提示词落地前保持首位。**手动排序**恢复保存的顺序,并在活动变化时保持已有位置。拖拽 Session 会在可见顺序上应用移动、保存结果并选中手动排序;真实 Workspace 的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表的顺序保留在浏览器本地。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
**最近更新**在分组和单列表视图中都按普通 Session 的最近一次用户提示词或 steering(中途引导)时间降序排列。当前选中的空白**新会话**在首条提示词落地前保持首位。**手动排序**保留当前顺序,并在活动变化时保持已有位置。两种模式都把新建空白 Session 放在首位;手动排序会在首条提示词落地和重新加载后保留拖拽位置。返回最近更新会丢弃手动位置并按时间排列。浏览器默认采用最近更新,并在重新加载后记住所选模式。拖拽 Session 会在可见顺序上应用移动、保存结果并选中手动排序;真实 Workspace 的拖拽还会更新 Host Session 记账,而 Ungrouped 和单列表的顺序保留在浏览器本地。折叠分组的拖拽边界按渲染行确定,并把来源行放在中间隐藏行之前,因此拖拽不会隐藏来源行。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
### 搜索
@@ -95,8 +95,10 @@ function useNativeDragAcceptance(active: boolean): void {
}, [active])
}
/** Reconcile a stored view order with the Workspace's current session account. */
function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readonly string[] | undefined): SessionId[] {
/** Preserve manual positions, prepend new blanks, and append other new account members. */
function reconciledSessionOrder(
sessionIds: readonly SessionId[], stored: readonly string[] | undefined, summaries: SessionListState['byId'],
): SessionId[] {
if (stored === undefined) return [...sessionIds]
const byId = new Map(sessionIds.map(id => [id as string, id]))
const ordered: SessionId[] = []
@@ -109,7 +111,8 @@ function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readon
}
for (const id of sessionIds) {
if (included.has(id)) continue
ordered.push(id)
if (summaries[id]?.blank === true) ordered.unshift(id)
else ordered.push(id)
}
return ordered
}
@@ -271,15 +274,18 @@ function SessionTree({
const accounted = new Set(workspaces.flatMap(workspace => workspace.sessionIds))
return list.ids.filter((id: SessionId) => list.byId[id] !== undefined && !accounted.has(id))
}, [list, workspaces])
const orderedWorkspaces = useMemo(() => workspaces.map(workspace => ({
...workspace,
sessionIds: orderBy === 'updated'
? [...workspace.sessionIds].sort((a, b) => compareSessionRecency(a, b, list.byId))
: reconciledSessionOrder(workspace.sessionIds, sessionOrderByAccount[workspace.workspaceId]),
})), [list, orderBy, sessionOrderByAccount, workspaces])
const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => {
const recent = [...workspace.sessionIds].sort((a, b) => compareSessionRecency(a, b, list.byId))
return {
...workspace,
sessionIds: orderBy === 'updated'
? recent
: reconciledSessionOrder(recent, sessionOrderByAccount[workspace.workspaceId], list.byId),
}
}), [list, orderBy, sessionOrderByAccount, workspaces])
const orderedUngroupedSessionIds = useMemo(() => {
const recent = [...ungroupedSessionIds].sort((a, b) => compareSessionRecency(a, b, list.byId))
return orderBy === 'updated' ? recent : reconciledSessionOrder(recent, sessionOrderByAccount[UNGROUPED_KEY])
return orderBy === 'updated' ? recent : reconciledSessionOrder(recent, sessionOrderByAccount[UNGROUPED_KEY], list.byId)
}, [list, orderBy, sessionOrderByAccount, ungroupedSessionIds])
useEffect(() => {
if (list.phase !== 'ready' || orderBy !== 'manual') return
@@ -592,12 +598,12 @@ function FlatList({
const rows = useMemo(() => {
if (orderBy === 'updated') return baseRows
const byId = new Map(baseRows.map(row => [row.id, row]))
return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY], list.byId)
.flatMap((id) => {
const row = byId.get(id)
return row === undefined ? [] : [row]
})
}, [baseRows, orderBy, sessionOrderByAccount, sessionIds])
}, [baseRows, list.byId, orderBy, sessionOrderByAccount, sessionIds])
useEffect(() => {
if (list.phase !== 'ready' || orderBy !== 'manual') return
const previous = sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]
@@ -803,33 +809,6 @@ export function WorkspaceBrowser({
const orderBy = useStore(s => s.orderBy)
const groupExpansion = useStore(s => s.groupExpansion)
const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
const currentBlankSessionId = useSessions((state) => {
const current = state.current
return current !== undefined && state.byId[current]?.blank === true ? current : undefined
})
const currentBlankAccount = currentBlankSessionId === undefined
|| workspacePhase !== 'ready'
? undefined
: owningGroupKey(workspaces, currentBlankSessionId)
const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined)
useEffect(() => {
if (currentBlankSessionId === undefined || currentBlankAccount === undefined) {
promotedBlank.current = undefined
return
}
const promoted = promotedBlank.current
if (promoted !== undefined && promoted.sessionId === currentBlankSessionId
&& promoted.accountKey === currentBlankAccount) return
promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
if (orderBy !== 'manual') return
for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
const previous = sessionOrderByAccount[accountKey] ?? []
actions.setSessionOrder(accountKey, [
currentBlankSessionId,
...previous.filter(id => id !== currentBlankSessionId),
])
}
}, [actions.setSessionOrder, currentBlankAccount, currentBlankSessionId, orderBy, sessionOrderByAccount])
useEffect(() => {
if (workspacePhase !== 'ready') return
actions.retainAccountKeys([
@@ -52,7 +52,10 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
persist: 'dsh.workspace.view.v5',
actions: {
setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
setOrderBy: (d, mode: SessionOrderBy) => { d.orderBy = mode },
setOrderBy: (d, mode: SessionOrderBy) => {
if (mode !== d.orderBy) d.sessionOrderByAccount = {}
d.orderBy = mode
},
setGroupExpanded: (d, key: string, expanded: boolean) => { d.groupExpansion[key] = expanded },
retainAccountKeys: (d, workspaceKeys: readonly string[]) => {
const retained = new Set(workspaceKeys)
@@ -64,6 +67,7 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
)
},
setSessionOrder: (d, accountKey: string, order: string[]) => {
if (d.orderBy === 'updated') d.sessionOrderByAccount = {}
d.orderBy = 'manual'
d.sessionOrderByAccount[accountKey] = order
},
@@ -506,6 +506,17 @@ describe('createWorkspaceViewStore', () => {
})
})
it('retains positions when reselecting Manual and discards them on mode switches', () => {
const store = createWorkspaceViewStore().create()
store.actions.setSessionOrder('alpha', ['two', 'one'])
store.actions.setOrderBy('manual')
expect(store.getSnapshot()).toMatchObject({ orderBy: 'manual', sessionOrderByAccount: { alpha: ['two', 'one'] } })
store.actions.setOrderBy('updated')
expect(store.getSnapshot()).toMatchObject({ orderBy: 'updated', sessionOrderByAccount: {} })
store.actions.setOrderBy('manual')
expect(store.getSnapshot().sessionOrderByAccount).toEqual({})
})
it('removes view state outside the retained Workspace key set', () => {
const store = createWorkspaceViewStore().create()
store.actions.setGroupExpanded('', true)
@@ -123,7 +123,7 @@ describe('WorkspaceBrowser', () => {
preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['older', 'newer'])
preferences.actions.setGroupExpanded(account, true)
preferences.actions.setGroupExpanded(UNGROUPED_KEY, true)
preferences.actions.setOrderBy('updated')
localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({ ...preferences.getSnapshot(), orderBy: 'updated' }))
const b = mount({
useSessions: hook(sessionState([summary('newer', 100)])),
useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace(account, ['older', 'newer'])])),
@@ -148,7 +148,46 @@ describe('WorkspaceBrowser', () => {
})
expect(names()).toEqual(['newer', 'older'])
act(() => { restored.store.actions.setOrderBy('manual') })
expect(names()).toEqual(['older', 'newer'])
expect(names()).toEqual(['newer', 'older'])
})
it.each(['workspace', 'flat', 'ungrouped'] as const)('starts Manual from the current %s order and forgets discarded layouts', (mode) => {
localStorage.clear()
const preferences = createWorkspaceViewStore().create()
preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
preferences.actions.setGroupExpanded(account, true)
const b = mount({
useSessions: hook(sessionState([summary('a', 30), summary('b', 20), summary('c', 10)])),
useWorkspaces: hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ['c', 'a', 'b'])])),
})
const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
.map(row => row.querySelector('[class*="title"]')?.textContent)
const pick = (name: string) => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name }))
}
expect(names()).toEqual(['a', 'b', 'c'])
pick('手动排序')
expect(names()).toEqual(['a', 'b', 'c'])
const source = screen.getByText('c').closest('[role="treeitem"]') as HTMLElement
const target = screen.getByText('a').closest('[role="treeitem"]') as HTMLElement
target.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
})
fireEvent.dragStart(source, { dataTransfer: dragData() })
fireDrag(target, 'drop', 102)
expect(names()).toEqual(['c', 'a', 'b'])
rerender(b, { useSessions: hook(sessionState([summary('a', 30), summary('b', 40), summary('c', 10)])) })
expect(names()).toEqual(['c', 'a', 'b'])
b.view.unmount()
const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
expect(names()).toEqual(['c', 'a', 'b'])
expect(restored.store.getSnapshot().orderBy).toBe('manual')
pick('最近更新')
expect(names()).toEqual(['b', 'a', 'c'])
pick('手动排序')
expect(names()).toEqual(['b', 'a', 'c'])
})
it.each(['workspace', 'flat'] as const)('keeps the provisional blank first and time ties stable in %s recency', (groupBy) => {
@@ -317,7 +356,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['two', 'three', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
})
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
@@ -448,7 +487,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('session-6')).toBeNull()
})
it('restores manual positions after recency updates and switches to Manual on drag', async () => {
it('discards manual positions on recency selection and switches to Manual on drag', async () => {
const insertSessionBefore = vi.fn(async () => {})
const initial = sessionState([summary('one', 3), summary('two', 2)])
const b = mount({
@@ -491,14 +530,14 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('one')
})
const promoted = sessionState([summary('one', 4), summary('two', 5)])
rerender(b, { useSessions: hook(promoted) })
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
@@ -507,7 +546,7 @@ describe('WorkspaceBrowser', () => {
useSessions: hook(promoted),
useWorkspaces: hook(workspaceState([workspace('alpha', ['two', 'one'])])),
})
expect(restored.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['two', 'one'])
expect(restored.store.getSnapshot().sessionOrderByAccount).toEqual({})
expect(screen.getAllByRole('treeitem').slice(1)[0]?.textContent).toContain('two')
})
@@ -643,70 +682,62 @@ describe('WorkspaceBrowser', () => {
expect(screen.queryByText('新会话')).toBeNull()
})
it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
const items = [
summary('old', 100),
summary('blank', 150, { blank: true }),
summary('mid', 200),
]
const startSession = vi.fn()
it.each(['workspace', 'flat', 'ungrouped'] as const)('keeps a new blank first across %s mode switches and preserves its manual drag after reload', (mode) => {
localStorage.clear()
const preferences = createWorkspaceViewStore().create()
preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
preferences.actions.setGroupExpanded(account, true)
const groups = (ids: string[]) => hook(workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)]))
const b = mount({
useSessions: hook(sessionState(items)),
useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
startSession,
useSessions: hook(sessionState([summary('old', 100), summary('mid', 200)])),
useWorkspaces: groups(['old', 'mid']),
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
})
startSession.mockImplementation(() => {
rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
})
fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
})
b.store.actions.setGroupBy('flat')
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
})
})
it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
const insertSessionBefore = vi.fn(async () => {})
const b = mount({
const names = () => screen.getAllByRole('treeitem').filter(row => row.getAttribute('aria-expanded') === null)
.map(row => row.querySelector('[class*="title"]')?.textContent)
const pick = (name: string) => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name }))
}
pick('手动排序')
pick('最近更新')
rerender(b, {
useSessions: hook(sessionState([
summary('old', 100),
summary('blank', 150, { blank: true }),
summary('mid', 200),
summary('old', 100), summary('mid', 200), summary('blank', 1, { blank: true }),
], { current: sid('blank') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
insertSessionBefore,
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
useWorkspaces: groups(['old', 'mid', 'blank']),
})
expect(names()).toEqual(['新会话', 'mid', 'old'])
expect(b.store.getSnapshot().orderBy).toBe('updated')
pick('手动排序')
expect(names()).toEqual(['新会话', 'mid', 'old'])
pick('最近更新')
const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
mid.getBoundingClientRect = () => ({
const old = screen.getByText('old').closest('[role="treeitem"]') as HTMLElement
old.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
})
fireEvent.dragStart(blank, { dataTransfer: dragData() })
fireDrag(mid, 'drop', 180)
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
rerender(b, {
fireDrag(old, 'drop', 180)
expect(names()).toEqual(['mid', 'old', '新会话'])
b.view.unmount()
const restored = mount({ useSessions: b.props.useSessions, useWorkspaces: b.props.useWorkspaces })
expect(names()).toEqual(['mid', 'old', '新会话'])
rerender(restored, { useSessions: hook(sessionState([
summary('old', 100), summary('mid', 200), summary('blank', 300),
], { current: sid('blank') })) })
expect(names()).toEqual(['mid', 'old', 'blank'])
rerender(restored, {
useSessions: hook(sessionState([
summary('old', 100),
summary('blank', 150),
summary('mid', 200),
], { current: sid('blank') })),
})
await waitFor(() => {
expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
summary('old', 100), summary('mid', 200), summary('blank', 300), summary('new-blank', 1, { blank: true }),
], { current: sid('new-blank') })),
useWorkspaces: groups(['old', 'mid', 'blank', 'new-blank']),
})
expect(names()).toEqual(['新会话', 'mid', 'old', 'blank'])
pick('最近更新')
expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
pick('手动排序')
expect(names()).toEqual(['新会话', 'blank', 'mid', 'old'])
})
it('shows local metadata matches immediately, then clears back to the grouped tree', async () => {
@@ -1299,7 +1330,7 @@ describe('WorkspaceBrowser', () => {
expect(screen.getAllByRole('treeitem').slice(1).map(row => row.textContent)).toEqual([
expect.stringContaining('one'), expect.stringContaining('two'), expect.stringContaining('three'),
])
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['three', 'one', 'two'])
expect(b.store.getSnapshot().sessionOrderByAccount).toEqual({})
})
dragAfter('one', 'three')
expect(b.store.getSnapshot().sessionOrderByAccount[UNGROUPED_KEY]).toEqual(['two', 'three', 'one'])
@@ -0,0 +1,8 @@
- tree "Sessions":
- treeitem "{{workspace}}" [expanded]:
- img
- text: {{workspace}}
- treeitem "New Session" [selected]
- treeitem "Newest conversation now"
- treeitem "Middle conversation 1min"
- treeitem "Oldest conversation 2min"