ソースを参照

fix(workspace): retain blank manual positions during reconnect

Dudu-0223 2 週間 前
親
コミット
11248ea949

+ 2 - 2
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.i18n.yaml

@@ -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: 909edd63a99f7f590257a0493a3f8907c5f2722c
-2026-09-10-derived-workspace-recency.zh.md: fcca801bfaa5a01b48f46a0e2782c2b54c17bf4a
+2026-09-10-derived-workspace-recency.md: e8f304110c89bf563164dd24125beeb36e7644a6
+2026-09-10-derived-workspace-recency.zh.md: 60f2da36c277e77d367f152aa8a3159ff68d909c

+ 1 - 1
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.md

@@ -14,7 +14,7 @@ Last updated is a pure projection of current Session summaries: ordinary rows so
 
 Only Manual uses browser-persisted Session display order. A Session drag snapshots every active account, applies the move to its browser-local account, and selects Manual atomically. Entering Manual also freezes every active account from the current chronological ordering. Returning to Last updated discards the manual layout; entering Manual again starts from the current timestamps. Reconciliation retains saved members that remain in the Workspace account, removes departed members, and appends newly known members at the end, newest first when several arrive together. New members without summaries wait for the summary, while previously saved slots survive a temporarily missing summary. Workspace membership and Workspace-group order remain Host-owned; Workspace-group drags still call the Host reorder operation.
 
-The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
+The selected blank New Session is a display invariant applied after either base order: it remains first and cannot start a drag. Manual persists the observed blank position even while the Workspace stream reconnects, preserving every other saved member until the complete baseline permits reconciliation. Waiting to record that position would lose it if the first prompt arrives before the baseline. Its first prompt removes the blank state. Manual then keeps the existing first slot and allows dragging; Last updated projects it from the prompt timestamp. The ordering subscription and reconciliation remain mounted when the sidebar is collapsed or search replaces the list body.
 
 The persistence key stores grouping, expansion, and saved Manual positions. Persisted observed-timestamp data is removed when active account keys are retained. Current metadata owns timestamp accuracy: a cold summary without prompt metadata can fall back to creation time, independently of view ordering.
 

+ 1 - 1
.agents/notes/implemented/simplification/2026-09-10-derived-workspace-recency.zh.md

@@ -14,7 +14,7 @@ Status: implemented
 
 只有手动排序使用浏览器持久化的 Session 显示顺序。拖拽 Session 会一次性记录所有有效记账、在对应的浏览器本地记账中应用移动,并原子地选中手动排序。直接进入手动排序同样会从当前时间顺序冻结所有有效记账。返回最近更新会丢弃手动布局;再次进入手动排序时,从当前时间戳确定的顺序开始。对账会保留仍属于 Workspace 记账的已保存成员、移除已经离开的成员,并把新发现的成员追加到末尾;多条同时到达时按最近更新时间降序排列。尚无摘要的新成员会等待摘要,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 成员关系和 Workspace 分组顺序仍由 Host 拥有;拖拽 Workspace 分组仍会调用 Host 重排序操作。
 
-当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
+当前选中的空白新会话是在两种基础顺序之后应用的显示不变式:它保持首位且无法发起拖拽。即使 Workspace 流正在重连,手动排序也会持久化已观察到的空白行位置,并保留其他所有已保存成员,直到完整基线允许对账。若等待基线后才记录该位置,首条提示词先到达时就会丢失它。首条提示词会移除空白状态;此后,手动排序保留原有首位并允许拖拽,最近更新则按提示词时间戳派生其位置。侧边栏折叠或搜索替代列表主体时,排序订阅和对账仍保持挂载。
 
 持久化 key 保存分组方式、展开状态和手动排序位置。保留有效记账 key 时会移除持久化的已观察时间数据。时间准确性由当前元数据负责:缺少提示词元数据的冷摘要可能回退到创建时间,这与视图排序相互独立。
 

+ 44 - 2
apps/web/tests/workspace-recency.e2e.ts

@@ -2,7 +2,7 @@
 import { readFile } from 'node:fs/promises'
 import { basename, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
-import { chromium, type Browser, type Page } from 'playwright'
+import { chromium, type Browser, type Page, type WebSocketRoute } from 'playwright'
 import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
 import {
   acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
@@ -20,6 +20,9 @@ describe('web e2e: workspace recency', () => {
   let browser: Browser
   let page: Page
   let tripwire: ReturnType<typeof watchConsole>
+  let socket: WebSocketRoute
+  let holdWorkspace = false
+  let releaseWorkspace: (() => void) | undefined
 
   beforeAll(async () => {
     scaffold = await launchWebScaffold({})
@@ -37,6 +40,29 @@ describe('web e2e: workspace recency', () => {
     }
     browser = await chromium.launch()
     page = await newEnglishPage(browser)
+    await page.routeWebSocket('**/api/remote.mux', (route) => {
+      socket = route
+      const server = route.connectToServer()
+      let heldStream: string | undefined
+      const pending: (string | Buffer)[] = []
+      server.onMessage((message) => {
+        const frame = JSON.parse(String(message)) as {
+          type: string
+          streamId: string
+          value?: { type: string; value?: { archivedSessionIds?: string[] } }
+        }
+        if (holdWorkspace && frame.type === 'item' && frame.value?.type === 'baseline'
+          && frame.value.value?.archivedSessionIds !== undefined) {
+          heldStream = frame.streamId
+          releaseWorkspace = () => {
+            heldStream = undefined
+            for (const buffered of pending.splice(0)) route.send(buffered)
+          }
+        }
+        if (frame.streamId === heldStream) pending.push(message)
+        else route.send(message)
+      })
+    })
     await page.clock.setFixedTime(now)
     tripwire = watchConsole(page)
     await page.addInitScript(({ account, ids }) => {
@@ -98,11 +124,27 @@ describe('web e2e: workspace recency', () => {
     await expect.poll(titles).toEqual(TITLES)
     await pick('Last updated')
     await pick('WorkSpace')
+    await pick('Manual')
+    const reconnectWarningStart = tripwire.warnings.length
+    holdWorkspace = true
+    await socket.close()
+    await expect.poll(() => releaseWorkspace !== undefined).toBe(true)
     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 pick('In one list')
     await expect.poll(titles).toEqual(['New Session', ...TITLES])
-    await pick('Manual')
+    await expect.poll(() => page.evaluate(() => {
+      const { sessionId } = JSON.parse(localStorage.getItem('dsh.sessions.current')!) as { sessionId: string }
+      const { sessionOrderByAccount } = JSON.parse(localStorage.getItem('dsh.workspace.view.v5')!) as {
+        sessionOrderByAccount: Record<string, string[]>
+      }
+      return sessionOrderByAccount.__flat_session_order__?.[0] === sessionId
+    })).toBe(true)
+    holdWorkspace = false
+    releaseWorkspace!()
+    await pick('WorkSpace')
+    acknowledgeReloadConnectionLoss(tripwire, reconnectWarningStart)
     await expect.poll(titles).toEqual(['New Session', ...TITLES])
     await compareOrRefreshGolden(
       join(SNAPSHOT_DIR, 'manual-blank.expected.md'),

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -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: bd5ae62f03b9d82c7aace7bf101b69f2ce9e940b
-README.zh.md: ee2b8c78cf0af1543e9855f8abdd35aa5a830365
+README.md: 7317573add877fce9bdfdf359219053ae2452566
+README.zh.md: 1b803e3b91030171829d8a7f6f03270fa1aa566f

+ 1 - 1
packages/client/ui-workspace/README.md

@@ -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. 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`.
+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. During Workspace reconnection, Manual records an observed blank Session at the front of its saved flat and known group orders without removing other saved members; full membership reconciliation waits for the Workspace baseline. 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
packages/client/ui-workspace/README.zh.md

@@ -67,7 +67,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
 
 ### 视图状态
 
-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`。
+Workspace 列表基线就绪后,浏览器持久化的展开状态和手动 Session 顺序记录只保留当前 Workspace id、Ungrouped 和单列表记账。`WorkspaceView.sessionIds` 提供真实 Workspace 的成员关系,而不提供 Session 显示顺序。视图操作要求显式传入当前各记账的顺序。单列表的成员筛选和排序使用 Session id,行渲染只计算一次状态指示。进入手动排序会从当前显示结果一次性记录每个有效记账;对账会保留仍属于该记账的已保存成员、移除已经离开的成员,并按最近更新时间追加新发现的成员。尚无 Session 摘要的新成员会等摘要到达后再加入,而已经保存的位置在摘要暂时缺失时仍会保留。Workspace 重连期间,手动排序会将已观察到的空白 Session 记录到已保存的单列表及已知分组顺序首位,不移除其他已保存成员;完整成员对账等待 Workspace 基线到齐。即使侧边栏收成窄栏或搜索替代列表主体,这项对账也保持挂载。最近更新直接从每份当前列表快照派生,不读取或写入已保存位置;时间相同时按 Session id 稳定排序。共享侧边栏投影会隐藏持久化 Session 摘要中带有 `origin: 'subagent'` 的行;每个可见普通行都会在经不间断的 subagent 谱系可达的任一后代运行时继承蓝色活动指示器。同一项纯派生逻辑还会为分组、平铺与搜索节点读取列表 projection value 中的 Schedule key;本包只使用纯类型依赖 `@deepseek-ai/dsh-schedule/client`,不会导入 Schedule 运行时或 `ui-schedule`。
 
 ### 悬浮卡片
 

+ 22 - 0
packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx

@@ -788,6 +788,28 @@ export function WorkspaceBrowser({
       ...workspaces.map(workspace => workspace.workspaceId as string),
     ])
   }, [actions.retainAccountKeys, workspacePhase, workspaces])
+  useEffect(() => {
+    if (list.phase !== 'ready' || workspaceReady || orderBy !== 'manual' || currentBlank === undefined) return
+    // A first prompt can end blank pinning before the Workspace baseline arrives.
+    // Preserve saved members until that baseline can establish departures.
+    const changed: Record<string, readonly string[]> = {}
+    for (const [key, ids] of Object.entries(activeSessionOrders)) {
+      if (key !== FLAT_SESSION_ORDER_KEY && workspacePhase !== 'ready') continue
+      const saved = sessionOrderByAccount[key] ?? []
+      if (ids[0] !== currentBlank || saved[0] === currentBlank) continue
+      changed[key] = [currentBlank, ...saved.filter(id => id !== currentBlank)]
+    }
+    if (Object.keys(changed).length > 0) actions.syncSessionOrders(changed)
+  }, [
+    actions.syncSessionOrders,
+    activeSessionOrders,
+    currentBlank,
+    list.phase,
+    orderBy,
+    sessionOrderByAccount,
+    workspacePhase,
+    workspaceReady,
+  ])
   useEffect(() => {
     if (list.phase !== 'ready' || !workspaceReady || orderBy !== 'manual') return
     const changed = Object.fromEntries(Object.entries(activeSessionOrders).filter(([key, ids]) => {

+ 45 - 0
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -280,6 +280,51 @@ describe('WorkspaceBrowser', () => {
     })
   })
 
+  it.each(['workspace', 'flat', 'ungrouped'] as const)('retains the new Session position through a %s Workspace reconnect', (mode) => {
+    const account = mode === 'flat' ? FLAT_SESSION_ORDER_KEY : mode === 'ungrouped' ? UNGROUPED_KEY : 'alpha'
+    const old = summary('old', 10)
+    const absent = summary('absent', 5)
+    const blank = summary('blank', 1, { blank: true })
+    const groups = (ids: string[]) => workspaceState(mode === 'ungrouped' ? [] : [workspace('alpha', ids)])
+    const preferences = createWorkspaceViewStore().create()
+    preferences.actions.setGroupBy(mode === 'flat' ? 'flat' : 'workspace')
+    preferences.actions.setGroupExpanded(account, true)
+    preferences.actions.setSessionOrder(account, ['old', 'absent'], {})
+    const b = mount({
+      useSessions: hook(sessionState([old, absent])),
+      useWorkspaces: hook(groups(['old', 'absent'])),
+    })
+    const names = () => screen.getAllByRole('treeitem')
+      .filter(row => row.getAttribute('aria-expanded') === null)
+      .map(row => row.textContent)
+    rerender(b, {
+      useSessions: hook(sessionState([old, blank], { current: blank.id })),
+      useWorkspaces: hook({ ...groups(['old', 'blank']), state: 'loading' }),
+    })
+    expect(names()).toEqual([expect.stringContaining('新会话'), expect.stringContaining('old')])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+    })
+    expect(names()).toEqual([expect.stringContaining('blank'), expect.stringContaining('old')])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+
+    rerender(b, {
+      useSessions: hook(sessionState([old, absent, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+      useWorkspaces: hook(groups(['old', 'absent', 'blank'])),
+    })
+    expect(names()).toEqual([
+      expect.stringContaining('blank'), expect.stringContaining('old'), expect.stringContaining('absent'),
+    ])
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old', 'absent'])
+    rerender(b, {
+      useSessions: hook(sessionState([old, { ...blank, blank: false, updatedAt: 20 }], { current: blank.id })),
+      useWorkspaces: hook(groups(['old', 'blank'])),
+    })
+    expect(b.store.getSnapshot().sessionOrderByAccount[account]).toEqual(['blank', 'old'])
+  })
+
   it('reconciles a late blank and its first prompt while the sidebar is collapsed', async () => {
     const old = summary('old', 10)
     const blank = summary('blank', 1, { blank: true })