refactor(workspace): derive flat rows only after ordering

This commit is contained in:
Dudu-0223 committed 2026-09-11 14:31:25 +08:00
1 parent d19d23cf86
commit 3a48b62344
8 files changed
+66 -74

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 packages/client/ui-workspace/README.md
README.md: 2663dea7f3e137173e52d47ba87318bd31fd2fc6
README.zh.md: f1bf49efebdf561df67cc902e1d4ffe48394469b
README.md: bd5ae62f03b9d82c7aace7bf101b69f2ce9e940b
README.zh.md: ee2b8c78cf0af1543e9855f8abdd35aa5a830365
+1 -1
View File
@@ -67,7 +67,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
### View state
Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
Once the Workspace list baseline is ready, browser-persisted expansion and manual Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. `WorkspaceView.sessionIds` supplies real-Workspace membership, not Session display order. View actions require the current account orders explicitly. Flat-list membership and ordering use Session ids; row rendering adds status indicators once. Entering Manual snapshots every active account from the current display; reconciliation retains saved members that still belong to the account, removes departed members, and appends newly known members by recency. A new membership entry without a Session summary is omitted until that summary arrives, while an already saved slot survives a temporarily missing summary. This reconciliation remains mounted while the sidebar is a rail or search replaces its body. Last updated derives directly from each current list snapshot without reading or writing saved positions; equal timestamps use Session ids as a stable tie-break. The shared sidebar projection hides rows whose durable Session summary has `origin: 'subagent'`, and each visible ordinary row inherits the blue activity indicator while any descendant reached through uninterrupted subagent-origin lineage is running. The same pure derivation reads the Schedule key from list projection values for grouped, flat, and search nodes; the package uses only the type-only `@deepseek-ai/dsh-schedule/client` dependency and does not import the Schedule runtime or `ui-schedule`.
### Hover cards
+1 -1
View File
@@ -67,7 +67,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
### 视图状态
Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
### 悬浮卡片
@@ -24,7 +24,7 @@ import type { WorkspaceBrowserProps } from '../contract/slots.ts'
import type { SessionNode, SessionOrderBy } from '../tree.ts'
import {
deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey,
pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY,
pinCurrentBlank, reconcileManualOrder, UNGROUPED_KEY, visibleSessionIds,
} from '../tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './Rows.tsx'
import { FLAT_SESSION_ORDER_KEY } from '../stores.ts'
@@ -505,7 +505,7 @@ function SessionTree({
/** The flat "In one list" body: every session is one draggable top-level row. */
function FlatList({
list, sessionIds, useSessionPendingInteraction, open, forkSession, onSessionRename, onSessionArchive,
archivedSessionIds, usePanelInfo, setSessionOrder,
usePanelInfo, setSessionOrder,
revealSessionId, onSessionRevealed, t,
}: Pick<
SessionTreeProps,
@@ -514,7 +514,6 @@ function FlatList({
| 'forkSession'
| 'onSessionRename'
| 'onSessionArchive'
| 'archivedSessionIds'
| 'usePanelInfo'
| 'setSessionOrder'
| 'revealSessionId'
@@ -526,17 +525,10 @@ function FlatList({
}) {
const panelActive = usePanelInfo(info => info.activePanelId !== null)
const pendingInteractions = useSessionPendingInteraction(s => s)
const baseRows = useMemo(
() => deriveFlat(list, archivedSessionIds, pendingInteractions),
[list, archivedSessionIds, pendingInteractions],
const rows = useMemo(
() => deriveFlat(list, sessionIds, pendingInteractions),
[list, sessionIds, pendingInteractions],
)
const rows = useMemo(() => {
const byId = new Map(baseRows.map(row => [row.id, row]))
return sessionIds.flatMap((id) => {
const row = byId.get(id)
return row === undefined ? [] : [row]
})
}, [baseRows, sessionIds])
const [drag, setDrag] = useState<DragState | null>(null)
const dropCommitted = useRef(false)
useNativeDragAcceptance(drag !== null)
@@ -749,7 +741,7 @@ export function WorkspaceBrowser({
return list.ids.filter(id => list.byId[id] !== undefined && !accounted.has(id))
}, [list, workspaces])
const flatMemberIds = useMemo(
() => deriveFlat(list, archivedSessionIds, new Map()).map(row => row.id),
() => visibleSessionIds(list, archivedSessionIds),
[archivedSessionIds, list],
)
const orderedWorkspaces = useMemo(() => workspaces.map((workspace) => {
@@ -1183,7 +1175,6 @@ export function WorkspaceBrowser({
useSessionPendingInteraction={useSessionPendingInteraction}
open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds}
setSessionOrder={saveSessionOrder}
revealSessionId={revealSessionId}
onSessionRevealed={acknowledgeSessionReveal}
@@ -34,7 +34,7 @@ type WorkspaceViewActions = {
setOrderBy: (
draft: WorkspaceViewState,
mode: SessionOrderBy,
initialOrders?: Readonly<Record<string, readonly string[]>>,
initialOrders: Readonly<Record<string, readonly string[]>>,
) => void
setGroupExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
retainAccountKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
@@ -46,7 +46,7 @@ type WorkspaceViewActions = {
draft: WorkspaceViewState,
accountKey: string,
order: readonly string[],
initialOrders?: Readonly<Record<string, readonly string[]>>,
initialOrders: Readonly<Record<string, readonly string[]>>,
) => void
}
@@ -72,7 +72,7 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
persist: 'dsh.workspace.view.v5',
actions: {
setGroupBy: (d, mode: SessionGroupBy) => { d.groupBy = mode },
setOrderBy: (d, mode: SessionOrderBy, initialOrders = {}) => {
setOrderBy: (d, mode: SessionOrderBy, initialOrders) => {
if (mode === d.orderBy) return
d.sessionOrderByAccount = mode === 'manual' ? copySessionOrders(initialOrders) : {}
d.orderBy = mode
@@ -92,7 +92,7 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
if (d.orderBy !== 'manual') return
Object.assign(d.sessionOrderByAccount, copySessionOrders(orders))
},
setSessionOrder: (d, accountKey, order, initialOrders = {}) => {
setSessionOrder: (d, accountKey, order, initialOrders) => {
if (d.orderBy === 'updated') d.sessionOrderByAccount = copySessionOrders(initialOrders)
d.orderBy = 'manual'
d.sessionOrderByAccount[accountKey] = [...order]
+21 -18
View File
@@ -374,33 +374,36 @@ export function deriveGroups(
}
/**
* Derive the flat session list ("In one list" mode): every session — fork
* children included — as a top-level row, newest-first with the selected
* provisional blank pinned first. No grouping, no parent/child adjacency.
* Content search lives outside this derivation
* (see {@link deriveSearchResults}).
* Select flat-list members without deriving row presentation or ordering.
* @param list - sessions list snapshot.
* @param archivedSessionIds - registry-global archive set.
* @returns known visible Session ids in list order, including ordinary forks and only the current blank.
*/
export function visibleSessionIds(
list: SessionListState,
archivedSessionIds: readonly SessionId[],
): SessionId[] {
const archived = new Set(archivedSessionIds)
return list.ids.filter((id) => {
const s = list.byId[id]
return s !== undefined && sessionVisible(s, list.current, archived)
})
}
/**
* Derive flat rows from the browser's ordered visible Session ids.
* @param list - sessions list snapshot used to select the ids.
* @param sessionIds - known visible members in render order, including any pinned blank.
* @param pendingInteractions - pending UI interactions by Session.
* @returns flat rows in render order.
* @returns flat rows in the supplied order with current status indicators.
*/
export function deriveFlat(
list: SessionListState,
archivedSessionIds: readonly SessionId[],
sessionIds: readonly SessionId[],
pendingInteractions: SessionPendingInteractions,
): SessionNode[] {
const archived = new Set(archivedSessionIds)
const descendants = indexSubagentDescendants(list.byId)
const visibleIds: SessionId[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined || !sessionVisible(s, list.current, archived)) continue
visibleIds.push(id)
}
const currentBlank = list.current !== undefined && list.byId[list.current]?.blank === true
? list.current
: undefined
return pinCurrentBlank(orderByRecency(visibleIds, list.byId), currentBlank)
return sessionIds
.map(id => sessionNode(list.byId[id] as SessionSummary, descendants, pendingInteractions))
}
@@ -6,7 +6,7 @@ import type { ScheduleId, ScheduleRecord } from '@deepseek-ai/dsh-schedule/clien
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
deriveFlat, deriveGroups, deriveSearchResults, orderByRecency, owningGroupKey,
pinCurrentBlank, reconcileManualOrder, workspaceLabel, UNGROUPED_KEY,
pinCurrentBlank, reconcileManualOrder, visibleSessionIds, workspaceLabel, UNGROUPED_KEY,
} from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
@@ -96,7 +96,7 @@ describe('deriveGroups', () => {
sessions, [workspace('project', ['awaiting'])], noArchive, attention, view(['project']),
)
expect(grouped[0]!.sessions[0]).toMatchObject({ pendingInteraction: 'plan-review', running: true })
expect(deriveFlat(sessions, noArchive, attention)[0])
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), attention)[0])
.toMatchObject({ pendingInteraction: 'plan-review', running: true })
})
@@ -109,7 +109,7 @@ describe('deriveGroups', () => {
{ key: `${kind}:1`, kind, sessionId: awaiting.id },
]])
expect(deriveFlat(list(awaiting), noArchive, attention)[0]?.pendingInteraction).toBe(kind)
expect(deriveFlat(list(awaiting), [awaiting.id], attention)[0]?.pendingInteraction).toBe(kind)
},
)
@@ -175,7 +175,8 @@ describe('deriveGroups', () => {
const plainNode = groups[0]!.sessions.find(session => session.id === plain.id)!
expect(doneNode.completed).toBe(true)
expect(plainNode.completed).toBe(false)
expect(deriveFlat(sessions, noArchive, noAttention).find(node => node.id === done.id)!.completed).toBe(true)
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
.find(node => node.id === done.id)!.completed).toBe(true)
const search = deriveSearchResults(
sessions, [workspace('first', ['done', 'plain'])], 'done', noArchive,
noAttention, { items: [], hasMore: false }, 10,
@@ -206,7 +207,7 @@ describe('deriveGroups', () => {
expect(deriveGroups(
sessions, workspaces, noArchive, noAttention, view(['project']),
)[0]!.sessions.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
expect(deriveFlat(sessions, noArchive, noAttention)
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
.map(node => [node.id, node.hasActiveSchedule])).toEqual(expected)
expect(deriveSearchResults(
sessions, workspaces, 'project', noArchive, noAttention, { items: [], hasMore: false }, 10,
@@ -238,8 +239,9 @@ describe('deriveGroups', () => {
expect(groups[0]!.sessionCount).toBe(2)
expect(groups[0]!.sessions[0]).toMatchObject({ running: false, runningSubagentCount: 2 })
expect(groups[0]!.sessions[1]).toMatchObject({ running: false, runningSubagentCount: 1 })
expect(deriveFlat(sessions, noArchive, noAttention).map(node => [node.id, node.runningSubagentCount])).toEqual([
[fork.id, 1], [parent.id, 2],
expect(deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
.map(node => [node.id, node.runningSubagentCount])).toEqual([
[parent.id, 2], [fork.id, 1],
])
expect(deriveSearchResults(
sessions, [workspace('first', ['parent', 'fork'])], 'parent', noArchive,
@@ -322,40 +324,36 @@ describe('deriveGroups', () => {
})
describe('deriveFlat', () => {
it('flattens every session — fork children included — newest-first with id tiebreak', () => {
it('renders ordinary forks in the supplied order regardless of timestamps', () => {
const parent = summary('parent', 10)
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), noArchive, noAttention)
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
const rows = deriveFlat(list(parent, child, tieB, tieA), [parent.id, tieA.id, child.id, tieB.id], noAttention)
expect(rows.map(row => row.id)).toEqual([parent.id, tieA.id, child.id, tieB.id])
})
it('hides subagent-origin rows but keeps ordinary forks', () => {
const parent = summary('parent', 1)
const fork = { ...summary('fork', 2), parentId: parent.id }
const subagent = { ...summary('subagent', 3), parentId: parent.id, origin: 'subagent' as const }
const rows = deriveFlat(
{ ...list(parent, fork, subagent), current: subagent.id },
noArchive,
noAttention,
)
expect(rows.map(row => row.id)).toEqual([fork.id, parent.id])
const ids = visibleSessionIds({ ...list(parent, fork, subagent), current: subagent.id }, noArchive)
expect(ids).toEqual([parent.id, fork.id])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, noArchive, noAttention).map(row => row.id)).toEqual([sid('present')])
expect(visibleSessionIds(partial, noArchive)).toEqual([sid('present')])
})
it('shows only the current blank session and excludes blanks from search', () => {
const currentBlank = { ...summary('current-blank', 9), blank: true }
const staleBlank = { ...summary('stale-blank', 8), blank: true }
const sessions = {
...list(summary('real', 1), currentBlank, staleBlank),
...list(currentBlank, summary('real', 1), staleBlank),
current: currentBlank.id,
}
const rows = deriveFlat(sessions, noArchive, noAttention)
const rows = deriveFlat(sessions, visibleSessionIds(sessions, noArchive), noAttention)
expect(rows.map(row => row.id)).toEqual([currentBlank.id, sid('real')])
expect(rows.map(row => row.title)).toEqual(['', 'real'])
expect(rows.map(row => row.blank)).toEqual([true, false])
@@ -364,7 +362,7 @@ describe('deriveFlat', () => {
it('hides archived sessions in flat mode', () => {
const kept = summary('kept', 1)
const gone = summary('gone', 2)
expect(deriveFlat(list(kept, gone), archived('gone'), noAttention).map(row => row.id)).toEqual([kept.id])
expect(visibleSessionIds(list(kept, gone), archived('gone'))).toEqual([kept.id])
})
})
@@ -523,9 +521,9 @@ describe('createWorkspaceViewStore', () => {
expect(store.getSnapshot().groupBy).toBe('workspace')
expect(store.getSnapshot().orderBy).toBe('updated')
store.actions.setGroupBy('flat')
store.actions.setOrderBy('updated')
store.actions.setOrderBy('updated', {})
store.actions.setGroupExpanded('alpha', true)
store.actions.setSessionOrder('alpha', ['one', 'two'])
store.actions.setSessionOrder('alpha', ['one', 'two'], {})
expect(store.getSnapshot().groupBy).toBe('flat')
expect(store.getSnapshot()).toMatchObject({
orderBy: 'manual',
@@ -536,10 +534,10 @@ describe('createWorkspaceViewStore', () => {
it('retains positions when reselecting Manual and snapshots the supplied order on mode switches', () => {
const store = createWorkspaceViewStore().create()
store.actions.setSessionOrder('alpha', ['two', 'one'])
store.actions.setOrderBy('manual')
store.actions.setSessionOrder('alpha', ['two', 'one'], {})
store.actions.setOrderBy('manual', {})
expect(store.getSnapshot()).toMatchObject({ orderBy: 'manual', sessionOrderByAccount: { alpha: ['two', 'one'] } })
store.actions.setOrderBy('updated')
store.actions.setOrderBy('updated', {})
expect(store.getSnapshot()).toMatchObject({ orderBy: 'updated', sessionOrderByAccount: {} })
store.actions.setOrderBy('manual', { alpha: ['one', 'two'] })
expect(store.getSnapshot().sessionOrderByAccount).toEqual({ alpha: ['one', 'two'] })
@@ -568,8 +566,8 @@ describe('createWorkspaceViewStore', () => {
store.actions.setGroupExpanded('', true)
store.actions.setGroupExpanded('alpha', true)
store.actions.setGroupExpanded('deleted', true)
store.actions.setSessionOrder('alpha', ['alpha-session'])
store.actions.setSessionOrder('deleted', ['deleted-session'])
store.actions.setSessionOrder('alpha', ['alpha-session'], {})
store.actions.setSessionOrder('deleted', ['deleted-session'], {})
store.actions.retainAccountKeys(['', 'alpha'])
@@ -25,7 +25,7 @@ afterEach(cleanup)
const scrollIntoView = vi.fn()
beforeEach(() => {
localStorage.clear()
createWorkspaceViewStore().create().actions.setOrderBy('manual')
createWorkspaceViewStore().create().actions.setOrderBy('manual', {})
Element.prototype.scrollIntoView = scrollIntoView
scrollIntoView.mockClear()
})
@@ -117,9 +117,9 @@ describe('WorkspaceBrowser', () => {
const preferences = createWorkspaceViewStore().create()
preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
const account = mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
preferences.actions.setSessionOrder(account, ['older', 'newer'])
preferences.actions.setSessionOrder(UNGROUPED_KEY, ['older', 'newer'])
preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['older', 'newer'])
preferences.actions.setSessionOrder(account, ['older', 'newer'], {})
preferences.actions.setSessionOrder(UNGROUPED_KEY, ['older', 'newer'], {})
preferences.actions.setSessionOrder(FLAT_SESSION_ORDER_KEY, ['older', 'newer'], {})
preferences.actions.setGroupExpanded(account, true)
preferences.actions.setGroupExpanded(UNGROUPED_KEY, true)
localStorage.setItem('dsh.workspace.view.v5', JSON.stringify({ ...preferences.getSnapshot(), orderBy: 'updated' }))
@@ -146,7 +146,7 @@ describe('WorkspaceBrowser', () => {
useWorkspaces: b.props.useWorkspaces,
})
expect(names()).toEqual(['newer', 'older'])
act(() => { restored.store.actions.setOrderBy('manual') })
act(() => { restored.store.actions.setOrderBy('manual', {}) })
expect(names()).toEqual(['newer', 'older'])
})
@@ -266,7 +266,7 @@ describe('WorkspaceBrowser', () => {
const b = mount({ useWorkspaces: hook(pending) })
act(() => {
b.store.actions.setGroupExpanded('deleted', true)
b.store.actions.setSessionOrder('deleted', ['session'])
b.store.actions.setSessionOrder('deleted', ['session'], {})
})
expect(b.store.getSnapshot().groupExpansion).toEqual({ deleted: true })