fix(client): prune removed workspace view state
This commit is contained in:
@@ -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