feat(ui-workspace): support manual flat-list ordering

This commit is contained in:
_Kerman
2026-08-12 11:13:57 +08:00
parent 3a0baa1e9d
commit 3a3248b31d
13 changed files with 292 additions and 80 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: cbb14183931f87315da89adc418084375d943002
README.zh.md: bf8cdf6584d2320c46b2c86496362d7d08f5fd0c
README.md: 9d7d4d77cc064146f1fdaed615509215c64308fc
README.zh.md: ca35d7cd2e7ff176f4ea40d1e9a3a6d1a7457462

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 grouped Session reorder. A Workspace remembers whether it is closed or showing Sessions; an open Workspace shows five Sessions by default, offers a transient **Show more** control for the remainder, and returns to five after the whole Workspace is closed and reopened. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped. View options combine grouping with one browser-persisted Session order per group: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped initializes from recency. Entering **Last updated** performs a complete recency sort and later user prompts or steers promote their Session once, while entering **Manual** preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped order remains browser-local because no Workspace owns those Sessions. Workspace drag order is Host-durable in either Session order mode.
The browser renders grouped or flat Session rows from the global runtime hooks and owns Workspace add/rename/reorder plus 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. Creating a Session from a Workspace row first opens that group so the new row remains visible when the Session state arrives. Once the Workspace list baseline is ready, browser-persisted expansion and Session-order records retain only current Workspace ids plus Ungrouped and the flat-list account. View options combine grouping with one browser-persisted Session order per account: real Workspaces initialize from `WorkspaceView.sessionIds`, while Ungrouped and the cross-Workspace flat list initialize from recency. **Manual** and **Last updated** apply in either presentation. Entering Last updated performs a complete recency sort and later user prompts or steers promote their Session once, while entering Manual preserves every current position and disables later promotion. Dragging edits the current order in either mode; Manual-mode drags for real Workspaces also update the Host Session account, while Ungrouped and flat-list orders remain browser-local because neither has one Workspace account. Flat rows omit the empty leading status slot because they have no parent hierarchy, but retain it when a Session status is visible. Workspace drag order is Host-durable in either Session order mode.
Collapsed search is one header action beside the view and add actions. Activating it expands the field across the header; an outside click collapses only a query that is empty after trimming, while the clear control always resets and collapses it. A non-blank search query replaces either browsing mode with one flat result list: case-insensitive title and Workspace substring matches appear immediately, while a 250 ms debounced Host request adds ranked current-conversation content matches and snippets. The English search input and its defensive request path remove NUL, cap the query at the wire schema's 500 UTF-16 code units without splitting a surrogate pair, and preserve the existing debounce and cancellation behavior. Each new query aborts the preceding request; a failed content search leaves metadata matches visible with a warning. The list is capped at 20, asks the user to narrow broader queries, and opens the selected Session without clearing the query or jumping to a specific event.

View File

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

View File

@@ -22,6 +22,7 @@ import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode, SessionOrderBy } from './tree.ts'
import { deriveFlat, deriveGroups, deriveSearchResults, UNGROUPED_KEY } from './tree.ts'
import { ProjectRowItem, SearchResultItem, SessionNodeItem } from './rows/Rows.tsx'
import { FLAT_SESSION_ORDER_KEY } from './stores.ts'
import { WorkspacePickFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
@@ -80,6 +81,46 @@ function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListStat
return a < b ? -1 : 1
}
/** Reconcile one editable order account and apply its activity-promotion policy. */
function nextSessionOrderAccount({
sessionIds, previousOrder, previousUpdatedAt, list, orderBy, sortByRecency,
}: {
sessionIds: readonly SessionId[]
previousOrder: readonly string[] | undefined
previousUpdatedAt: Readonly<Record<string, number>>
list: SessionListState
orderBy: SessionOrderBy
sortByRecency: boolean
}): { order: SessionId[]; updatedAt: Record<string, number>; changed: boolean } {
let order = reconciledSessionOrder(sessionIds, previousOrder)
if (sortByRecency) {
order.sort((a, b) => compareSessionRecency(a, b, list.byId))
} else if (orderBy === 'updated') {
const promoted = sessionIds
.filter((id) => {
const session = list.byId[id]
return session !== undefined
&& (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
})
.sort((a, b) => compareSessionRecency(a, b, list.byId))
if (promoted.length > 0) {
const promotedIds = new Set(promoted)
order = [...promoted, ...order.filter(id => !promotedIds.has(id))]
}
}
const updatedAt: Record<string, number> = {}
for (const id of sessionIds) {
const session = list.byId[id]
if (session !== undefined) updatedAt[id] = session.updatedAt
}
const orderChanged = previousOrder === undefined
|| order.length !== previousOrder.length
|| order.some((id, index) => id !== previousOrder[index])
const timestampsChanged = Object.keys(updatedAt).length !== Object.keys(previousUpdatedAt).length
|| Object.entries(updatedAt).some(([id, timestamp]) => previousUpdatedAt[id] !== timestamp)
return { order, updatedAt, changed: orderChanged || timestampsChanged }
}
/** Grouping and ordering menu; own open state so it resets with the wide chrome. */
function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
groupBy: 'workspace' | 'flat'
@@ -97,8 +138,9 @@ function ViewOptionsMenu({ groupBy, orderBy, onGroupPick, onOrderPick, t }: {
{ type: 'label' as const, id: 'group-by', text: t('groupBy.label') },
{ id: 'workspace', label: t('groupBy.workspace') },
{ id: 'flat', label: t('groupBy.flat') },
{ type: 'separator' as const, id: 'order-by-separator' },
{ type: 'label' as const, id: 'order-by', text: t('orderBy.label') },
{ id: 'manual', label: t('orderBy.manual'), disabled: groupBy !== 'workspace' },
{ id: 'manual', label: t('orderBy.manual') },
{ id: 'updated', label: t('orderBy.updated') },
]}
selectedIds={[groupBy, orderBy]}
@@ -159,7 +201,7 @@ type SessionTreeProps = Pick<
workspaceExpansion: Readonly<Record<string, boolean>>
/** Persist one Workspace group's zero-or-five-session state. */
setWorkspaceExpanded: (key: string, expanded: boolean) => void
/** Shared editable orders used by both Session order modes. */
/** Shared editable orders used by Workspace groups and the flat-list account. */
recentSessionOrder: Readonly<Record<string, readonly string[]>>
/** Last update timestamps observed for one-time recent-update promotions. */
recentSessionUpdatedAt: Readonly<Record<string, Readonly<Record<string, number>>>>
@@ -246,34 +288,16 @@ function SessionTree({
for (const { key, sessionIds } of accounts) {
const previousOrder = recentSessionOrder[key]
const previousUpdatedAt = recentSessionUpdatedAt[key] ?? {}
let nextOrder = reconciledSessionOrder(sessionIds, previousOrder)
if (orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated)) {
nextOrder.sort((a, b) => compareSessionRecency(a, b, list.byId))
} else if (orderBy === 'updated') {
const promoted = sessionIds
.filter((id) => {
const session = list.byId[id]
return session !== undefined
&& (previousUpdatedAt[id] === undefined || session.updatedAt > previousUpdatedAt[id])
})
.sort((a, b) => compareSessionRecency(a, b, list.byId))
if (promoted.length > 0) {
const promotedIds = new Set(promoted)
nextOrder = [...promoted, ...nextOrder.filter(id => !promotedIds.has(id))]
}
}
const nextUpdatedAt: Record<string, number> = {}
for (const id of sessionIds) {
const session = list.byId[id]
if (session !== undefined) nextUpdatedAt[id] = session.updatedAt
}
const orderChanged = previousOrder === undefined
|| nextOrder.length !== previousOrder.length
|| nextOrder.some((id, index) => id !== previousOrder[index])
const timestampsChanged = Object.keys(nextUpdatedAt).length !== Object.keys(previousUpdatedAt).length
|| Object.entries(nextUpdatedAt).some(([id, updatedAt]) => previousUpdatedAt[id] !== updatedAt)
if (orderChanged || timestampsChanged) {
syncRecentSessions(key, nextOrder.map(id => id as string), nextUpdatedAt)
const next = nextSessionOrderAccount({
sessionIds,
previousOrder,
previousUpdatedAt,
list,
orderBy,
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
})
if (next.changed) {
syncRecentSessions(key, next.order.map(id => id as string), next.updatedAt)
}
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, ungroupedSessionIds, workspaces])
@@ -512,15 +536,90 @@ function SessionTree({
)
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds, t }: Pick<
SessionTreeProps, 'useSessions' | 'open' | 'forkSession' | 'onSessionRename' | 'onSessionArchive' | 'archivedSessionIds' | 't'
/** The flat "In one list" body: every session is one draggable top-level row. */
function FlatList({
useSessions, open, forkSession, onSessionRename, onSessionArchive, archivedSessionIds,
orderBy, recentSessionOrder, recentSessionUpdatedAt, syncRecentSessions, setRecentSessionOrder, t,
}: Pick<
SessionTreeProps,
| 'useSessions'
| 'open'
| 'forkSession'
| 'onSessionRename'
| 'onSessionArchive'
| 'archivedSessionIds'
| 'orderBy'
| 'recentSessionOrder'
| 'recentSessionUpdatedAt'
| 'syncRecentSessions'
| 'setRecentSessionOrder'
| 't'
>) {
const list = useSessions(s => s)
const rows = useMemo(
const baseRows = useMemo(
() => deriveFlat(list, archivedSessionIds),
[list, archivedSessionIds],
)
const sessionIds = useMemo(() => baseRows.map(row => row.id), [baseRows])
const previousOrderBy = useRef(orderBy)
useEffect(() => {
if (list.phase !== 'ready') return
const previousOrder = recentSessionOrder[FLAT_SESSION_ORDER_KEY]
const previousUpdatedAt = recentSessionUpdatedAt[FLAT_SESSION_ORDER_KEY] ?? {}
const switchedToUpdated = previousOrderBy.current !== 'updated' && orderBy === 'updated'
previousOrderBy.current = orderBy
const next = nextSessionOrderAccount({
sessionIds,
previousOrder,
previousUpdatedAt,
list,
orderBy,
sortByRecency: orderBy === 'updated' && (previousOrder === undefined || switchedToUpdated),
})
if (next.changed) {
syncRecentSessions(FLAT_SESSION_ORDER_KEY, next.order.map(id => id as string), next.updatedAt)
}
}, [list, orderBy, recentSessionOrder, recentSessionUpdatedAt, sessionIds, syncRecentSessions])
const rows = useMemo(() => {
const byId = new Map(baseRows.map(row => [row.id, row]))
return reconciledSessionOrder(sessionIds, recentSessionOrder[FLAT_SESSION_ORDER_KEY])
.flatMap((id) => {
const row = byId.get(id)
return row === undefined ? [] : [row]
})
}, [baseRows, recentSessionOrder, sessionIds])
const [drag, setDrag] = useState<DragState | null>(null)
const dropCommitted = useRef(false)
useEffect(() => {
if (drag === null) return
const acceptDrag = (event: DragEvent): void => {
event.preventDefault()
if (event.dataTransfer !== null) event.dataTransfer.dropEffect = 'move'
}
const acceptDrop = (event: DragEvent): void => { event.preventDefault() }
document.addEventListener('dragover', acceptDrag)
document.addEventListener('drop', acceptDrop)
return () => {
document.removeEventListener('dragover', acceptDrag)
document.removeEventListener('drop', acceptDrop)
}
}, [drag])
const commitDrag = (activeDrag: DragState, over: NonNullable<DragState['over']>): void => {
if (dropCommitted.current) return
dropCommitted.current = true
setDrag(null)
const targetIndex = rows.findIndex(row => row.id === over.id)
if (targetIndex === -1) return
const anchor = over.half === 'before' ? over.id : rows[targetIndex + 1]?.id
if (anchor === activeDrag.sessionId) return
const sourceIndex = rows.findIndex(row => row.id === activeDrag.sessionId)
const anchorIndex = anchor === undefined ? rows.length : rows.findIndex(row => row.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
const nextOrder = rows.map(row => row.id).filter(id => id !== activeDrag.sessionId)
const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
setRecentSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
}
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
@@ -528,19 +627,42 @@ function FlatList({ useSessions, open, forkSession, onSessionRename, onSessionAr
{rows.length === 0 && (
<div className={css.empty}>{t('empty.none')}</div>
)}
{rows.map(node => (
<SessionNodeItem
key={node.id}
node={node}
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
onArchive={onSessionArchive}
t={t}
/>
))}
{rows.map((node) => {
const active = drag !== null
return (
<SessionNodeItem
key={node.id}
node={node}
currentId={list.current}
now={now}
onOpen={open}
onRename={onSessionRename}
onFork={forkSession}
onArchive={onSessionArchive}
flat
drag={{
start: () => {
dropCommitted.current = false
setDrag({ workspaceKey: FLAT_SESSION_ORDER_KEY, sessionId: node.id, over: null })
},
active,
marker: active && drag.over?.id === node.id ? drag.over.half : null,
hover: (half) => {
setDrag(current => current === null ? current : { ...current, over: { id: node.id, half } })
},
drop: (half) => {
if (drag !== null) commitDrag(drag, { id: node.id, half })
},
end: () => {
if (drag?.over !== null && drag?.over !== undefined) commitDrag(drag, drag.over)
else setDrag(null)
dropCommitted.current = false
},
}}
t={t}
/>
)
})}
</div>
<span className={css.fade} />
</div>
@@ -654,9 +776,6 @@ export function WorkspaceBrowser({
const directoryFlowAvailable = useDirectoryFlow(occupied => occupied)
const groupBy = useStore(s => s.groupBy)
const orderBy = useStore(s => s.orderBy)
// A flat list has no single Workspace account to drag. Keep the stored
// grouped preference intact while presenting the flat list by recency.
const effectiveOrderBy = groupBy === 'flat' && orderBy === 'manual' ? 'updated' : orderBy
const workspaceExpansion = useStore(s => s.workspaceExpansion)
const recentSessionOrder = useStore(s => s.recentSessionOrder)
const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt)
@@ -664,6 +783,7 @@ export function WorkspaceBrowser({
if (workspacePhase !== 'ready') return
actions.retainWorkspaceKeys([
UNGROUPED_KEY,
FLAT_SESSION_ORDER_KEY,
...workspaces.map(workspace => workspace.workspaceId as string),
])
}, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
@@ -928,7 +1048,7 @@ export function WorkspaceBrowser({
{wide && (
<ViewOptionsMenu
groupBy={groupBy}
orderBy={effectiveOrderBy}
orderBy={orderBy}
onGroupPick={(mode) => { actions.setGroupBy(mode) }}
onOrderPick={(mode) => { actions.setOrderBy(mode) }}
t={t}
@@ -1011,7 +1131,13 @@ export function WorkspaceBrowser({
<FlatList
useSessions={useSessions} open={open} forkSession={forkSession}
onSessionRename={onSessionRename} onSessionArchive={onSessionArchive}
archivedSessionIds={archivedSessionIds} t={t}
archivedSessionIds={archivedSessionIds}
orderBy={orderBy}
recentSessionOrder={recentSessionOrder}
recentSessionUpdatedAt={recentSessionUpdatedAt}
syncRecentSessions={actions.syncRecentSessions}
setRecentSessionOrder={actions.setRecentSessionOrder}
t={t}
/>
)
: (

View File

@@ -117,6 +117,10 @@
margin: 0 6px 0 4px;
}
.flatSessionRowWithoutStatus .title {
margin-left: 0;
}
@keyframes row-in {
from { opacity: 0; }
}

View File

@@ -339,10 +339,11 @@ export function SearchResultItem({ result, currentId, onOpen, t }: {
* @param props.onFork - fork a session at its last completed turn.
* @param props.onArchive - archive a session by id.
* @param props.drag - optional draggable-row wiring.
* @param props.flat - omit the empty status slot in the hierarchy-free flat list.
* @param props.t - the browser root's locale seat.
* @returns the session row.
*/
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, t }: {
export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, drag, flat = false, t }: {
node: SessionNode
currentId: string | undefined
now: number
@@ -355,6 +356,8 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
onArchive: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group sessions outside search). */
drag?: RowDragProps | undefined
/** The row is rendered without a parent Workspace header. */
flat?: boolean | undefined
t: RowTranslate
}) {
const row = node
@@ -362,6 +365,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
const selected = node.id === currentId
const statuses = sessionStatuses(node, t)
const primaryStatus = statuses[0]
const showStatus = primaryStatus.state !== 'done' || row.completed
const [menuOpen, setMenuOpen] = useState(false)
// Archive hides the row through the registry-global archive set and never
// touches the session log, so it is not styled as destructive and needs no
@@ -377,6 +381,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
<div
className={clsx(
css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
flat && !showStatus && css.flatSessionRowWithoutStatus,
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
)}
role="treeitem"
@@ -410,16 +415,18 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
{/* Pending interaction and own or descendant activity outrank the
finished-but-unviewed reminder, which returns after activity stops
and is cleared by opening the session. */}
<span className={css.slot}>
{(primaryStatus.state !== 'done' || row.completed) && (
<>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>
{(!flat || showStatus) && (
<span className={css.slot}>
{showStatus && (
<>
<StateDot state={primaryStatus.state} />
{statuses.map(status => (
<span className={css.visuallyHidden} key={status.label}>{status.label}</span>
))}
</>
)}
</span>
)}
<span className={css.title}>{title}</span>
{/* A blank New Session row is a provisional placeholder: nothing has
happened in it yet, so a "now" timestamp and the row verbs

View File

@@ -7,6 +7,9 @@
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** Browser-local order account for the hierarchy-free flat Session list. */
export const FLAT_SESSION_ORDER_KEY = '__flat_session_order__'
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Session order: user-arranged only, or user-arranged plus activity promotion. */
@@ -18,9 +21,9 @@ type WorkspaceViewState = {
orderBy: WorkspaceOrderBy
/** Explicit zero-or-five-session state keyed by Workspace group identity. */
workspaceExpansion: Record<string, boolean>
/** Shared editable per-Workspace order; recent-update mode may promote rows within it. */
/** Shared editable order per Workspace group plus the browser-local flat-list account. */
recentSessionOrder: Record<string, string[]>
/** Last observed update timestamps used to detect one-time promotion events. */
/** Last observed update timestamps per order account for one-time promotion events. */
recentSessionUpdatedAt: Record<string, Record<string, number>>
}

View File

@@ -102,6 +102,7 @@ describe('WorkspaceBrowser.module.css list', () => {
expect(declarations('.searchExpanded')?.get('height')).toBe('30px')
expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px')
expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px')
expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0')
expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
expect(rowDeclarations('.sessionRow.selected')?.get('background'))
.toBe('var(--dsw-alias-interactive-bg-hover)')

View File

@@ -57,6 +57,21 @@ function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number):
}
describe('workspace browser rows', () => {
it('omits only an empty leading status slot in the hierarchy-free flat list', () => {
const idle: SessionNode = {
id: sid('flat'), title: 'Flat Session', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0,
}
const view = render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={vi.fn()}
onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
const title = screen.getByText('Flat Session')
expect(title.previousElementSibling).toBeNull()
view.rerender(<SessionNodeItem node={{ ...idle, running: true }} currentId={undefined} now={0}
onOpen={vi.fn()} onRename={vi.fn()} onFork={vi.fn()} onArchive={vi.fn()} flat t={t} />)
expect(screen.getByText('Flat Session').previousElementSibling?.querySelector('[data-state="ongoing"]')).toBeTruthy()
})
it('renders a selected content-search row and opens only its session', () => {
const onOpen = vi.fn()
const result: SearchResultNode = {

View File

@@ -8,7 +8,7 @@ import type {
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { createWorkspaceViewStore, FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { zh } from '../src/client/locales.ts'
@@ -130,6 +130,7 @@ describe('WorkspaceBrowser', () => {
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
expect(screen.getByText('分组方式')).toBeTruthy() // the menu heading label
expect(screen.getByRole('separator')).toBeTruthy()
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'按工作区', '单列表', '手动排序', '最近更新',
])
@@ -145,6 +146,7 @@ describe('WorkspaceBrowser', () => {
// Back to workspace grouping through the same menu.
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
expect(screen.getByRole('menuitem', { name: '手动排序' }).hasAttribute('disabled')).toBe(false)
fireEvent.click(screen.getByRole('menuitem', { name: '按工作区' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('工作区')).toBeTruthy()
@@ -156,6 +158,60 @@ describe('WorkspaceBrowser', () => {
expect(b.store.getSnapshot().groupBy).toBe('workspace')
})
it('persists flat-list drag order locally and applies Last updated within that account', async () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
const workspaces = workspaceState([
workspace('alpha', ['one']),
workspace('beta', ['two']),
])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaces),
insertSessionBefore,
})
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
.toEqual(['one', 'two', 'three'])
})
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement
three.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 150, toJSON: () => ({}),
})
fireEvent.dragStart(one, { dataTransfer: dragData() })
fireDrag(three, 'drop', 180)
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
.toEqual(['two', 'three', 'one'])
expect(insertSessionBefore).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
await waitFor(() => {
expect(b.store.getSnapshot().recentSessionOrder[FLAT_SESSION_ORDER_KEY])
.toEqual(['one', 'two', 'three'])
})
fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
fireEvent.click(screen.getByRole('menuitem', { name: '手动排序' }))
fireEvent.dragStart(one, { dataTransfer: dragData() })
fireDrag(three, 'drop', 180)
b.view.unmount()
const restored = mount({ useSessions: hook(sessions), useWorkspaces: hook(workspaces) })
expect(restored.store.getSnapshot().groupBy).toBe('flat')
expect(restored.store.getSnapshot().orderBy).toBe('manual')
expect(screen.getAllByRole('treeitem').map(row => row.textContent)).toEqual([
expect.stringContaining('two'),
expect.stringContaining('three'),
expect.stringContaining('one'),
])
})
it('expands a group on click and opens a session row', () => {
const open = vi.fn()
mount({