Преглед изворни кода

fix(web): preserve hover-card selection and feedback

creatixchu пре 2 месеци
родитељ
комит
8997afb3e5

+ 2 - 2
.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.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-07-31-hover-card-click-copy.md
-2026-07-31-hover-card-click-copy.md: 6a59013cdabf4d7d2ecc35d4e6ec3c14ee206057
-2026-07-31-hover-card-click-copy.zh.md: f742b7e9ffdf09a7a23d5aab2e1e9189520d9193
+2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2
+2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md

@@ -10,11 +10,11 @@ Workspace and Session rows clip the two values their hover cards expose in full:
 
 ## Decision
 
-`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; it writes that exact string through the shared clipboard helper and replaces its content with the success label for one second only after the host accepts the write. Without `copyText`, the atom retains its read/select-only behavior.
+`HoverCard` accepts an optional `copyText` plus `copyLabel` and `copiedLabel`. With `copyText`, the whole card has button semantics for pointer and keyboard activation; its accessible name combines the localized action prefix with the exact value, it writes that value through the shared clipboard helper, and it replaces its content with the success label for up to one second only after the host accepts the write. The feedback retains the pre-copy card height and clears with the card. Without `copyText`, the atom retains its read/select-only behavior.
 
-The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a Session card passes the full display title. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`.
+The Workspace browser chooses the payload rather than making the primitive infer it from rendered text: a Workspace card passes the full directory path, and a non-blank Session card passes the full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder, not session content. The browser's locale seat supplies `Copy`/`复制` and the success state `Copied`/`已复制`.
 
-Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; the completed click activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success.
+Press and activation remain separate contracts. A pointer press inside the card keeps it mounted so text selection can begin; a completed non-collapsed selection intersecting the card suppresses pointer-click activation, while a plain click or button key activates copy. Anchor-region presses still dismiss immediately, and clipboard rejection leaves the original content visible without claiming success.
 
 ## Alternatives considered
 
@@ -26,4 +26,4 @@ Press and activation remain separate contracts. A pointer press inside the card
 
 ## Consequences
 
-Both hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears the timer on unmount and never reports a rejected write as success. Focused component coverage pins pointer selection, activation, failure, feedback expiry, and cleanup, while the real-browser Workspace scenario verifies the English label and browser clipboard.
+Both non-placeholder hover-card variants gain the same click and keyboard affordance while retaining consumer-owned payload semantics and localized feedback. The generic atom adds one optional behavior path and a one-second timer; it clears copied state on close, ignores completion after close or unmount, and never reports a rejected write as success. Focused component coverage pins pointer selection precedence, activation, failure, feedback geometry and expiry, and cleanup, while the real-browser Workspace scenario verifies the English label, stable feedback height, and browser clipboard.

+ 4 - 4
.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md

@@ -10,11 +10,11 @@ Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:W
 
 ## 决策
 
-`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;卡片通过共享剪贴板辅助函数写入该字符串,并且只有宿主接受写入后,才会用成功标签替换内容一秒。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。
+`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。
 
-Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,Session 卡片传入完整显示标题。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。
+Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。
 
-按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;完成点击才会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。
+按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。
 
 ## 备选方案
 
@@ -26,4 +26,4 @@ Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推
 
 ## 后果
 
-两种悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;组件卸载时会清除该计时器,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本、激活、失败、反馈状态到期与清理行为,真实浏览器中的 Workspace 场景则验证英文标签和浏览器剪贴板。
+两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。

+ 15 - 6
apps/web/tests/workspace-management.e2e.ts

@@ -28,6 +28,10 @@ const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', impo
 const MODE = webSnapshotMode()
 const BROWSER_EXPECTED = join(SNAPSHOT_DIR, 'directory-browser.expected.md')
 const SEED_ID = 'workspace-management-web-e2e'
+// Both waits exceed ui-primitives' 200ms POINTER_GRACE_MS. Keep them coupled
+// to that contract if the shared grace tuning changes.
+const POINTER_TRANSIT_MS = 300
+const POINTER_HOLD_MS = 600
 
 describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
   let scaffold: WebScaffold
@@ -423,16 +427,21 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     // The card is REACHABLE: it sits 8px off the row, so getting to it means
     // crossing ground that belongs to neither. Hovering it must not dismiss
     // it — the regression this scenario guards.
-    const card = page.getByRole('button', { name: 'Copy' })
+    const card = page.getByRole('button', { name: `Copy: ${rowTitle}` })
     await card.hover()
-    await page.waitForTimeout(600)
+    await page.waitForTimeout(POINTER_HOLD_MS)
     expect(await page.getByText('Idle', { exact: true }).count()).toBeGreaterThanOrEqual(1)
     // The full title is the card's primary value: activating anywhere on the
     // card writes it through the browser clipboard and localizes the success
     // feedback through the English locale seat.
     await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
+    const cardHeight = (await card.boundingBox())?.height
     await card.click()
-    await page.getByRole('button', { name: 'Copied' }).waitFor({ timeout: 5_000 })
+    const copied = page.getByRole('status').getByText('Copied', { exact: true })
+    await copied.waitFor({ timeout: 5_000 })
+    await page.waitForTimeout(POINTER_HOLD_MS)
+    expect((await card.boundingBox())?.height).toBe(cardHeight)
+    expect(await copied.isVisible()).toBe(true)
     expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(rowTitle)
     // Leaving anchor and card together closes it after the grace.
     await page.getByRole('button', { name: 'Settings' }).hover()
@@ -455,13 +464,13 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
     // the pointer ON the trigger, so entering the list has to come first for
     // the return to be a real departure.
     await item.hover()
-    await page.waitForTimeout(300)
+    await page.waitForTimeout(POINTER_TRANSIT_MS)
     await trigger.hover()
-    await page.waitForTimeout(600)
+    await page.waitForTimeout(POINTER_HOLD_MS)
     expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
     // ...and back down into the list, which must still be there to enter.
     await item.hover()
-    await page.waitForTimeout(600)
+    await page.waitForTimeout(POINTER_HOLD_MS)
     expect(await page.getByRole('menuitem', { name: 'Rename' }).count()).toBe(1)
     // Pointer-leave dismissal still applies once the pointer genuinely leaves.
     await page.getByRole('button', { name: 'Settings' }).hover()

+ 2 - 2
packages/client/ui-primitives/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-primitives/README.md
-README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e
-README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5
+README.md: 3a7c0fcb6557e2f8aa213290932cefeb62621f0e
+README.zh.md: 2a0ce4e3b951cf77f6c8bd83dc15b874d1092027

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

@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
 
 ## Hover cards
 
-`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, writes that exact primary value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
+`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
 
 ## Markdown rendering
 

+ 1 - 1
packages/client/ui-primitives/README.zh.md

@@ -6,7 +6,7 @@
 
 ## 悬浮卡片
 
-`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,通过包内剪贴板辅助函数原样写入该主要值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
+`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
 
 ## Markdown 渲染
 

+ 6 - 1
packages/client/ui-primitives/src/HoverCard.module.css

@@ -31,8 +31,13 @@
   outline-offset: 2px;
 }
 
+.feedback {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
 .copied {
-  display: block;
   color: #FFFFFF;
   font-size: 14px;
   line-height: 20px;

+ 48 - 18
packages/client/ui-primitives/src/HoverCard.tsx

@@ -8,7 +8,7 @@
 // traversal already treats it as inside — one pair of wrapper handlers covers
 // anchor and card alike.
 
-import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
 import type { ReactNode } from 'react'
 import { createPortal } from 'react-dom'
 import { writeClipboard } from './clipboard.ts'
@@ -22,8 +22,9 @@ import css from './HoverCard.module.css'
  * readable and selectable, but it carries no dismissal affordance of its own.
  * @param props.openDelayMs - hover dwell before the card shows (default 500).
  * @param props.disabled - suppress opening; turning true closes an open card.
- * @param props.copyText - optional primary value copied by activating the card.
- * @param props.copyLabel - accessible activation label (default "复制").
+ * @param props.copyText - optional primary value copied by activation and
+ * included in the card's accessible name.
+ * @param props.copyLabel - accessible activation-label prefix (default "复制").
  * @param props.copiedLabel - visible success label (default "复制成功").
  * @returns anchor wrapper with the conditional portaled card.
  */
@@ -43,13 +44,30 @@ export function HoverCard({
   const cardRef = useRef<HTMLDivElement>(null)
   const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const copyHeightRef = useRef<number | null>(null)
+  const copyEpochRef = useRef(0)
   const copyingRef = useRef(false)
   const mountedRef = useRef(true)
   const [open, setOpen] = useState(false)
   const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
   const [copied, setCopied] = useState(false)
 
-  const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) })
+  const clearCopied = useCallback(() => {
+    if (copyTimerRef.current !== null) {
+      clearTimeout(copyTimerRef.current)
+      copyTimerRef.current = null
+    }
+    copyHeightRef.current = null
+    setCopied(false)
+  }, [])
+
+  const close = useCallback(() => {
+    copyEpochRef.current += 1
+    clearCopied()
+    setOpen(false)
+  }, [clearCopied])
+
+  const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
 
   const clearTimer = () => {
     if (timerRef.current !== null) {
@@ -63,15 +81,19 @@ export function HoverCard({
     if (!disabled) return
     clearTimer()
     cancelClose()
-    setOpen(false)
-  }, [disabled, cancelClose])
+    close()
+  }, [disabled, cancelClose, close])
 
   useEffect(() => {
     mountedRef.current = true
     return () => {
       mountedRef.current = false
+      copyEpochRef.current += 1
       clearTimer()
-      if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
+      if (copyTimerRef.current !== null) {
+        clearTimeout(copyTimerRef.current)
+        copyTimerRef.current = null
+      }
     }
   }, [])
 
@@ -112,31 +134,39 @@ export function HoverCard({
   const copy = async (text: string): Promise<void> => {
     if (copied || copyingRef.current) return
     copyingRef.current = true
+    const copyEpoch = copyEpochRef.current
     const accepted = await writeClipboard(text)
     copyingRef.current = false
-    if (!accepted || !mountedRef.current) return
+    const card = cardRef.current
+    if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return
+    const height = card.offsetHeight
+    copyHeightRef.current = height > 0 ? height : null
     setCopied(true)
-    copyTimerRef.current = setTimeout(() => {
-      copyTimerRef.current = null
-      setCopied(false)
-    }, 1000)
+    copyTimerRef.current = setTimeout(clearCopied, 1000)
   }
 
   const copyable = copyText !== undefined
   const card = open && pos !== null && (
     <div
       ref={cardRef}
-      className={`${css.card}${copyable ? ` ${css.copyable}` : ''}`}
-      style={pos}
+      className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
+      style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
       role={copyable ? 'button' : undefined}
       tabIndex={copyable ? 0 : undefined}
-      aria-label={copyable ? (copied ? copiedLabel : copyLabel) : undefined}
-      onClick={copyable ? () => { void copy(copyText) } : undefined}
+      aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
+      onClick={copyable
+        ? (e) => {
+          const selection = window.getSelection()
+          if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0
+            && selection.getRangeAt(0).intersectsNode(e.currentTarget)) return
+          void copy(copyText)
+        }
+        : undefined}
       onKeyDown={copyable
         ? (e) => {
           if (e.key !== 'Enter' && e.key !== ' ') return
           e.preventDefault()
-          e.currentTarget.click()
+          void copy(copyText)
         }
         : undefined}
     >
@@ -172,7 +202,7 @@ export function HoverCard({
         if (cardRef.current?.contains(e.target as Node)) return
         clearTimer()
         cancelClose()
-        setOpen(false)
+        close()
       }}
     >
       {anchor}

+ 79 - 3
packages/client/ui-primitives/tests/hover-card.spec.tsx

@@ -140,6 +140,38 @@ describe('HoverCard', () => {
     expect(screen.getByText('card body')).toBeTruthy()
   })
 
+  it('keeps a completed card selection instead of treating its click as copy', async () => {
+    const writeText = vi.fn(async () => {})
+    const restoreClipboard = installClipboard(writeText)
+    const selection = window.getSelection()
+    if (selection === null) throw new Error('jsdom selection API unavailable')
+    try {
+      const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
+      fireEvent.pointerEnter(wrapper)
+      act(() => { vi.advanceTimersByTime(500) })
+      const card = screen.getByRole('button', { name: 'Copy: card body' })
+      const selectedText = screen.getByText('card body')
+      const cardRange = document.createRange()
+      cardRange.selectNodeContents(selectedText)
+      selection.addRange(cardRange)
+      await act(async () => { fireEvent.click(card) })
+      expect(writeText).not.toHaveBeenCalled()
+      expect(selection.toString()).toBe('card body')
+      expect(screen.getByText('card body')).toBeTruthy()
+
+      // A non-collapsed selection elsewhere does not block this card.
+      selection.removeAllRanges()
+      const anchorRange = document.createRange()
+      anchorRange.selectNodeContents(screen.getByText('row'))
+      selection.addRange(anchorRange)
+      await act(async () => { fireEvent.click(card) })
+      expect(writeText).toHaveBeenCalledWith('card body')
+    } finally {
+      selection.removeAllRanges()
+      restoreClipboard()
+    }
+  })
+
   it('a press while closed leaves the card closed', () => {
     mount()
     fireEvent.pointerDown(screen.getByText('row'))
@@ -158,11 +190,13 @@ describe('HoverCard', () => {
       })
       fireEvent.pointerEnter(wrapper)
       act(() => { vi.advanceTimersByTime(500) })
-      const card = screen.getByRole('button', { name: 'Copy path' })
+      const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
+      Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
       await act(async () => { fireEvent.click(card) })
       expect(writeText).toHaveBeenCalledWith('/full/path')
       expect(screen.getByRole('status').textContent).toBe('Copied')
-      expect(screen.getByRole('button', { name: 'Copied' })).toBe(card)
+      expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
+      expect(card.style.minHeight).toBe('96px')
       // Repeated activation while feedback is visible neither rewrites nor
       // extends the one-second success window.
       await act(async () => { fireEvent.click(card) })
@@ -170,7 +204,8 @@ describe('HoverCard', () => {
       act(() => { vi.advanceTimersByTime(999) })
       expect(screen.getByText('Copied')).toBeTruthy()
       act(() => { vi.advanceTimersByTime(1) })
-      expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card)
+      expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
+      expect(card.style.minHeight).toBe('')
       expect(screen.getByText('card body')).toBeTruthy()
     } finally {
       restoreClipboard()
@@ -228,6 +263,26 @@ describe('HoverCard', () => {
     }
   })
 
+  it('clears copied feedback when the card closes', async () => {
+    const writeText = vi.fn(async () => {})
+    const restoreClipboard = installClipboard(writeText)
+    try {
+      const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
+      fireEvent.pointerEnter(wrapper)
+      act(() => { vi.advanceTimersByTime(500) })
+      await act(async () => { fireEvent.click(screen.getByRole('button')) })
+      expect(screen.getByText('Copied')).toBeTruthy()
+      fireEvent.pointerLeave(wrapper)
+      act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+      expect(screen.queryByText('Copied')).toBeNull()
+      fireEvent.pointerEnter(wrapper)
+      act(() => { vi.advanceTimersByTime(500) })
+      expect(screen.getByText('card body')).toBeTruthy()
+    } finally {
+      restoreClipboard()
+    }
+  })
+
   it('does not create copied feedback after an in-flight write unmounts', async () => {
     let acceptWrite: (() => void) | undefined
     const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
@@ -246,6 +301,27 @@ describe('HoverCard', () => {
     }
   })
 
+  it('does not restore copied feedback after an in-flight card closes', async () => {
+    let acceptWrite: (() => void) | undefined
+    const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
+    const restoreClipboard = installClipboard(writeText)
+    try {
+      const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
+      fireEvent.pointerEnter(wrapper)
+      act(() => { vi.advanceTimersByTime(500) })
+      fireEvent.click(screen.getByRole('button'))
+      fireEvent.pointerLeave(wrapper)
+      act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
+      fireEvent.pointerEnter(wrapper)
+      act(() => { vi.advanceTimersByTime(500) })
+      await act(async () => { acceptWrite?.() })
+      expect(vi.getTimerCount()).toBe(0)
+      expect(screen.getByText('card body')).toBeTruthy()
+    } finally {
+      restoreClipboard()
+    }
+  })
+
   it('coalesces activations while the clipboard write is in flight', async () => {
     let acceptWrite: (() => void) | undefined
     const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))

+ 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: 864dea6b63c0c87fd6aabb8caf61373473f08f2d
-README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c
+README.md: 6403d07d3d1d09232ebcc6fe707b39f1c68edb95
+README.zh.md: a5f7b8f278d38a83d547646cdf48bf6c33187475

Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-workspace/README.md


Разлика између датотеке није приказан због своје велике величине
+ 0 - 0
packages/client/ui-workspace/README.zh.md


+ 1 - 1
packages/client/ui-workspace/src/client/rows/Rows.tsx

@@ -350,7 +350,7 @@ export function SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork
       anchor={ownRow}
       content={<SessionHoverContent node={node} now={now} t={t} />}
       disabled={menuOpen || drag?.active === true}
-      copyText={title}
+      copyText={row.blank ? undefined : row.title}
       copyLabel={t('copy')}
       copiedLabel={t('hover.copied')}
     />

+ 2 - 1
packages/client/ui-workspace/tests/rows.spec.tsx

@@ -158,7 +158,7 @@ describe('workspace browser rows', () => {
       expect(screen.getAllByText('Project')).toHaveLength(2)
       expect(screen.getByText('/projects/project')).toBeTruthy()
       expect(screen.getByText(/^创建于 \d+年\d+月\d+日 /)).toBeTruthy()
-      await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
+      await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
       expect(writeText).toHaveBeenCalledWith('/projects/project')
       expect(screen.getByText('已复制')).toBeTruthy()
     } finally {
@@ -194,6 +194,7 @@ describe('workspace browser rows', () => {
       expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
       expect(screen.getByText('空闲')).toBeTruthy()
       expect(screen.queryByText('刚刚')).toBeNull()
+      expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
     } finally {
       vi.useRealTimers()
     }

Неке датотеке нису приказане због велике количине промена