fix(client): prune removed workspace view state
This commit is contained in:
+2
-2
@@ -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/feature/2026-08-11-workspace-sidebar-order-and-folding.md
|
||||
2026-08-11-workspace-sidebar-order-and-folding.md: dd3c00f07f2e6c67450aecdf74d6e501cbc56402
|
||||
2026-08-11-workspace-sidebar-order-and-folding.zh.md: 14b353b50522547658e9a0cd4851cbfd4bdb7d1d
|
||||
2026-08-11-workspace-sidebar-order-and-folding.md: 78d8399b2f5cb1f8f33ffe3ba89ff27944ae8e63
|
||||
2026-08-11-workspace-sidebar-order-and-folding.zh.md: 87449ddd7265a6bcb11d7a2e4ffc8ac2a5c9257c
|
||||
@@ -20,13 +20,13 @@ The client installs a Workspace drag optimistically. Request and frame generatio
|
||||
|
||||
### Session folding and view order
|
||||
|
||||
Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace.
|
||||
Each Workspace persists one browser-local open state: closed means zero Session rows and open means up to five. When more Sessions exist, **Show more** reveals the remainder only for the current mount; closing the whole Workspace clears this transient expansion, so reopening returns to five. The current Session's group opens automatically only when the user has not already stored an explicit state for that Workspace. After a ready Workspace baseline changes, the browser removes expansion, order, and observed-timestamp records for ids absent from that baseline while retaining the Ungrouped expansion key.
|
||||
|
||||
The combined view menu offers **Manual** and **Last updated** over one browser-local, persisted per-Workspace order initialized from `WorkspaceView.sessionIds`. Entering Last updated performs one complete recency sort; a later user prompt or steer promotes that Session once, and dragging may edit the resulting order. Returning to Manual preserves the current order and only disables later activity promotion. Manual-mode drags also write the Host Session account, while activity promotion remains a browser-local presentation choice. The flat list uses recency because it has no single Workspace account for durable Session drag.
|
||||
|
||||
### Drag and compact chrome
|
||||
|
||||
Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so its hollow leading circle remains visible without moving the list. During a Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker.
|
||||
Workspace hit testing uses the complete rendered group section, including visible Session rows. One insertion boundary is shared by the preceding group's lower half and the following group's upper half, and the indicator is an absolutely positioned line that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so its hollow leading circle remains visible without moving the list. During a Workspace or Session drag, document-level `dragover` and `drop` handlers accept the native operation; if release occurs outside the Workspace list, `dragend` commits the last valid marker.
|
||||
|
||||
Search is a header action while collapsed and expands across the title and trailing actions. An outside click collapses a query that is empty after trimming but retains a non-empty query. Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances.
|
||||
|
||||
@@ -53,4 +53,4 @@ Search is a header action while collapsed and expands across the title and trail
|
||||
|
||||
## Testing
|
||||
|
||||
Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, and order frames. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, order-preserving mode switches, one-time recent-update promotion, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Session drops, search collapse rules, and compact CSS dimensions.
|
||||
Domain and Host tests cover durable Workspace moves, no-op and invalid anchors, restart recovery, full-order RPC responses, order frames, and one Workspace snapshot per Host-stream baseline. Runtime tests cover optimistic order, frame/response precedence, overlapping rejection rollback to Host-confirmed order, reconnect baselines, and New Session target priority. UI tests cover five-row folding, transient expansion reset, pruning persisted state after Workspace removal, order-preserving mode switches, one-time recent-update promotion, selected view indicators, expanded-section Workspace hit testing, an unclipped first insertion boundary, outside-list Workspace and Session drops, search collapse rules, and compact CSS dimensions.
|
||||
+3
-3
@@ -20,13 +20,13 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
|
||||
|
||||
### Session 折叠与视图顺序
|
||||
|
||||
每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。
|
||||
每个 Workspace 持久化一项浏览器本地打开状态:关闭表示零条 Session 行,打开表示最多五条。存在更多 Session 时,**展开其余**只在当前挂载期间显示剩余项;关闭整个 Workspace 会清除此临时展开,因此重新打开时恢复为五条。只有在用户尚未为该 Workspace 存储明确状态时,当前 Session 所在分组才会自动打开。就绪的 Workspace 基线发生变化后,浏览器会移除基线中不存在 id 的展开状态、顺序和已观察时间戳记录,同时保留 Ungrouped 的展开键。
|
||||
|
||||
组合视图菜单在同一份按 Workspace 持久于浏览器本地、由 `WorkspaceView.sessionIds` 初始化的顺序上提供**手动排序**和**最近更新**。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。手动模式下的拖拽还会写入 Host Session 记账,而活动置顶仍属于浏览器本地呈现选择。平铺列表没有可承载持久 Session 拖拽的单一 Workspace 记账,因此使用最近更新顺序。
|
||||
|
||||
### 拖拽与紧凑界面
|
||||
|
||||
Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧空心圆保持可见,列表位置也不会改变。Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
|
||||
Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧空心圆保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
|
||||
|
||||
搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
|
||||
|
||||
@@ -53,4 +53,4 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
|
||||
|
||||
## 测试
|
||||
|
||||
领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应和顺序帧。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、保持顺序的模式切换、一次性最近更新置顶、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Session 松手、搜索收起规则和紧凑 CSS 尺寸。
|
||||
领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。
|
||||
@@ -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: 359979fa26b0e12c7e28b929029fb7d4e9fca324
|
||||
README.zh.md: 2139ea3cf7ec3ae84aaa84e181d2f36a60c9ba60
|
||||
README.md: 3308044abe32ba36d45b02ddacba93772265d315
|
||||
README.zh.md: 38ccb89e0a1fa03ce2d2bab08e7605d8c60f2472
|
||||
@@ -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 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.
|
||||
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. 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: 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 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.
|
||||
|
||||
|
||||
@@ -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 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
|
||||
该浏览器通过全局运行时钩子将 Session 行渲染为分组或扁平形式,并负责 Workspace 添加/重命名/重排序以及 Workspace 内的 Session 重排序。每个 Workspace 会记住自身是关闭还是显示 Session;打开后默认显示五条 Session,其余条目通过临时的**展开其余**控件显示,而关闭并重新打开整个 Workspace 后会恢复为五条。Workspace 列表基线就绪后,浏览器持久化的展开状态与 Session 顺序记录只保留当前 Workspace id 和 Ungrouped。视图选项把分组方式和一份浏览器持久化的 Session 顺序放在一起:进入**最近更新**时执行一次完整的时间排序,后续 user prompt 或 steer 会将对应 Session 置顶一次;进入**手动排序**则保留所有当前位置并停用后续置顶。两种模式下的拖拽都会编辑当前顺序,手动模式下的拖拽还会更新 Host Workspace 记账。无论采用哪种 Session 顺序,Workspace 拖拽顺序都由 Host 持久化。
|
||||
|
||||
折叠搜索是视图和添加操作旁的一枚区头按钮。激活后,输入框会扩展并占据区头;点击外部只会收起经清除首尾空白后为空的查询,而清除控件总会重置并收起搜索。非空白查询会以单一扁平结果列表替代任一浏览模式:不区分大小写的标题和 Workspace 子串匹配项会立即显示,经 250 ms 防抖的 Host 请求则会加入经过排序的当前对话内容匹配项及其摘要片段。英文搜索输入框及其防御性请求路径会移除 NUL,将查询限制在传输 schema 规定的 500 个 UTF-16 代码单元内且不会拆分代理项对,并保留现有的防抖与取消行为。每次新查询都会中止前一个请求;内容搜索失败时,元数据匹配项仍会显示,同时给出警告。列表最多显示 20 条结果,并会在查询过宽时提示用户缩小范围;打开所选 Session 时既不会清除查询,也不会跳转至特定事件。
|
||||
|
||||
|
||||
@@ -625,6 +625,7 @@ export function WorkspaceBrowser({
|
||||
t,
|
||||
}: WorkspaceBrowserProps) {
|
||||
const workspaces = useWorkspaces(state => state.items)
|
||||
const workspacePhase = useWorkspaces(state => state.phase)
|
||||
const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds)
|
||||
// Live occupancy of this surface's directory-flow hole (the same source the
|
||||
// flow reads): a composition without a picking affordance can add nothing.
|
||||
@@ -637,6 +638,13 @@ export function WorkspaceBrowser({
|
||||
const workspaceExpansion = useStore(s => s.workspaceExpansion)
|
||||
const recentSessionOrder = useStore(s => s.recentSessionOrder)
|
||||
const recentSessionUpdatedAt = useStore(s => s.recentSessionUpdatedAt)
|
||||
useEffect(() => {
|
||||
if (workspacePhase !== 'ready') return
|
||||
actions.retainWorkspaceKeys([
|
||||
UNGROUPED_KEY,
|
||||
...workspaces.map(workspace => workspace.workspaceId as string),
|
||||
])
|
||||
}, [actions.retainWorkspaceKeys, workspacePhase, workspaces])
|
||||
// The query outlives the tree and the input (both wide-only) so collapsing
|
||||
// does not silently drop an in-progress filter.
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
@@ -32,6 +32,7 @@ type WorkspaceViewActions = {
|
||||
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
|
||||
setOrderBy: (draft: WorkspaceViewState, mode: WorkspaceOrderBy) => void
|
||||
setWorkspaceExpanded: (draft: WorkspaceViewState, key: string, expanded: boolean) => void
|
||||
retainWorkspaceKeys: (draft: WorkspaceViewState, workspaceKeys: readonly string[]) => void
|
||||
syncRecentSessions: (
|
||||
draft: WorkspaceViewState,
|
||||
workspaceKey: string,
|
||||
@@ -59,6 +60,18 @@ export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState
|
||||
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
|
||||
setOrderBy: (d, mode: WorkspaceOrderBy) => { d.orderBy = mode },
|
||||
setWorkspaceExpanded: (d, key: string, expanded: boolean) => { d.workspaceExpansion[key] = expanded },
|
||||
retainWorkspaceKeys: (d, workspaceKeys: readonly string[]) => {
|
||||
const retained = new Set(workspaceKeys)
|
||||
d.workspaceExpansion = Object.fromEntries(
|
||||
Object.entries(d.workspaceExpansion).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
d.recentSessionOrder = Object.fromEntries(
|
||||
Object.entries(d.recentSessionOrder).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
d.recentSessionUpdatedAt = Object.fromEntries(
|
||||
Object.entries(d.recentSessionUpdatedAt).filter(([key]) => retained.has(key)),
|
||||
)
|
||||
},
|
||||
syncRecentSessions: (d, workspaceKey: string, order: string[], updatedAt: Record<string, number>) => {
|
||||
d.recentSessionOrder[workspaceKey] = order
|
||||
d.recentSessionUpdatedAt[workspaceKey] = updatedAt
|
||||
|
||||
@@ -395,6 +395,22 @@ describe('createWorkspaceViewStore', () => {
|
||||
recentSessionUpdatedAt: { alpha: { one: 1, two: 2 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('removes view state outside the retained Workspace key set', () => {
|
||||
const store = createWorkspaceViewStore().create()
|
||||
store.actions.setWorkspaceExpanded('', true)
|
||||
store.actions.setWorkspaceExpanded('alpha', true)
|
||||
store.actions.setWorkspaceExpanded('deleted', true)
|
||||
store.actions.syncRecentSessions('alpha', ['alpha-session'], { 'alpha-session': 2 })
|
||||
store.actions.syncRecentSessions('deleted', ['deleted-session'], { 'deleted-session': 1 })
|
||||
|
||||
store.actions.retainWorkspaceKeys(['', 'alpha'])
|
||||
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.workspaceExpansion).toEqual({ '': true, alpha: true })
|
||||
expect(snapshot.recentSessionOrder).toEqual({ alpha: ['alpha-session'] })
|
||||
expect(snapshot.recentSessionUpdatedAt).toEqual({ alpha: { 'alpha-session': 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectLabel', () => {
|
||||
|
||||
@@ -94,6 +94,28 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
|
||||
}
|
||||
|
||||
describe('WorkspaceBrowser', () => {
|
||||
it('prunes deleted Workspace view state only after the Workspace baseline is ready', async () => {
|
||||
const pending = {
|
||||
...workspaceState([]),
|
||||
phase: 'pending' as const,
|
||||
state: 'loading' as const,
|
||||
baselinesReady: false,
|
||||
}
|
||||
const b = mount({ useWorkspaces: hook(pending) })
|
||||
act(() => {
|
||||
b.store.actions.setWorkspaceExpanded('deleted', true)
|
||||
b.store.actions.syncRecentSessions('deleted', ['session'], { session: 1 })
|
||||
})
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({ deleted: true })
|
||||
|
||||
rerender(b, { useWorkspaces: hook(workspaceState([])) })
|
||||
await waitFor(() => {
|
||||
expect(b.store.getSnapshot().workspaceExpansion).toEqual({})
|
||||
expect(b.store.getSnapshot().recentSessionOrder).toEqual({})
|
||||
expect(b.store.getSnapshot().recentSessionUpdatedAt).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
|
||||
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
|
||||
const b = mount({
|
||||
|
||||
Reference in New Issue
Block a user