Просмотр исходного кода

Merge remote-tracking branch 'origin/master' into worktree/abort-partial-finalize

creatixchu 1 месяц назад
Родитель
Сommit
09b3dfa06d

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md
+2026-08-18-rail-search-outside-click-self-dismissal.md: 9893b3a2456b9a592e1feb107d21404e043dee78
+2026-08-18-rail-search-outside-click-self-dismissal.zh.md: 91e343b8843dc02ca9c1be2b145e79beb84e17b7

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md

@@ -0,0 +1,27 @@
+# Agent Note: Rail search keeps its expansion when the opening click reaches document
+
+Status: implemented
+
+English | [中文](2026-08-18-rail-search-outside-click-self-dismissal.zh.md)
+
+## Problem
+
+The collapsed sidebar's rail search button arms the rail gesture (`searchOnExpand`), expands the search affordance (`searchExpanded`), and requests sidebar expansion — designed to land the user in a focused search input once the column slides open. In a real browser the gesture never completed: the sidebar expanded but the search box stayed closed and unfocused.
+
+The initiating click destroys its own effect. React dispatches the rail button's handler mid-bubble; the state flip renders the wide header and mounts the WorkspaceBrowser's outside-click dismissal listener on `document` during that same dispatch. The click then keeps bubbling and reaches `document` with the now-unmounted rail button as its target — outside `searchRoot` — so the freshly mounted listener immediately collapses the search it was opening. The package test missed this because `fireEvent.click` on the button does not re-bubble through listeners mounted during dispatch the way a real browser event does.
+
+## Decision
+
+The outside-click dismissal listener does not mount while the rail gesture is in flight: its effect returns early while `searchOnExpand` is set, and `searchOnExpand` already ends exactly when the gesture settles (focus lands in the input after the column slide). After settle, outside clicks dismiss the search as before. A regression test replays the real-browser order — rail click, wide flip, then the same click arriving at `document` — and requires the search to stay expanded through it and to dismiss on the next genuine outside click.
+
+## Alternatives considered
+
+**Stop propagation on the rail button's click.** Suppressing bubbling at the initiator couples the rail button to a listener it cannot see, and every other expansion path — a future keyboard shortcut, another rail entry — would reintroduce the bug. The listener owns dismissal, so the listener carries the guard.
+
+**Defer listener attachment by a frame or timeout.** A raw delay encodes the symptom (the click arrives "too early") instead of the cause (a gesture is in flight). `searchOnExpand` is already the explicit in-flight state with the correct end point; a frame boundary is neither.
+
+**Dismiss on `pointerdown` instead of `click`.** The initiating gesture's `pointerdown` precedes the listener mount, so it cannot self-dismiss. Rejected because it changes dismissal semantics for every interaction — a drag or a press-and-slide-away would dismiss where a completed click today does not — to fix a problem scoped to one gesture.
+
+## Consequences
+
+The rail search gesture works end to end in the assembled application, pinned by an `apps/web` real-browser scenario: a real click travels through the collapsed rail, the wide flip, and the document-level bubble, and the search stays expanded with focus landing in the input. During the in-flight window (~300 ms column slide) an outside click does not dismiss the search; that window ends the moment focus lands. The package-level regression test additionally pins the guard's timing at the unit level.

+ 27 - 0
.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.zh.md

@@ -0,0 +1,27 @@
+# Agent Note: 轨道搜索在展开点击到达 document 时保持展开
+
+Status: implemented
+
+[English](2026-08-18-rail-search-outside-click-self-dismissal.md) | 中文
+
+## 问题
+
+收起侧边栏的轨道搜索按钮会置位轨道手势标志(`searchOnExpand`)、展开搜索控件(`searchExpanded`)并请求侧边栏展开——设计意图是列滑开后让用户直接落在已聚焦的搜索输入框里。但在真实浏览器中这个手势从未完成:侧边栏展开了,搜索框却保持关闭且未聚焦。
+
+发起手势的那次点击摧毁了它自己的效果。React 在冒泡中途派发轨道按钮的处理器;状态翻转渲染出宽态头部,并在同一次派发期间把 WorkspaceBrowser 的"点击外部收起搜索"监听器挂到 `document` 上。随后这次点击继续冒泡到达 `document`,其 target 是已卸载的轨道按钮——位于 `searchRoot` 之外——于是刚挂上的监听器立刻收起了它正要打开的搜索。包级测试没有抓到这个问题,因为 `fireEvent.click` 在按钮上触发时,不会像真实浏览器事件那样继续冒泡穿过派发期间新挂载的监听器。
+
+## 决策
+
+轨道手势进行期间不挂载"点击外部收起"监听器:其 effect 在 `searchOnExpand` 置位期间提前返回,而 `searchOnExpand` 本就精确终止于手势落定之时(列滑动结束、焦点落入输入框)。落定之后,外部点击照旧收起搜索。一个回归测试重放真实浏览器的顺序——轨道点击、宽态翻转、同一次点击到达 `document`——要求搜索在此过程中保持展开,并在下一次真正的外部点击时收起。
+
+## 备选方案
+
+**在轨道按钮的点击上阻止冒泡。** 在发起方抑制冒泡会让轨道按钮耦合到一个它看不见的监听器,而且其他每条展开路径——未来的键盘快捷键、另一个轨道入口——都会重新引入此缺陷。收起由监听器负责,守卫就应由监听器承载。
+
+**将监听器挂载延迟一帧或一个定时器。** 裸延迟编码的是症状(点击来得"太早")而非成因(手势正在进行)。`searchOnExpand` 已经是带有正确终点的显式进行中状态;帧边界两者都不是。
+
+**改在 `pointerdown` 上收起而非 `click`。** 发起手势的 `pointerdown` 先于监听器挂载,因而不会自我收起。被否决是因为它改变了所有交互的收起语义——拖拽或按下后滑走会触发收起,而如今完成的点击才会——只为修复一个局限于单个手势的问题。
+
+## 影响
+
+轨道搜索手势在组装后的应用中端到端可用,由 `apps/web` 的真实浏览器场景钉住:真实点击穿过收起轨道、宽态翻转与 document 级冒泡,搜索保持展开且焦点落入输入框。在手势进行窗口内(约 300 ms 列滑动)外部点击不会收起搜索;该窗口在焦点落定的瞬间结束。包级回归测试另外钉住了单元层面的守卫时序。

+ 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: 3a88a61ca25550f1ad803a79e171ae2a7b8d4820
-2026-08-11-workspace-sidebar-order-and-folding.zh.md: e3e710bb9f38bcefcc9eeb50983c866ec5bc2619
+2026-08-11-workspace-sidebar-order-and-folding.md: d683d782454bb9fe1fad1fdc1d1a5fc3184a697b
+2026-08-11-workspace-sidebar-order-and-folding.zh.md: 99e1991cfbb7b1540235ab0190defb31ad0ed6d7

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

@@ -28,7 +28,7 @@ The combined view menu offers **Manual** and **Last updated** in grouped and fla
 
 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 with a joined right-facing chevron that does not affect layout. A tree-body overlay draws the first boundary at the same negative offset outside the scrolling clip, so the leading chevron 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.
+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; while the rail search gesture is still in flight the outside-click listener stays unmounted ([rail-search self-dismissal](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)). Compact Workspace and Session rows, a 24px bottom fade, and the absence of per-Workspace Session counts preserve vertical space without removing navigation affordances.
 
 ## Alternatives considered
 

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

@@ -28,7 +28,7 @@ Workspace 注册表持有持久 `workspaceIds` 顺序,并提供采用 DOM `ins
 
 Workspace 命中测试使用完整渲染分组区段,包括可见 Session 行。前一分组的下半部与后一分组的上半部共享同一条插入边界,指示器是一条带有相连右向尖角且不影响布局的绝对定位横线。树主体覆盖层会在滚动裁切区外以相同的负偏移绘制第一条边界,因此左侧尖角保持可见,列表位置也不会改变。Workspace 或 Session 拖拽期间,文档级 `dragover` 与 `drop` 处理器会接受原生操作;若在 Workspace 列表外松手,`dragend` 会提交最后一个有效标记。
 
-搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
+搜索在折叠时是区头操作,展开后占据标题与尾部操作的空间。查询经清除首尾空白后为空时,点击外部会收起搜索;非空查询则会保留;轨道搜索手势仍在进行期间,外部点击监听器保持未挂载([轨道搜索自我收起](../bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md))。紧凑的 Workspace 与 Session 行、24px 底部渐隐以及取消每个 Workspace 的 Session 数量共同节省纵向空间,同时保留导航入口。
 
 ## 考虑过的替代方案
 

+ 68 - 0
apps/web/tests/rail-search-expand.e2e.ts

@@ -0,0 +1,68 @@
+// Web e2e scenario: the collapsed rail's search control in the real event
+// order. The rail click flips the sidebar wide and mounts WorkspaceBrowser's
+// outside-click dismissal listener during its own React dispatch; the same
+// click then keeps bubbling to document with the unmounted rail button as its
+// target — outside searchRoot. The package-level jsdom test cannot replay
+// that continuation (fireEvent does not re-bubble through listeners mounted
+// mid-dispatch), so the guard that keeps the gesture alive
+// (.agents/notes/implemented/bug-fix/2026-08-18-rail-search-outside-click-self-dismissal.md)
+// is pinned here, in the assembled application under a real browser click.
+//
+// Zero model calls: collapsing the sidebar and expanding the search are pure
+// client layout gestures; the scenario needs no session content at all.
+import type { Browser, Page } from 'playwright'
+import { chromium } from 'playwright'
+import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
+import { launchWebScaffold, watchConsole, type WebScaffold } from './scaffold.ts'
+import { newEnglishPage, saveFailureShot } from './support.ts'
+
+/** WorkspaceBrowser's rail-search focus delay (EXPAND_SLIDE_MS) plus flush headroom. */
+const FOCUS_SETTLE_MS = 600
+
+describe('web e2e: rail search click survives its own document-level bubble', () => {
+  let scaffold: WebScaffold
+  let browser: Browser
+  let page: Page
+  let tripwire: ReturnType<typeof watchConsole>
+
+  beforeAll(async () => {
+    scaffold = await launchWebScaffold({})
+    browser = await chromium.launch()
+    page = await newEnglishPage(browser)
+    tripwire = watchConsole(page)
+    await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
+    await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
+  }, 120_000)
+
+  afterAll(async () => {
+    await browser?.close()
+    await scaffold?.close()
+  })
+
+  it('expands the search and lands focus in the input from one rail click', async () => {
+    onTestFailed(() => saveFailureShot(page, 'web-e2e-rail-search-expand'))
+    await page.getByRole('button', { name: 'Collapse sidebar' }).click()
+    const railSearch = page.getByRole('button', { name: 'Search sessions' })
+    // The wide chrome stays mounted through the 150ms collapse crossfade; the
+    // rail control (no aria-expanded) replaces it at settle.
+    await expect.poll(async () => railSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBeNull()
+
+    // The one real click under test: it must expand the sidebar AND leave the
+    // search expanded after its own bubble reaches document.
+    await railSearch.click()
+
+    const wideSearch = page.getByRole('button', { name: 'Search sessions' })
+    await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('true')
+    const input = page.getByPlaceholder('Search sessions...')
+    await expect.poll(
+      async () => input.evaluate(el => document.activeElement === el),
+      { timeout: FOCUS_SETTLE_MS + 10_000 },
+    ).toBe(true)
+
+    // The guard ends with the gesture: a genuine outside click on an empty
+    // query dismisses the expanded search as before.
+    await page.getByRole('button', { name: 'New session' }).first().click()
+    await expect.poll(async () => wideSearch.getAttribute('aria-expanded'), { timeout: 10_000 }).toBe('false')
+    expect(tripwire.pageErrors).toEqual([])
+  }, 60_000)
+})

+ 1 - 0
apps/web/tsconfig.json

@@ -53,6 +53,7 @@
     "tests/cold-blank-session.e2e.ts",
     "tests/stats-paged-history.e2e.ts",
     "tests/sidebar-scrollbar.e2e.ts",
+    "tests/rail-search-expand.e2e.ts",
     "tests/conversation-column-overflow.e2e.ts",
     "tests/code-mode-round.e2e.ts",
     "tests/composer-draft-scroll.e2e.ts",

+ 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: fb26b0386f729863514862ccb819cb3f99e96dc0
-README.zh.md: a32c5e2ca343c21b6a156da25807c5a96162e03f
+README.md: 041c886ae3f38415ac01fbfc3546a1f32581257c
+README.zh.md: 96d9bc33b8ed47372a0e01c4992dfcef59f2432f

Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-workspace/README.md


Разница между файлами не показана из-за своего большого размера
+ 1 - 1
packages/client/ui-workspace/README.zh.md


+ 7 - 2
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -822,8 +822,13 @@ export function WorkspaceBrowser({
     searchInput.current?.focus({ preventScroll: true })
   }, [wide, searchExpanded, searchOnExpand])
 
+  // Outside-click dismissal stays off while the rail gesture is in flight
+  // (searchOnExpand): the rail click flips the shell wide and mounts this
+  // listener during its own dispatch, then keeps bubbling to document with
+  // the now-unmounted rail button as its target — outside searchRoot, so the
+  // listener would dismiss the search that click just opened.
   useEffect(() => {
-    if (!wide || !searchExpanded) return
+    if (!wide || !searchExpanded || searchOnExpand) return
     const onClick = (event: MouseEvent): void => {
       if (!(event.target instanceof Node) || searchRoot.current?.contains(event.target) === true) return
       searchInput.current?.blur()
@@ -832,7 +837,7 @@ export function WorkspaceBrowser({
     }
     document.addEventListener('click', onClick)
     return () => { document.removeEventListener('click', onClick) }
-  }, [normalizedQuery, wide, searchExpanded])
+  }, [normalizedQuery, wide, searchExpanded, searchOnExpand])
 
   useEffect(() => {
     if (normalizedQuery === '') {

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

@@ -724,6 +724,28 @@ describe('WorkspaceBrowser', () => {
     }
   })
 
+  it('keeps the rail-opened search expanded when the initiating click reaches document', () => {
+    vi.useFakeTimers()
+    try {
+      const b = mount({ wide: false })
+      fireEvent.click(screen.getByRole('button', { name: '搜索会话' }))
+      rerender(b, { wide: true })
+      // In the browser the rail click keeps bubbling to document after the
+      // wide flip mounted the outside-click listener, with the unmounted rail
+      // button as its target — outside searchRoot. It must not dismiss the
+      // search it just opened.
+      fireEvent.click(document.body)
+      expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('true')
+      act(() => { vi.advanceTimersByTime(300) })
+      expect(document.activeElement).toBe(screen.getByPlaceholderText('搜索会话…'))
+      // The gesture has settled: outside clicks dismiss the search again.
+      fireEvent.click(document.body)
+      expect(screen.getByRole('button', { name: '搜索会话' }).getAttribute('aria-expanded')).toBe('false')
+    } finally {
+      vi.useRealTimers()
+    }
+  })
+
   it('rail add-workspace raises the directory flow in place, with no menu and no expansion', () => {
     const expandSidebar = vi.fn()
     mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })

+ 1 - 0
tsconfig.host.json

@@ -40,6 +40,7 @@
     "apps/web/tests/cold-blank-session.e2e.ts",
     "apps/web/tests/stats-paged-history.e2e.ts",
     "apps/web/tests/sidebar-scrollbar.e2e.ts",
+    "apps/web/tests/rail-search-expand.e2e.ts",
     "apps/web/tests/conversation-column-overflow.e2e.ts",
     "apps/web/tests/code-mode-round.e2e.ts",
     "apps/web/tests/composer-draft-scroll.e2e.ts",

Некоторые файлы не были показаны из-за большого количества измененных файлов