From 8997afb3e52308e4fddd579351f6d321737eff21 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 16:13:12 +0800 Subject: [PATCH] fix(web): preserve hover-card selection and feedback --- ...2026-07-31-hover-card-click-copy.i18n.yaml | 4 +- .../2026-07-31-hover-card-click-copy.md | 8 +- .../2026-07-31-hover-card-click-copy.zh.md | 8 +- apps/web/tests/workspace-management.e2e.ts | 21 +++-- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/HoverCard.module.css | 7 +- .../client/ui-primitives/src/HoverCard.tsx | 66 +++++++++++---- .../ui-primitives/tests/hover-card.spec.tsx | 82 ++++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../ui-workspace/src/client/rows/Rows.tsx | 2 +- .../client/ui-workspace/tests/rows.spec.tsx | 3 +- 15 files changed, 169 insertions(+), 48 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml index 906a082a14..d4f7f72fe2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md index 6a59013cda..c87734fe32 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md +++ b/.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. diff --git a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md index f742b7e9ff..a57b5238b0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md +++ b/.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 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index b149a49e37..b00d220585 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/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() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index f4728b0681..3212ae3554 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 11ce5d2b71..3a7c0fcb65 100644 --- a/packages/client/ui-primitives/README.md +++ b/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 diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d1176a37df..2a0ce4e3b9 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/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 渲染 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index b5edbfdb9c..3ba3469fed 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/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; diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index a3bebdd460..2387eb5172 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/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(null) const timerRef = useRef | null>(null) const copyTimerRef = useRef | null>(null) + const copyHeightRef = useRef(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 => { 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 && (
{ 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} diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 2931f89668..9e37a61d8e 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/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((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((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((resolve) => { acceptWrite = resolve })) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3beee61dc4..40da4bb214 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 864dea6b63..6403d07d3d 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -8,7 +8,7 @@ The browser renders grouped or flat Session rows from the global runtime hooks a The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. The Session row's Archive action commits without a confirmation dialog (non-destructive: the log and the workspace accounting slot remain) through `ctx.workspaces.archiveSession`; the row disappears from every grouping surface — workspace groups, Ungrouped, content search, and the flat list — when the archive-set echo lands, and failures are console diagnostics that leave the tree unchanged. A blank New Session row is a pure placeholder: it renders no row menu and no time label (nothing has happened in it yet), so rename, fork, and archive first apply once the first prompt lands. -Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a Session card writes its full display title. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. +Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write. The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 0d8f3d79a0..a5f7b8f278 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -8,7 +8,7 @@ 该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。Session 行内的 Archive 操作不经确认对话框直接提交(非破坏性:日志和 workspace 记账席位保持不变),通过 `ctx.workspaces.archiveSession` 归档;归档集合回声落地后,该行从所有分组视图——workspace 分组、Ungrouped、内容搜索和平铺列表——中消失,失败只作为控制台诊断输出,树保持不变。blank「新会话」行是纯占位:不渲染行菜单和时间标签(其中还没有发生任何事),rename/fork/归档都从首条 prompt 落地后才可用。 -Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活 Session 卡片则会写入其完整显示标题。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 +Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index f967a81e0f..27a105df30 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/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={} disabled={menuOpen || drag?.active === true} - copyText={title} + copyText={row.blank ? undefined : row.title} copyLabel={t('copy')} copiedLabel={t('hover.copied')} /> diff --git a/packages/client/ui-workspace/tests/rows.spec.tsx b/packages/client/ui-workspace/tests/rows.spec.tsx index e1dfe4efae..bb0be9538c 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/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() }