Explorar el Código

fix(ui-workspace): promote new sessions only once

_Kerman hace 1 mes
padre
commit
b3b6407a2c

+ 2 - 2
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.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/feature/2026-08-11-workspace-sidebar-order-and-folding.md
-2026-08-11-workspace-sidebar-order-and-folding.md: c09ec72be443be7fb22c42df98e42a73d83d468b
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: 481066d438722568728587ad0764b193eb56650e
+2026-08-11-workspace-sidebar-order-and-folding.md: ad079cfc71d6efff7679ce3b8512167bce95e6c8
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: fdc23fbc3a745b030c296d91257adb505b282471

+ 3 - 3
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.md

@@ -24,7 +24,7 @@ Each Workspace persists one browser-local open state: closed means zero Session
 
 The combined view menu offers **Manual** and **Last updated** in grouped and flat presentation, with one browser-local persisted order per account. A real Workspace initializes from `WorkspaceView.sessionIds`; Ungrouped and the cross-Workspace flat list initialize from recency and have no Host Session account. 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 for a real Workspace also write the Host Session account, while Ungrouped and flat-list drags and activity promotion remain browser-local. Flat rows omit an empty leading status slot because they have no parent hierarchy, while a visible status retains its slot.
 
-The blank Session that is currently selected — the New Session being created — renders first in its account in both Manual and Last updated. Opening or reusing a blank never advances its `updatedAt` (the Host derives it from `createdAt` and `lastPromptAt`), so without the pin a reused blank would keep its old creation-time position. The pin is applied when deriving the render order and is never written back to the persisted per-account order: deselecting the blank or sending its first prompt restores its stored slot, and a drag the pin would mask (dragging the pinned blank itself, or parking another Session into the pinned slot) is skipped entirely, including the Host account write.
+When New Session creation selects a blank Session, the browser promotes it once in both its grouped account and the flat-list account. This explicit creation promotion does not advance `updatedAt`; later drag ordering treats the blank like any other Session, and the first prompt does not undo a Manual-mode drag.
 
 ### Drag and compact chrome
 
@@ -50,10 +50,10 @@ Search is a header action while collapsed and expands across the title and trail
 
 - Workspace order is durable and shared through the Host, while grouping, open state, per-account Session view order, and query state remain browser-local presentation preferences. Ungrouped and the flat list support the same drag and promotion rules, but their orders are browser-local because neither has one Workspace account.
 - Last updated performs a complete recency sort on entry, then preserves manual adjustments until a user prompt or steer advances one Session and moves it to the front. Returning to Manual preserves every current position.
-- The current blank New Session row renders first in its account while it is selected, as a render-time pin that never touches the persisted order; every other row keeps its Manual or Last-updated position, and a non-blank current Session is never reordered by navigation.
+- A newly selected blank New Session row enters grouped and flat orders first once, then follows the same drag and activity rules as every other Session.
 - Opening a Workspace never shows more than five Sessions without an explicit **Show more** gesture, and closing it resets only that transient gesture.
 - The Host Session account retains the manual-order meaning established by [Session List Browsing and Manual Workspace Order](2026-07-25-session-list-browsing-and-manual-order.md).
 
 ## Testing
 
-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, pinning the current blank New Session row to its account front in both modes, non-blank navigation leaving positions untouched, masked drags of the pinned row being skipped without a browser reorder or Host write, the pin releasing when the blank turns real, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, 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.
+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 and New Session promotion, Manual drag retention after the first prompt, browser-local Ungrouped and flat-list drag persistence, hierarchy-free flat-row leading spacing, 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
.agents/notes/implemented/feature/2026-08-11-workspace-sidebar-order-and-folding.zh.md

@@ -24,7 +24,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 组合视图菜单在分组和单列表呈现中都提供**手动排序**和**最近更新**,每个记账各自持有一份浏览器本地持久顺序。真实 Workspace 从 `WorkspaceView.sessionIds` 初始化;Ungrouped 和跨 Workspace 的单列表从最近更新时间顺序初始化,且没有 Host Session 记账。进入最近更新时会执行一次完整的时间排序;后续 user prompt 或 steer 会将对应 Session 置顶一次,拖拽仍可编辑所得顺序。返回手动排序会保留当前顺序,只停用后续活动置顶。真实 Workspace 在手动模式下的拖拽还会写入 Host Session 记账,而 Ungrouped 和单列表的拖拽与活动置顶保留在浏览器本地。单列表没有父级层次,因此不显示空的左侧状态槽;存在可见状态时仍保留该槽。
 
-当前选中的空白 Session(正在新建的"新会话")在**手动排序**和**最近更新**两种模式下都渲染在其记账第一位。打开或复用空白不会推进其 `updatedAt`(Host 从 `createdAt` 与 `lastPromptAt` 推导),若不置顶,复用的空白会停留在创建时的旧位置。置顶只作用于派生渲染顺序,绝不写回持久化的每记账顺序:取消选中空白或发出首条提示词后即恢复其存储位置;会被置顶掩盖的拖拽(拖动被置顶的空白本身,或把其他 Session 放入被置顶的槽位)会被整体跳过,包括 Host 记账写入。
+创建“新会话”并选中空白 Session 时,浏览器会在其分组记账和单列表记账中各置顶一次。这次明确的创建置顶不会推进 `updatedAt`;后续拖拽把空白 Session 当作普通 Session,首条提示词落地也不会撤销手动模式下的拖拽。
 
 ### 拖拽与紧凑界面
 
@@ -50,10 +50,10 @@ Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行
 
 - Workspace 顺序通过 Host 持久并共享;分组方式、打开状态、每个记账的 Session 视图顺序和查询状态仍是浏览器本地呈现偏好。Ungrouped 和单列表支持相同的拖拽与置顶规则,但因没有单一 Workspace 记账,其顺序只保存在浏览器本地。
 - 最近更新模式会在进入时执行完整时间排序,随后保持手动调整,直到 user prompt 或 steer 推进某条 Session 并将其置顶。返回手动排序会保留所有当前位置。
-- 当前选中的空白"新会话"行在被选中期间渲染在其记账第一位,这是纯渲染期置顶,从不改动持久顺序;其余行保持手动排序或最近更新位置,点开非空白会话不会因导航而重排。
+- 新选中的空白“新会话”行会在分组和单列表顺序中各置顶一次,之后遵循与其他 Session 相同的拖拽和活动规则。
 - 未执行明确的**展开其余**手势时,打开 Workspace 最多显示五条 Session;关闭分组只重置这项临时手势。
 - Host Session 记账继续采用[会话列表浏览与 Workspace 手动排序](2026-07-25-session-list-browsing-and-manual-order.md)确立的手动顺序含义。
 
 ## 测试
 
-领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新置顶、两种模式下把当前空白"新会话"行置顶到记账最前、点开非空白会话不改变位置、被置顶掩盖的拖拽被整体跳过且不写浏览器或 Host 顺序、空白转正后置顶释放、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。
+领域与 Host 测试覆盖持久 Workspace 移动、无操作与无效锚点、重启恢复、完整顺序 RPC 响应、顺序帧以及每条 Host stream 基线只读取一份 Workspace 快照。运行时测试覆盖乐观顺序、帧/响应优先级、重叠拒绝后恢复 Host 已确认顺序、重连基线以及 New Session 目标优先级。UI 测试覆盖五行折叠、临时展开重置、Workspace 移除后清理持久状态、保持顺序的模式切换、一次性最近更新与“新会话”置顶、首条提示词落地后保留手动拖拽、浏览器本地 Ungrouped 与单列表拖拽持久化、无层级单列表行左侧间距、当前视图标记、展开区段的 Workspace 命中、未裁切的第一条插入边界、列表外 Workspace 与 Session 松手、搜索收起规则和紧凑 CSS 尺寸。

+ 32 - 41
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -95,32 +95,6 @@ function reconciledSessionOrder(sessionIds: readonly SessionId[], stored: readon
   return ordered
 }
 
-/**
- * Render-time pin: the blank Session the user is currently in (the New
- * Session being created) renders first in its account in both order modes.
- * Opening or reusing a blank never advances its `updatedAt` (the Host derives
- * it from `createdAt` and `lastPromptAt`), so without the pin a reused blank
- * would keep its old creation-time position. The pin applies only to this
- * derived render order — it is never written back to the persisted account
- * order, so the stored position returns as soon as the Session stops being
- * the current blank.
- * @param order - account order before pinning.
- * @param list - sessions list snapshot (`current` and `byId` decide the pin).
- * @returns `order` with the current blank moved to the front, otherwise `order` unchanged.
- */
-function pinCurrentBlank<T extends string>(order: readonly T[], list: SessionListState): T[] {
-  const current = list.current
-  if (current === undefined || list.byId[current]?.blank !== true) return [...order]
-  const key = current as unknown as T
-  if (!order.includes(key)) return [...order]
-  return [key, ...order.filter(id => id !== key)]
-}
-
-/** True when two orders hold the same ids at the same indexes. */
-function sameOrder(a: readonly string[], b: readonly string[]): boolean {
-  return a.length === b.length && a.every((id, index) => id === b[index])
-}
-
 /** Newest update first with stable Session identity as the tie-break. */
 function compareSessionRecency(a: SessionId, b: SessionId, byId: SessionListState['byId']): number {
   const aUpdatedAt = byId[a]?.updatedAt ?? Number.NEGATIVE_INFINITY
@@ -338,20 +312,20 @@ function SessionTree({
   const orderedWorkspaces = useMemo(() => {
     return workspaces.map((workspace) => {
       const stored = sessionOrderByAccount[workspace.workspaceId as string]
-      const sessionIds = pinCurrentBlank(reconciledSessionOrder(workspace.sessionIds, stored), list)
+      const sessionIds = reconciledSessionOrder(workspace.sessionIds, stored)
       return { ...workspace, sessionIds }
     })
-  }, [list, sessionOrderByAccount, workspaces])
+  }, [sessionOrderByAccount, workspaces])
   const orderedUngroupedSessionIds = useMemo(
-    () => pinCurrentBlank(reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]), list),
-    [sessionOrderByAccount, ungroupedSessionIds, list],
+    () => reconciledSessionOrder(ungroupedSessionIds, sessionOrderByAccount[UNGROUPED_KEY]),
+    [sessionOrderByAccount, ungroupedSessionIds],
   )
   const groups = useMemo(
     () => deriveGroups(list, orderedWorkspaces, archivedSessionIds, {
       expandedGroups,
       ...(sessionOrderByAccount[UNGROUPED_KEY] === undefined
         ? {}
-        : { ungroupedOrder: pinCurrentBlank(sessionOrderByAccount[UNGROUPED_KEY], list) }),
+        : { ungroupedOrder: sessionOrderByAccount[UNGROUPED_KEY] }),
     }),
     [list, orderedWorkspaces, archivedSessionIds, expandedGroups, sessionOrderByAccount],
   )
@@ -378,11 +352,6 @@ function SessionTree({
     const nextOrder = accountSessionIds.filter(id => id !== activeDrag.sessionId)
     const insertAt = anchor === undefined ? nextOrder.length : nextOrder.indexOf(anchor)
     nextOrder.splice(insertAt === -1 ? nextOrder.length : insertAt, 0, activeDrag.sessionId)
-    // The render-time pin cancels drags whose committed order would render
-    // unchanged (dragging the pinned New Session row, or parking another row
-    // into the pinned slot). Skip the whole commit so neither the browser
-    // account nor the Host account records a move the user never saw.
-    if (sameOrder(accountSessionIds, pinCurrentBlank(nextOrder, list))) return
     setSessionOrder(activeDrag.accountKey, nextOrder.map(id => id as string))
     if (orderBy === 'updated' || activeDrag.accountKey === UNGROUPED_KEY) return
     insertSessionBefore(activeDrag.accountKey as WorkspaceId, activeDrag.sessionId, anchor).catch((reason: unknown) => {
@@ -622,12 +591,12 @@ function FlatList({
   }, [list, orderBy, sessionOrderByAccount, sessionUpdatedAtByAccount, sessionIds, syncSessionOrderAccount])
   const rows = useMemo(() => {
     const byId = new Map(baseRows.map(row => [row.id, row]))
-    return pinCurrentBlank(reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]), list)
+    return reconciledSessionOrder(sessionIds, sessionOrderByAccount[FLAT_SESSION_ORDER_KEY])
       .flatMap((id) => {
         const row = byId.get(id)
         return row === undefined ? [] : [row]
       })
-  }, [baseRows, list, sessionOrderByAccount, sessionIds])
+  }, [baseRows, sessionOrderByAccount, sessionIds])
   const [drag, setDrag] = useState<DragState | null>(null)
   const dropCommitted = useRef(false)
   useNativeDragAcceptance(drag !== null)
@@ -645,9 +614,6 @@ function FlatList({
     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)
-    // The render-time pin masks drags of the pinned New Session row, which
-    // would render unchanged; skip the commit entirely.
-    if (sameOrder(rows.map(row => row.id), pinCurrentBlank(nextOrder, list))) return
     setSessionOrder(FLAT_SESSION_ORDER_KEY, nextOrder.map(id => id as string))
   }
   const now = Date.now()
@@ -811,6 +777,31 @@ export function WorkspaceBrowser({
   const groupExpansion = useStore(s => s.groupExpansion)
   const sessionOrderByAccount = useStore(s => s.sessionOrderByAccount)
   const sessionUpdatedAtByAccount = useStore(s => s.sessionUpdatedAtByAccount)
+  const currentBlankSessionId = useSessions((state) => {
+    const current = state.current
+    return current !== undefined && state.byId[current]?.blank === true ? current : undefined
+  })
+  const currentBlankAccount = currentBlankSessionId === undefined
+    ? undefined
+    : (workspaces.find(workspace => workspace.sessionIds.includes(currentBlankSessionId))
+      ?.workspaceId as string | undefined) ?? UNGROUPED_KEY
+  const promotedBlank = useRef<{ sessionId: SessionId; accountKey: string } | undefined>(undefined)
+  useEffect(() => {
+    if (currentBlankSessionId === undefined || currentBlankAccount === undefined) {
+      promotedBlank.current = undefined
+      return
+    }
+    if (promotedBlank.current?.sessionId === currentBlankSessionId
+      && promotedBlank.current.accountKey === currentBlankAccount) return
+    promotedBlank.current = { sessionId: currentBlankSessionId, accountKey: currentBlankAccount }
+    for (const accountKey of new Set([currentBlankAccount, FLAT_SESSION_ORDER_KEY])) {
+      const previous = sessionOrderByAccount[accountKey] ?? []
+      actions.setSessionOrder(accountKey, [
+        currentBlankSessionId,
+        ...previous.filter(id => id !== currentBlankSessionId),
+      ])
+    }
+  }, [actions.setSessionOrder, currentBlankAccount, currentBlankSessionId, sessionOrderByAccount])
   useEffect(() => {
     if (workspacePhase !== 'ready') return
     actions.retainAccountKeys([

+ 32 - 175
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -95,11 +95,6 @@ function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrows
   b.view.rerender(<WorkspaceBrowser {...b.props} />)
 }
 
-/** Every tree row's text, group headers included, in render order. */
-function rowsOf(): (string | null)[] {
-  return screen.getAllByRole('treeitem').map(row => row.textContent)
-}
-
 describe('WorkspaceBrowser', () => {
   it('workspace hover card shows a POSIX home descendant as ~', () => {
     vi.useFakeTimers()
@@ -473,99 +468,38 @@ describe('WorkspaceBrowser', () => {
     expect(screen.queryByText('新会话')).toBeNull()
   })
 
-  it('pins the current blank New Session row first in Last-updated mode (workspace view)', async () => {
+  it('promotes the blank selected by New Session in its grouped and flat orders', async () => {
+    const items = [
+      summary('old', 100),
+      summary('blank', 150, { blank: true }),
+      summary('mid', 200),
+    ]
+    const startSession = vi.fn()
     const b = mount({
-      useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('blank', 150, { blank: true }),
-        summary('mid', 200),
-      ])),
+      useSessions: hook(sessionState(items)),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
-    })
-    fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
-    await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') })
-    fireEvent.click(screen.getByText('alpha'))
-    await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) })
-    // The stale blank is hidden while it is not the current session.
-    expect(rowsOf()).toEqual([
-      expect.stringContaining('alpha'),
-      expect.stringContaining('mid'),
-      expect.stringContaining('old'),
-    ])
-    // New Session reuses the blank: it becomes current and jumps to the top,
-    // even though its updatedAt (creation time) is older than mid's.
-    rerender(b, {
-      useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('blank', 150, { blank: true }),
-        summary('mid', 200),
-      ], { current: sid('blank') })),
+      startSession,
     })
     await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('新会话'),
-        expect.stringContaining('mid'),
-        expect.stringContaining('old'),
-      ])
-    })
-  })
-
-  it('does not pin a non-blank current session in Last-updated mode', async () => {
-    const b = mount({
-      useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('mid', 200),
-      ])),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'mid'])])),
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'blank', 'mid'])
     })
-    fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
-    await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') })
-    fireEvent.click(screen.getByText('alpha'))
-    await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) })
-    // Opening an ordinary session is navigation, not a New Session gesture:
-    // the selected row stays at its recency position.
-    rerender(b, {
-      useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('mid', 200),
-      ], { current: sid('old') })),
+    startSession.mockImplementation(() => {
+      rerender(b, { useSessions: hook(sessionState(items, { current: sid('blank') })) })
     })
+    fireEvent.click(screen.getByRole('button', { name: '在“alpha”中新建会话' }))
+    expect(startSession).toHaveBeenCalledWith(wid('alpha'))
     await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('mid'),
-        expect.stringContaining('old'),
-      ])
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
+      expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank'])
     })
-  })
-
-  it('pins the current blank first in the flat list too', async () => {
-    const b = mount({
-      useSessions: hook(sessionState([
-        summary('old', 100),
-        summary('blank', 150, { blank: true }),
-        summary('mid', 200),
-      ], { current: sid('blank') })),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
-    })
-    fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '单列表' }))
-    fireEvent.click(screen.getByRole('button', { name: '视图选项' }))
-    fireEvent.click(screen.getByRole('menuitem', { name: '最近更新' }))
-    await waitFor(() => { expect(b.store.getSnapshot().orderBy).toBe('updated') })
+    b.store.actions.setGroupBy('flat')
     await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('新会话'),
-        expect.stringContaining('mid'),
-        expect.stringContaining('old'),
-      ])
+      expect(b.store.getSnapshot().sessionOrderByAccount[FLAT_SESSION_ORDER_KEY]).toEqual(['blank', 'mid', 'old'])
     })
   })
 
-  it('pins the current blank first in manual mode too', async () => {
+  it('does not repeat blank promotion after a manual drag or the first prompt', async () => {
+    const insertSessionBefore = vi.fn(async () => {})
     const b = mount({
       useSessions: hook(sessionState([
         summary('old', 100),
@@ -573,107 +507,30 @@ describe('WorkspaceBrowser', () => {
         summary('mid', 200),
       ], { current: sid('blank') })),
       useWorkspaces: hook(workspaceState([workspace('alpha', ['old', 'blank', 'mid'])])),
-    })
-    expect(b.store.getSnapshot().orderBy).toBe('manual')
-    // The current session's group auto-expands; the New Session being
-    // created renders first even though the manual account order holds it
-    // in its creation-time slot.
-    await waitFor(() => { expect(b.store.getSnapshot().groupExpansion).toEqual({ alpha: true }) })
-    // Manual order follows the Workspace account [old, blank, mid]; the
-    // current blank is pinned first, the rest keeps the account order.
-    await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('新会话'),
-        expect.stringContaining('old'),
-        expect.stringContaining('mid'),
-      ])
-    })
-  })
-
-  it('skips drags masked by the pinned New Session row without reordering or writing Host', async () => {
-    const insertSessionBefore = vi.fn(async () => {})
-    const b = mount({
-      useSessions: hook(sessionState([
-        summary('one', 3),
-        summary('blank', 2, { blank: true }),
-        summary('three', 1),
-      ], { current: sid('blank') })),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])),
       insertSessionBefore,
     })
-    // The current session's group auto-expands, exposing the pinned rows.
     await waitFor(() => {
-      expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three'])
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['blank', 'old', 'mid'])
     })
-    await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('新会话'),
-        expect.stringContaining('one'),
-        expect.stringContaining('three'),
-      ])
-    })
-    const blankRow = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
-    const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
-    const three = screen.getByText('three').closest('[role="treeitem"]') as HTMLElement
-    three.getBoundingClientRect = () => ({
+    const blank = screen.getByText('新会话').closest('[role="treeitem"]') as HTMLElement
+    const mid = screen.getByText('mid').closest('[role="treeitem"]') as HTMLElement
+    mid.getBoundingClientRect = () => ({
       top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
     })
-    // Dragging the pinned New Session row to the end would render unchanged
-    // (the pin returns it to the front): the whole commit is skipped.
-    fireEvent.dragStart(blankRow, { dataTransfer: dragData() })
-    fireDrag(three, 'drop', 180)
-    expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three'])
-    expect(insertSessionBefore).not.toHaveBeenCalled()
-    // Parking the row directly below the pinned blank above it is masked the
-    // same way: no browser reorder and no Host write.
-    blankRow.getBoundingClientRect = () => ({
-      top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
-    })
-    fireEvent.dragStart(one, { dataTransfer: dragData() })
-    fireDrag(blankRow, 'drop', 105)
-    expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three'])
-    expect(insertSessionBefore).not.toHaveBeenCalled()
-  })
+    fireEvent.dragStart(blank, { dataTransfer: dragData() })
+    fireDrag(mid, 'drop', 180)
+    expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
+    expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('blank'), undefined)
 
-  it('releases the pin when the current New Session stops being blank', async () => {
-    const b = mount({
-      useSessions: hook(sessionState([
-        summary('one', 3),
-        summary('blank', 2, { blank: true }),
-        summary('three', 1),
-      ], { current: sid('blank') })),
-      useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'blank', 'three'])])),
-    })
-    // The current session's group auto-expands, exposing the pinned rows.
-    await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('新会话'),
-        expect.stringContaining('one'),
-        expect.stringContaining('three'),
-      ])
-    })
-    // The pin is a render-time effect only: the persisted account order
-    // keeps the blank in its creation-time slot throughout.
-    expect(b.store.getSnapshot().sessionOrderByAccount['alpha']).toEqual(['one', 'blank', 'three'])
-    // Sending the first prompt turns the blank into a real session; the pin
-    // is released and the row returns to its stored slot.
     rerender(b, {
       useSessions: hook(sessionState([
-        summary('one', 3),
-        summary('blank', 2, { blank: false }),
-        summary('three', 1),
+        summary('old', 100),
+        summary('blank', 150),
+        summary('mid', 200),
       ], { current: sid('blank') })),
     })
     await waitFor(() => {
-      expect(rowsOf()).toEqual([
-        expect.stringContaining('alpha'),
-        expect.stringContaining('one'),
-        expect.stringContaining('blank'),
-        expect.stringContaining('three'),
-      ])
+      expect(b.store.getSnapshot().sessionOrderByAccount.alpha).toEqual(['old', 'mid', 'blank'])
     })
   })