Merge latest master into manual compaction
This commit is contained in:
+2
-2
@@ -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/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: a357f20734d1aa8df60efbf28fd5b8a1a814d63e
|
||||
2026-07-30-client-locale-full-rollout.zh.md: d22b743f0597405e7f42374ec2caed5523e85595
|
||||
2026-07-30-client-locale-full-rollout.md: 09baf5876029295f7a80b6a0fe6a6395d98f406c
|
||||
2026-07-30-client-locale-full-rollout.zh.md: 806916aea15a21fd24fdfc4654976b3c4577a675
|
||||
@@ -14,7 +14,7 @@ After the typed locale standard seat landed (`locale:` on register → framework
|
||||
|
||||
**Component copy rides the standard `t` seat; deep children take `t` as a plain prop** typed `XxxProps['t']`. The dictionary canon is unchanged: `zh satisfies Record<string, string>` is the key source and `en satisfies Record<XxxKey, string>` locks bilingual balance.
|
||||
|
||||
**Zero-cordis atoms (ui-primitives) take copy as props**: `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
|
||||
**Zero-cordis atoms (ui-primitives) take copy as props**: `copyLabel`/`copiedLabel` on `HoverCard`, `labels` on `TerminalBlock`/`JsonTree`, `copyLabel`/`copiedLabel` on `CodeBlock`, `codeLabels` on `MarkdownText`, `truncatedLabel` on `JsonBlock`, `label` on `ConnectionBanner`, `closeLabel` on `Modal` — defaults are the previous hardcoded strings, so a consumer passing nothing renders byte-identical output. Localized plugins pass dictionary-driven labels from their own `t` seat; call sites passing object props memoize them on the `t` identity (`MarkdownText` caches its component table on the `codeLabels` identity).
|
||||
|
||||
**The non-translation boundary (deliberate decisions, not debt):**
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t`
|
||||
|
||||
**组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record<string, string>` 为 key 源、`en satisfies Record<XxxKey, string>` 锁双语平衡。
|
||||
|
||||
**zero-cordis 原子组件(ui-primitives)文案 props 化**:`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
|
||||
**zero-cordis 原子组件(ui-primitives)文案 props 化**:`HoverCard` 的 `copyLabel`/`copiedLabel`、`TerminalBlock`/`JsonTree` 的 `labels`、`CodeBlock` 的 `copyLabel`/`copiedLabel`、`MarkdownText` 的 `codeLabels`、`JsonBlock` 的 `truncatedLabel`、`ConnectionBanner` 的 `label`、`Modal` 的 `closeLabel`——默认值即原硬编码字符串,不传 props 的消费者渲染逐字节不变。已本地化的插件从自己的 `t` 席位传字典驱动的 label;传对象 props 的调用点按 `t` 身份 memo(`MarkdownText` 的组件表按 `codeLabels` 身份缓存)。
|
||||
|
||||
**不翻译边界(刻意决定,不是欠账):**
|
||||
|
||||
|
||||
@@ -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-07-30-hover-popup-pointer-grace.md
|
||||
2026-07-30-hover-popup-pointer-grace.md: 3f60c98ec6453b633feebe408cbc0c0c49eedea1
|
||||
2026-07-30-hover-popup-pointer-grace.zh.md: db10e156103284383f911684b2c92977a0315275
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: Hover popup pointer grace
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-30-hover-popup-pointer-grace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Both popups the workspace browser rows raise floated out of reach of the pointer. `HoverCard` closed on the first `pointerleave` from its anchor and rendered its card `pointer-events: none`, but the card sits 8px off the anchor's right edge, so every path to it crossed ground belonging to neither and killed the card before it arrived — the full workspace path and session title it exists to show could be read only in passing. The row action menus passed `closeOnPointerLeave`, whose handler sat on the portaled list: aiming back at the `...` trigger that opened the list closed it, and so did any overshoot past a list edge, with no window to come back.
|
||||
|
||||
## Decision
|
||||
|
||||
`usePointerGrace` ([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts)) owns one cancelable delayed close, shared by both atoms, with `POINTER_GRACE_MS` at 200. Leaving arms the close; coming back cancels it. Transit through an anchor-to-popup gap is therefore survivable, while a pointer that has genuinely moved on still dismisses the popup.
|
||||
|
||||
`HoverCard` arms the grace on leave instead of closing, and its card no longer sets `pointer-events: none`, so resting on the card holds it open. Re-entering while already open cancels the pending close without restarting the dwell, which keeps the card from blinking when the pointer crosses the gap. A press on the card starts a selection instead of dismissing it; only anchor-region presses and an owner flipping `disabled` dismiss immediately, ahead of the grace.
|
||||
|
||||
`Menu` moves pointer-leave dismissal from the portaled list to the wrapper span. React's enter/leave traversal runs over the React tree, so the trigger and the portaled list are one region there: crossing the 4px gap between them, or aiming back at the trigger, no longer counts as leaving. Leaving is only armed while the list is open, and an owner-driven close (selection, Escape, outside click) disarms a pending grace close in an effect keyed on `open` alone — folding that into the outside-click effect would cancel the grace on every re-render, since owners pass a fresh `onClose` closure each time.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Close the popups only on outside click and Escape.** Rejected because both popups are hover-raised and unlabeled as dismissible; leaving them up after the pointer has moved to another row would strand a card over unrelated content.
|
||||
|
||||
**Widen the anchor's hit area to abut the popup.** Rejected because the 8px and 4px offsets are the design's, and an invisible bridge element would have to track every reposition the fixed-positioned popups already do on scroll and resize.
|
||||
|
||||
**Keep the hover card `pointer-events: none` and only add the grace.** Rejected because the pointer resting on the card would then hit whatever is behind it, so the grace would expire and close the card the user had just reached.
|
||||
|
||||
**Give each atom its own timer.** Rejected because the two closes are the same behavior with the same tuning; a shared hook keeps them from drifting apart.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hover card is now hit-testable and covers 244px of whatever it overlays while shown, which is the price of being reachable; it still lives only as long as the pointer is on the row or the card. Row menus survive the round trip between trigger and list, and a menu that closes for its own reason cannot be reopened into a stale pending close. Menus without `closeOnPointerLeave` are untouched — the wrapper handlers are only attached when it is set.
|
||||
|
||||
## Testing
|
||||
|
||||
`packages/client/ui-primitives/tests/hover-card.spec.tsx` and `tests/atoms.spec.tsx` pin the grace boundary, cancel-on-return, no-second-dwell, disarm-on-owner-close, and the no-arming-while-closed case. The reachability gestures themselves — hovering onto the card, and moving between an open list and its trigger — are pinned in the real browser by `apps/web/tests/workspace-management.e2e.ts`, since they depend on hit testing and layout that jsdom does not model.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Agent Note: 悬浮弹层的指针宽限期
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-30-hover-popup-pointer-grace.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
工作区浏览器行弹出的两种弹层都处于指针无法抵达的位置。`HoverCard` 在指针离开锚点的第一个 `pointerleave` 上就关闭,其卡片还设置了 `pointer-events: none`;但卡片位于锚点右边缘外 8px 处,因此通往卡片的每条路径都要穿过既不属于锚点也不属于卡片的区域,卡片在指针抵达之前就已被销毁——它本应展示的完整工作区路径和会话标题只能匆匆一瞥。行操作菜单传入了 `closeOnPointerLeave`,而其处理器挂在传送后的列表上:把指针移回打开该列表的 `...` 触发按钮会关闭列表,越过列表边缘的任何一次抖动同样如此,且没有任何折返窗口。
|
||||
|
||||
## 决策
|
||||
|
||||
`usePointerGrace`([packages/client/ui-primitives/src/pointer-grace.ts](../../../../packages/client/ui-primitives/src/pointer-grace.ts))持有唯一一个可取消的延迟关闭,由两个原子组件共享,`POINTER_GRACE_MS` 为 200。离开会启动关闭,折返则取消它。因此指针可以安全穿越锚点与弹层之间的间隙,而真正移开的指针仍会关闭弹层。
|
||||
|
||||
`HoverCard` 在离开时启动宽限期而不再立即关闭,其卡片也不再设置 `pointer-events: none`,因此指针停在卡片上即可让它保持打开。在已打开状态下重新进入只取消待执行的关闭,而不重启停留计时,从而避免指针穿越间隙时卡片闪烁。在卡片上按下指针用于开始文本选择,不会关闭卡片;只有锚点区域内的按下和所有者将 `disabled` 置真,才会抢在宽限期之前立即关闭卡片。
|
||||
|
||||
`Menu` 把指针离开关闭的处理从传送后的列表移到包裹 span 上。React 的 enter/leave 遍历基于 React 树进行,因此触发按钮与传送后的列表在这里属于同一区域:穿越两者之间 4px 的间隙、或把指针移回触发按钮,都不再算作离开。只有在列表打开时才会启动离开关闭;由所有者驱动的关闭(选择、Escape、外部点击)会在一个仅以 `open` 为依赖的 effect 中解除待执行的宽限关闭——若把它折叠进外部点击的 effect,则每次重新渲染都会取消宽限期,因为所有者每次都传入新的 `onClose` 闭包。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**仅通过外部点击和 Escape 关闭这两种弹层。** 之所以否决:两者都由悬停唤起,且没有可见的关闭标识;在指针已移到其他行之后仍让它们停留,会把卡片遗留在无关内容之上。
|
||||
|
||||
**扩大锚点的命中区域,使其与弹层相接。** 之所以否决:8px 与 4px 的偏移来自设计稿,而一个不可见的桥接元素还必须跟随这两个固定定位弹层已经在滚动和缩放时执行的每一次重新定位。
|
||||
|
||||
**保留悬浮卡片的 `pointer-events: none`,只加入宽限期。** 之所以否决:那样指针停在卡片上时命中的是卡片背后的元素,宽限期仍会到期,并关闭用户刚刚够到的卡片。
|
||||
|
||||
**让两个原子组件各自持有计时器。** 之所以否决:这两处关闭是同一种行为、同一套调参;共享 hook 可以防止它们各自漂移。
|
||||
|
||||
## 后果
|
||||
|
||||
悬浮卡片现在可被命中,显示期间会遮挡其覆盖区域的 244px——这是可抵达性的代价;它依然只在指针位于行或卡片上时存在。行菜单现在能承受触发按钮与列表之间的往返,而因自身原因关闭的菜单也不会被残留的待执行关闭重新关掉。未设置 `closeOnPointerLeave` 的菜单不受影响——只有设置该属性时才会挂上包裹层处理器。
|
||||
|
||||
## 测试
|
||||
|
||||
`packages/client/ui-primitives/tests/hover-card.spec.tsx` 与 `tests/atoms.spec.tsx` 固定验证宽限期边界、折返取消、不重启停留计时、所有者关闭时解除待执行关闭,以及列表关闭时不启动关闭。可抵达性手势本身——把指针移到卡片上,以及在打开的列表与其触发按钮之间移动——由 `apps/web/tests/workspace-management.e2e.ts` 在真实浏览器中固定验证,因为它们依赖 jsdom 无法建模的命中测试与布局。
|
||||
@@ -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/feature/2026-07-31-hover-card-click-copy.md
|
||||
2026-07-31-hover-card-click-copy.md: c87734fe328fa2adb396d6685495faa82bc1fff2
|
||||
2026-07-31-hover-card-click-copy.zh.md: a57b5238b095de293605d4e309dcc2da3516e904
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Hover cards copy their primary value on activation
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-31-hover-card-click-copy.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Workspace and Session rows clip the two values their hover cards expose in full: the Workspace directory path and Session title. The [reachable card](../bug-fix/2026-07-30-hover-popup-pointer-grace.md) permits text selection, but selecting and copying a single known value is a needlessly precise gesture, and the card gives no confirmation that the clipboard accepted it.
|
||||
|
||||
## Decision
|
||||
|
||||
`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 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; 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
|
||||
|
||||
**Copy the card's rendered `textContent`.** That would concatenate the primary value with creation time or running status, making the clipboard payload depend on presentation and localization.
|
||||
|
||||
**Implement clipboard state in both Workspace card bodies.** The two consumers would duplicate host fallback, keyboard behavior, timer ownership, and success rendering even though the card owns the activation surface.
|
||||
|
||||
**Change the common Chinese `copied` label from `复制成功` to `已复制`.** That would alter every existing copy control to satisfy one card interaction. The Workspace dictionary owns the card-specific wording instead.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note(agent 决策记录):悬浮卡片激活时复制主要值
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-31-hover-card-click-copy.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Workspace 与 Session 行会截断对应悬浮卡片完整展示的两个值:Workspace 目录路径和 Session 标题。这张[可抵达的卡片](../bug-fix/2026-07-30-hover-popup-pointer-grace.md)支持文本选择,但复制单个已知值仍需精确选择,操作没有必要地繁琐;卡片也不会确认剪贴板是否接受了写入。
|
||||
|
||||
## 决策
|
||||
|
||||
`HoverCard` 接收可选的 `copyText`,以及 `copyLabel` 和 `copiedLabel`。传入 `copyText` 后,整个卡片都会为指针与键盘激活提供按钮语义;其无障碍名称由本地化操作前缀和原值组成,卡片通过共享剪贴板辅助函数写入该值,并且只有宿主接受写入后,才会用成功标签替换内容最长一秒。反馈保持复制前的卡片高度,并会随卡片关闭一同清除。未传入 `copyText` 时,该原子组件维持只读且可选择文本的行为。
|
||||
|
||||
Workspace 浏览器选择复制载荷,不让基础组件从渲染文本中推断:Workspace 卡片传入完整目录路径,非空白 Session 卡片传入完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。浏览器的 locale 席位提供 `Copy`/`复制`,成功状态则使用 `Copied`/`已复制`。
|
||||
|
||||
按下与激活仍是两份独立契约。卡片内发生指针按下时,卡片保持挂载,以便用户开始选择文本;文本选择完成后,若非折叠选区与卡片相交,就会阻止指针点击激活,而普通点击或按钮激活键会激活复制。锚点区域内发生指针按下时,卡片仍会立即消失;剪贴板拒绝写入时,卡片继续显示原内容,不会声称复制成功。
|
||||
|
||||
## 备选方案
|
||||
|
||||
**复制卡片渲染后的 `textContent`。** 这会把主要值与创建时间或运行状态拼接起来,使剪贴板载荷依赖表现形式和本地化结果。
|
||||
|
||||
**在两个 Workspace 卡片主体中分别实现剪贴板状态。** 两个消费方会重复实现宿主回退、键盘行为、计时器所有权和成功状态渲染,尽管激活表层由卡片持有。
|
||||
|
||||
**将通用中文 `copied` 标签从 `复制成功` 改为 `已复制`。** 这样会为了满足一种卡片交互而改变所有现有复制控件。卡片专用文案应由 Workspace 字典持有。
|
||||
|
||||
## 后果
|
||||
|
||||
两种非占位悬浮卡片都获得相同的点击与键盘操作能力,同时保留由消费方决定载荷的语义和本地化反馈。通用原子组件增加一条可选行为路径和一个一秒计时器;卡片关闭时会清除已复制状态,关闭或卸载后到达的完成结果会被忽略,写入被拒绝时绝不会报告成功。聚焦组件测试会固定指针选择文本的优先级、激活、失败、反馈尺寸与到期清除以及清理行为,真实浏览器中的 Workspace 场景则验证英文标签、反馈期间高度稳定和浏览器剪贴板。
|
||||
@@ -3,11 +3,11 @@
|
||||
// one creation route), the rename round trip over the real wire
|
||||
// (workspace.rename RPC + durable registry), duplicate-name pre-check, the
|
||||
// flat "In one list" view with its persisted group-by preference, the session
|
||||
// hover card, and the session archive round trip (row menu →
|
||||
// workspace.archiveSession RPC → durable global set → row hidden across
|
||||
// reload). Zero model calls: workspace.create/rename/archiveSession are host
|
||||
// RPCs with no model involvement, and the one session row the
|
||||
// flat/hover/archive scenarios need comes from a seeded fixture (the
|
||||
// hover card and row action menu, and the session archive round trip (row
|
||||
// menu → workspace.archiveSession RPC → durable global set → row hidden
|
||||
// across reload). Zero model calls: workspace.create/rename/archiveSession
|
||||
// are host RPCs with no model involvement, and the one session row the
|
||||
// flat/hover/menu/archive scenarios need comes from a seeded fixture (the
|
||||
// seeded-history seed reused verbatim — no new recording).
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -29,8 +29,12 @@ 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 card)', () => {
|
||||
describe('web e2e: workspace management (create / rename / flat view / hover affordances)', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
@@ -398,14 +402,17 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('shows the session hover card after a dwell on the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
|
||||
// Expand Ungrouped to reveal the seeded session row, then dwell on it
|
||||
// (the card opens after a 500ms hover delay, portaled to body).
|
||||
/**
|
||||
* Expand Ungrouped and return its seeded session row. The only visible child
|
||||
* is the non-blank persisted Session; the blank Session created while
|
||||
* adopting the Workspace stays hidden.
|
||||
* @returns the session row locator, already present.
|
||||
*/
|
||||
async function seededSessionRow() {
|
||||
const ungroupedRow = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
|
||||
const ungroupedSection = ungroupedRow.locator('..')
|
||||
// Initial-current auto-expansion can race this following test's gesture;
|
||||
// converge on expanded rather than assuming which update wins first.
|
||||
// Initial-current auto-expansion can race this gesture; converge on
|
||||
// expanded rather than assuming which update wins first.
|
||||
await expect.poll(async () => {
|
||||
if (await ungroupedRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await page.getByText('Ungrouped', { exact: true }).click()
|
||||
@@ -413,17 +420,72 @@ describe('web e2e: workspace management (create / rename / flat view / hover car
|
||||
}
|
||||
return await ungroupedRow.getAttribute('aria-expanded')
|
||||
}, { timeout: 5_000 }).toBe('true')
|
||||
// The only visible child is the non-blank persisted Session; the blank
|
||||
// Session created while adopting the Workspace remains hidden.
|
||||
const sessionRow = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
const row = ungroupedSection.locator('[role="treeitem"]').nth(1)
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
return row
|
||||
}
|
||||
|
||||
it('shows the session hover card after a dwell on the row', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-hover'))
|
||||
// Dwell on the seeded row; the card opens after a 500ms hover delay,
|
||||
// portaled to body.
|
||||
const sessionRow = await seededSessionRow()
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
await sessionRow.hover()
|
||||
// Card content: the full title plus the Idle status line (display-only
|
||||
// card; no aria role — text anchors are the stable selector).
|
||||
// Card content: the full title plus the Idle status line (no aria role —
|
||||
// text anchors are the stable selector).
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBeGreaterThanOrEqual(1)
|
||||
// Leaving the anchor closes it with no delay.
|
||||
// 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: ${rowTitle}` })
|
||||
await card.hover()
|
||||
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()
|
||||
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()
|
||||
await expect.poll(() => page.getByText('Idle', { exact: true }).count(), { timeout: 5_000 }).toBe(0)
|
||||
await expect.poll(() => card.count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps an open row menu up while the pointer moves between trigger and list', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
|
||||
const sessionRow = await seededSessionRow()
|
||||
// The trigger is display:none until its row hovers.
|
||||
await sessionRow.hover()
|
||||
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
|
||||
await trigger.click()
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
// that return trip used to fire the list's pointerleave and close the
|
||||
// menu, so a hesitating pointer lost it. Order matters — clicking leaves
|
||||
// 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(POINTER_TRANSIT_MS)
|
||||
await trigger.hover()
|
||||
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(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()
|
||||
await expect.poll(() => page.getByRole('menuitem', { name: 'Rename' }).count(), { timeout: 5_000 }).toBe(0)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -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: 6e30892b3fb657d22a06b74f9176c04552a969ff
|
||||
README.zh.md: e194f982f399c4c11749a04a28b10e189adc33bf
|
||||
README.md: f06e8c014c19d17197980a232e8b42080aac2901
|
||||
README.zh.md: feb97ee9fa834fbff10052909630ffcbbce9c78a
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/Input, the markdown family (MessageText/MarkdownText/JsonBlock), the read-only JsonTree inspector, the `useAnchoredMaxHeight` hook that clamps a bottom-anchored overlay to the viewport space above its anchor (re-measured on resize, scroll, and a caller-supplied dependency), TerminalBlock, DiffBlock, ReadBlock, SearchBlock, and WebBlock. Contract: api-contracts v3 §8.
|
||||
|
||||
## 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, 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
|
||||
|
||||
`MarkdownText` renders GFM from untrusted assistant output through React elements. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders image alt text without loading remote resources; `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars).
|
||||
@@ -41,5 +45,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
- **Glyph-level icons are redrawn approximations** — the fish logo (and the sparkle held by ui-conversation) come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
|
||||
- **Pill and Input have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms.
|
||||
- **StateDot `Active` variant is a hidden placeholder in the design** — not implemented; the four shipped states (done/warning/ongoing/error) are the complete P-I surface.
|
||||
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
|
||||
- **User-facing copy localizes through label props, defaulting to the original Chinese literals** — the atoms are zero-cordis and cannot reach `ctx.locale`, so `HoverCard` (`copyLabel`/`copiedLabel`), `TerminalBlock` (`labels`), `JsonTree` (`labels`), `CodeBlock` (`copyLabel`/`copiedLabel`), `MarkdownText` (`codeLabels`), `JsonBlock` (`truncatedLabel`), `ConnectionBanner` (`label`), and `Modal` (`closeLabel`) take their copy as optional props with the previous hardcoded strings as defaults. Localized plugins pass dictionary-driven labels from their own `t` seat; a consumer that passes nothing renders exactly the pre-localization output. `WebBlock` does not yet follow this pattern: its source expand/collapse controls, source-list and fetch truncation notes, and empty-search note stay inline Chinese, pending the same label-prop treatment.
|
||||
- **`TerminalBlock` is not a terminal emulator** — it renders settled or still-running command output, not an interactive session: SGR color and attributes are honored, and so are the in-line cursor movements a progress line uses — carriage return, backspace, erase-in-line, tab stops and character width. Absolute cursor positioning, screen clearing, and alternate-screen sequences are stripped. Basic-16 magenta and cyan have no token equivalent and stay literal rgb.
|
||||
@@ -4,9 +4,14 @@
|
||||
|
||||
纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock、ReadBlock、SearchBlock,以及 WebBlock。契约:api-contracts v3 §8。
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
`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 渲染
|
||||
|
||||
`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并只渲染图片 alt 文本而不加载远程资源;`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。
|
||||
|
||||
## 终端输出
|
||||
|
||||
`TerminalBlock` 将一条 shell 命令渲染为终端表层:命令的每一行各占一个提示行(缩短后的 `cwd` 标签只出现在第一行,因为视图只知道一个工作目录,而一个 `cd` 就会让后面的行去到别处,标签之后是该行)、命令输出、非零退出码或终止信号对应的状态胶囊,以及写入原始 `output` prop 的复制控件。一枚运行状态 `StateDot` 为整次调用标记一次,位于第一行,以脱离文档流的方式落在卡片以自身左内边距预留的落区中,因此它位于卡片盒之内、提示文字之左。它用到 `StateDot` 的三种状态——`running` 期间为追逐动画,与渲染状态胶囊相同的退出状态为红色,其余为绿色——因此卡片直接陈述其命令是否仍在运行,而不是让人从有无输出中推断;由于 `StateDot` 是 `aria-hidden`,它携带一处视觉隐藏的文本标签。无论多少行都只有一枚状态点是有意为之:退出状态属于整次调用,因此每行一枚就会声称一个视图并不携带的逐行结果。命令文本使用 `white-space: pre`,因此重复空格、制表符与缩进续行都原样呈现,同时该行仍保持单行并以省略号截断。ANSI 转义序列通过运行时依赖 `anser` 解析为 React span;光标移动在剥除无显示意义控制符之前先重放进逐行的列缓冲,因为回车与退格**只移动**光标:单是 `100%` 加回车再加 `OK` 显示为 `OK0%`,而 spinner 随重绘写出的 `\x1b[K` 会擦掉尾巴,因此 `100%\r\x1b[KOK` 显示为 `OK`。行内擦除的三种参数形式都被遵循,光标按终端列推进(8 列制表位;emoji 与 CJK 占两列;组合标记不占列),SGR 状态按单元格归一化存储,与终端一致,并跨行延续、在行结束时的状态处收束;基础 16 色前景色映射到 `--dsw-*` token,而 256 色板与真彩色值按字面 rgb 透传。输出保持 `white-space: pre` 并支持横向滚动,因此按列对齐的输出保留其对齐而不会软换行;超过 `maxLines`(默认 16,与 TUI 转录相同的切分算法)时折叠为头部切片加尾部切片,由展开按钮控制。原理:[Web 终端卡片笔记](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)。
|
||||
@@ -21,7 +26,7 @@
|
||||
|
||||
## 搜索结果
|
||||
|
||||
`SearchBlock` 渲染一次已完成的搜索,一个组件绘制两种 kind(由 `kind` 判别)。`matches`(grep)把每个文件渲染为粗体路径头加其 `lineNumber: line` 行,每个文件组可折叠;`paths`(glob)渲染扁平路径列表。两者都摊平成一个行列表,由高度上限做头/尾切片(默认 16,与 TerminalBlock 相同的切分算法),且都不软换行——长匹配行或路径横向滚动而非折行。当工具截断结果时,banner 摘要把截断前总数折入(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断结果呈现为完整;复制控件写入完整结构化结果,无论是否触及上限或哪些组被折叠。几何镜像 CodeBlock/TerminalBlock。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
|
||||
`SearchBlock` 渲染一次已完成的搜索,并通过 `kind` 判别,由一个组件处理两种结果。`matches`(grep)将每个文件显示为粗体路径头及其 `lineNumber: line` 行,各文件组均可折叠;`paths`(glob)显示扁平的路径列表。两者都摊平成一个行列表,由高度上限对其做头尾切片(默认 16,与 `TerminalBlock` 相同的切分算法),且都不软换行:较长的匹配行或路径会横向滚动而非折行。当工具截断结果时,banner 摘要会包含截断前的总数(grep 为 `显示 X / 共 N 处匹配 · K 个文件`,glob 为 `显示 X / 共 N 个路径`),使卡片绝不把截断后的结果呈现为完整结果;无论是否触及上限或哪些组处于折叠状态,复制控件都会写入完整的结构化结果。几何结构与 `CodeBlock`/`TerminalBlock` 一致。原理:[Web 搜索卡片笔记](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md)。
|
||||
|
||||
## Web 检索
|
||||
|
||||
@@ -40,5 +45,5 @@
|
||||
- **字形级图标是重新绘制的近似版本**:鱼形标志(以及 ui-conversation 持有的闪光图标)来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
|
||||
- **Pill 与 Input 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。
|
||||
- **StateDot 的 `Active` 变体是设计中的隐藏占位符**:尚未实现;已交付的四种状态(done/warning/ongoing/error)构成完整的 P-I 表层。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
|
||||
- **面向用户的文案经 label props 本地化,默认值为原中文字面量**:这些原子组件是 zero-cordis 的,拿不到 `ctx.locale`,因此 `HoverCard`(`copyLabel`/`copiedLabel`)、`TerminalBlock`(`labels`)、`JsonTree`(`labels`)、`CodeBlock`(`copyLabel`/`copiedLabel`)、`MarkdownText`(`codeLabels`)、`JsonBlock`(`truncatedLabel`)、`ConnectionBanner`(`label`)和 `Modal`(`closeLabel`)都把文案作为可选 props 接收,默认值即此前的硬编码字符串。已本地化的插件用自己的 `t` 席位传入字典驱动的 label;什么都不传的消费者渲染与本地化之前逐字节一致。`WebBlock` 尚未跟进这一模式:它的来源展开/收起控件、来源列表与 fetch 截断提示、以及空搜索提示仍是内联中文,待同样的 label-prop 处理。
|
||||
- **`TerminalBlock` 不是终端模拟器**:它渲染已结束或仍在运行的命令输出,而不是交互式会话:SGR 颜色与属性会被遵循,进度行所用的行内光标移动同样被遵循——回车、退格、行内擦除、制表位与字符宽度。绝对光标定位、清屏与备用屏幕序列会被剥离。基础 16 色中的洋红与青色没有对应 token,保持字面 rgb。
|
||||
@@ -7,7 +7,9 @@
|
||||
|
||||
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
|
||||
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
|
||||
* light/dark identical), so a component-level variable, not a theme token. */
|
||||
* light/dark identical), so a component-level variable, not a theme token.
|
||||
* Hit-testable on purpose: resting the pointer on the card holds it open
|
||||
* (HoverCard's grace close), which a `pointer-events: none` card cannot do. */
|
||||
.card {
|
||||
--dsw-hovercard-bg: #2C2C2E;
|
||||
position: fixed;
|
||||
@@ -18,5 +20,35 @@
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-hovercard-bg);
|
||||
box-shadow: var(--dsw-shadow-lv3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.copyable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copyable:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copied {
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,33 +1,73 @@
|
||||
// HoverCard: delayed hover-preview card portaled to document.body.
|
||||
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
|
||||
// the card is fixed-positioned at its right edge and repositions on
|
||||
// scroll/resize while open. Display-only — the card ignores pointer events
|
||||
// and closes the instant the pointer leaves the anchor (no close delay).
|
||||
// scroll/resize while open. The card is reachable: it takes pointer events,
|
||||
// and leaving the anchor only arms a grace-delayed close, so the pointer can
|
||||
// cross the 8px gap and settle on the card to read a clipped path or title.
|
||||
// The portaled card is a React child of the wrapper, so React's enter/leave
|
||||
// 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'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './HoverCard.module.css'
|
||||
|
||||
/**
|
||||
* Render an anchor with a hover-triggered preview card.
|
||||
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
|
||||
* @param props.content - card content (display-only, no pointer interaction).
|
||||
* @param props.content - card content; the pointer may rest on it, so it is
|
||||
* 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 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.
|
||||
*/
|
||||
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
|
||||
export function HoverCard({
|
||||
anchor, content, openDelayMs = 500, disabled = false,
|
||||
copyText, copyLabel = '复制', copiedLabel = '复制成功',
|
||||
}: {
|
||||
anchor: ReactNode
|
||||
content: ReactNode
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
copyText?: string | undefined
|
||||
copyLabel?: string | undefined
|
||||
copiedLabel?: string | undefined
|
||||
}) {
|
||||
const rootRef = useRef<HTMLSpanElement>(null)
|
||||
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 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) {
|
||||
@@ -40,10 +80,22 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
useEffect(() => {
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
}, [disabled])
|
||||
cancelClose()
|
||||
close()
|
||||
}, [disabled, cancelClose, close])
|
||||
|
||||
useEffect(() => clearTimer, [])
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
copyEpochRef.current += 1
|
||||
clearTimer()
|
||||
if (copyTimerRef.current !== null) {
|
||||
clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fixed-position from the anchor rect before paint; track the anchor while
|
||||
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
|
||||
@@ -79,9 +131,49 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
}
|
||||
}, [open, pos])
|
||||
|
||||
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
|
||||
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(clearCopied, 1000)
|
||||
}
|
||||
|
||||
const copyable = copyText !== undefined
|
||||
const card = open && pos !== null && (
|
||||
<div ref={cardRef} className={css.card} style={pos}>
|
||||
{content}
|
||||
<div
|
||||
ref={cardRef}
|
||||
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 ? `${copyLabel}: ${copyText}` : undefined}
|
||||
onClick={copyable
|
||||
? (e) => {
|
||||
const selection = window.getSelection()
|
||||
if (selection !== null && !selection.isCollapsed) {
|
||||
for (let i = 0; i < selection.rangeCount; i += 1) {
|
||||
if (selection.getRangeAt(i).intersectsNode(e.currentTarget)) return
|
||||
}
|
||||
}
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
onKeyDown={copyable
|
||||
? (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
{copied ? <span className={css.copied} aria-hidden="true">{copiedLabel}</span> : content}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -91,21 +183,33 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false
|
||||
className={css.root}
|
||||
onPointerEnter={() => {
|
||||
if (disabled) return
|
||||
// Coming back inside during the grace (the gap, or the card itself)
|
||||
// keeps the current card rather than restarting the dwell.
|
||||
cancelClose()
|
||||
if (open) return
|
||||
clearTimer()
|
||||
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
// Leaving a closed card schedules a no-op close; only arm while
|
||||
// open, matching Menu's shape.
|
||||
if (open) armClose()
|
||||
}}
|
||||
// Any press inside the anchor (row click, menu trigger) dismisses the
|
||||
// A press inside the anchor (row click, menu trigger) dismisses the
|
||||
// card immediately, without waiting for the owner to flip `disabled`.
|
||||
onPointerDownCapture={() => {
|
||||
// Capture presses reach this handler from the card too — it is a React
|
||||
// child of the wrapper — but a press there starts a selection, so the
|
||||
// card must stay mounted under it (and the browser's click with it).
|
||||
onPointerDownCapture={(e) => {
|
||||
if (cardRef.current?.contains(e.target as Node)) return
|
||||
clearTimer()
|
||||
setOpen(false)
|
||||
cancelClose()
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{anchor}
|
||||
{open && copyable && <span className={css.status} role="status">{copied ? copiedLabel : ''}</span>}
|
||||
{card !== false && createPortal(card, document.body)}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { CSSProperties, ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutline16 } from './icons/index.tsx'
|
||||
import { usePointerGrace } from './pointer-grace.ts'
|
||||
import css from './Menu.module.css'
|
||||
|
||||
/** Selectable row (optionally with a nested submenu). */
|
||||
@@ -69,8 +70,10 @@ const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
|
||||
* from the anchor rect (repositions on scroll/resize while open). Use when an
|
||||
* ancestor's overflow clipping would crop the in-place list; default false
|
||||
* keeps the pure-CSS in-place behavior.
|
||||
* @param props.closeOnPointerLeave - close the list when the pointer leaves
|
||||
* it (default false keeps it open until outside click/Escape/selection).
|
||||
* @param props.closeOnPointerLeave - close the list once the pointer has left
|
||||
* both trigger and list for the pointer grace (default false keeps it open
|
||||
* until outside click/Escape/selection). The grace makes the 4px trigger->list
|
||||
* gap and a brief overshoot survivable; coming back cancels the close.
|
||||
* @param props.compact - use reduced menu typography and spacing.
|
||||
* @param props.getAnchorRect - portal mode only: supply the anchor rect
|
||||
* directly (e.g. from a host-owned trigger button) instead of measuring the
|
||||
@@ -102,6 +105,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [openSubmenuId, setOpenSubmenuId] = useState<string | null>(null)
|
||||
const [fixedPos, setFixedPos] = useState<CSSProperties | null>(null)
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(onClose)
|
||||
|
||||
// Portal mode: fixed-position the list from the anchor rect before paint;
|
||||
// track the anchor while open (capture-phase scroll catches nested panes).
|
||||
@@ -179,6 +183,14 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
}
|
||||
}, [open, onClose])
|
||||
|
||||
// A close from selection/Escape/outside click outruns a pending grace close;
|
||||
// left armed it would shut a list reopened inside the grace window. Its own
|
||||
// effect, not the listener effect above: that one re-runs on every `onClose`
|
||||
// identity change and would cancel the grace mid-transit.
|
||||
useEffect(() => {
|
||||
if (!open) cancelClose()
|
||||
}, [open, cancelClose])
|
||||
|
||||
// The submenu card is absolutely positioned outside the list box; the
|
||||
// scroll clip would crop it, so only submenu-free menus get the height cap.
|
||||
const scrollable = !items.some(entry => !isSeparator(entry) && !isLabel(entry) && entry.submenu !== undefined && entry.submenu.length > 0)
|
||||
@@ -251,7 +263,6 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
className={clsx(css.list, compact && css.compactList, scrollable && css.scrollable, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
|
||||
style={portal ? fixedPos ?? MEASURE_STYLE : undefined}
|
||||
role="menu"
|
||||
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
|
||||
// React portals bubble synthetic events through the REACT tree: without
|
||||
// this stop, an item click re-fires the anchor row's own onClick
|
||||
// (open/toggle) after onSelect.
|
||||
@@ -268,8 +279,17 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
|
||||
</div>
|
||||
)
|
||||
|
||||
// Pointer-leave dismissal watches the WRAPPER, not the list: React's
|
||||
// enter/leave traversal runs over the React tree, so trigger and portaled
|
||||
// list are one region here. Aiming back at the trigger, or crossing the 4px
|
||||
// gap between them, therefore never counts as leaving.
|
||||
return (
|
||||
<span ref={rootRef} className={clsx(css.root, className)}>
|
||||
<span
|
||||
ref={rootRef}
|
||||
className={clsx(css.root, className)}
|
||||
onPointerEnter={closeOnPointerLeave ? cancelClose : undefined}
|
||||
onPointerLeave={closeOnPointerLeave ? () => { if (open) armClose() } : undefined}
|
||||
>
|
||||
{anchor}
|
||||
{portal ? (list !== false && createPortal(list, document.body)) : list}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// Shared close timing for pointer-dismissed popups (HoverCard, hover-closing
|
||||
// Menu). Both float free of their anchor, so the pointer has to cross ground
|
||||
// that belongs to neither on its way in; closing on the first pointerleave
|
||||
// makes the popup unreachable. The grace turns that transit into a cancelable
|
||||
// pending close.
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Grace before a pointer-dismissed popup closes. Covers the anchor->popup gap
|
||||
* (8px for HoverCard, 4px for Menu) at a hand's travel speed without leaving a
|
||||
* popup lingering once the pointer has genuinely moved on.
|
||||
*/
|
||||
export const POINTER_GRACE_MS = 200
|
||||
|
||||
/** Cancelable delayed close for a pointer-dismissed popup. */
|
||||
export interface PointerGrace {
|
||||
/** Schedule the close {@link POINTER_GRACE_MS} from now, replacing any pending one. */
|
||||
arm: () => void
|
||||
/** Abort a pending close (the pointer came back). */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay a pointer-dismissed popup's close so the pointer can cross the gap
|
||||
* between anchor and popup. A pending close is dropped on unmount.
|
||||
* @param close - runs when the grace elapses with no re-entry; read at fire
|
||||
* time, so callers may pass a fresh closure each render.
|
||||
* @returns the {@link PointerGrace} handle.
|
||||
*/
|
||||
export function usePointerGrace(close: () => void): PointerGrace {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const closeRef = useRef(close)
|
||||
closeRef.current = close
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
if (timerRef.current === null) return
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}, [])
|
||||
|
||||
const arm = useCallback(() => {
|
||||
cancel()
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null
|
||||
closeRef.current()
|
||||
}, POINTER_GRACE_MS)
|
||||
}, [cancel])
|
||||
|
||||
useEffect(() => cancel, [cancel])
|
||||
|
||||
return { arm, cancel }
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Button, ConnectionBanner, Input, Menu, Modal, Pill } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
@@ -160,16 +161,77 @@ describe('Menu', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith('del')
|
||||
})
|
||||
|
||||
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByRole('menu'))
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
it('closeOnPointerLeave closes a grace after the pointer leaves trigger and list; default never does', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// Still open through the grace: the pointer may be crossing the gap.
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
rerender(
|
||||
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).toHaveBeenCalledTimes(1)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('coming back inside the grace keeps the list open (trigger and list are one region)', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a close from selection disarms the pending grace close', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
const { rerender } = render(
|
||||
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
const wrapper = screen.getByText('trigger').parentElement as HTMLElement
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
// The owner closes for its own reason (selection/Escape) mid-grace; the
|
||||
// armed timer must not survive to shut a list reopened right after.
|
||||
rerender(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('leaving a closed list arms nothing', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<Menu open={false} closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
|
||||
fireEvent.pointerLeave(screen.getByText('trigger').parentElement as HTMLElement)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { POINTER_GRACE_MS } from '../src/pointer-grace.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { vi.useFakeTimers() })
|
||||
@@ -16,7 +17,13 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number
|
||||
})
|
||||
}
|
||||
|
||||
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
function mount(props: {
|
||||
openDelayMs?: number
|
||||
disabled?: boolean
|
||||
copyText?: string
|
||||
copyLabel?: string
|
||||
copiedLabel?: string
|
||||
} = {}) {
|
||||
const view = render(
|
||||
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
|
||||
)
|
||||
@@ -25,6 +32,19 @@ function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
|
||||
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
|
||||
}
|
||||
|
||||
/** Install the async browser clipboard and restore its prior host shape. */
|
||||
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
|
||||
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return () => {
|
||||
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
||||
else Object.defineProperty(navigator, 'clipboard', prior)
|
||||
}
|
||||
}
|
||||
|
||||
describe('HoverCard', () => {
|
||||
it('opens after the dwell delay, positioned right of the anchor', () => {
|
||||
const { wrapper } = mount()
|
||||
@@ -54,18 +74,47 @@ describe('HoverCard', () => {
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
|
||||
it('pointerleave closes an open card a grace later; re-enter after that restarts the dwell', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 1) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('reaching the card inside the grace keeps it open without restarting the dwell', () => {
|
||||
// The portaled card is a React child of the wrapper, so the pointer
|
||||
// arriving on it re-enters the wrapper — the gesture the 8px anchor gap
|
||||
// used to make impossible.
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS - 50) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS * 10) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('re-entering while open does not queue a second dwell', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
// A dwell restarted by the redundant enter would reopen the card here.
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
@@ -78,6 +127,237 @@ describe('HoverCard', () => {
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('a press on the card starts a selection instead of dismissing it', () => {
|
||||
// The card is a React child of the wrapper, so capture-phase presses on
|
||||
// it reach the wrapper's dismissal handler too; they must not close it,
|
||||
// or the first pointerdown of a text-selection drag would kill the card.
|
||||
const { wrapper } = mount()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.pointerDown(screen.getByText('card body'))
|
||||
// Still mounted after a grace's worth of time: no close was armed either.
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
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()
|
||||
|
||||
// Firefox supports multiple selection ranges: any range intersecting
|
||||
// this card wins, not only the first.
|
||||
selection.removeAllRanges()
|
||||
const getSelection = vi.spyOn(window, 'getSelection').mockReturnValue({
|
||||
isCollapsed: false,
|
||||
rangeCount: 2,
|
||||
getRangeAt: vi.fn((index: number) => ({
|
||||
intersectsNode: () => index === 1,
|
||||
})),
|
||||
} as unknown as Selection)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
getSelection.mockRestore()
|
||||
|
||||
// A non-collapsed selection elsewhere does not block this card.
|
||||
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'))
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
it('copies its configured value and shows success only for the feedback window', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({
|
||||
copyText: '/full/path',
|
||||
copyLabel: 'Copy path',
|
||||
copiedLabel: 'Copied',
|
||||
})
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
|
||||
const status = screen.getByRole('status')
|
||||
expect(status.textContent).toBe('')
|
||||
expect(card.contains(status)).toBe(false)
|
||||
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).toHaveBeenCalledWith('/full/path')
|
||||
expect(status.textContent).toBe('Copied')
|
||||
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) })
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
act(() => { vi.advanceTimersByTime(999) })
|
||||
expect(status.textContent).toBe('Copied')
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
||||
expect(card.style.minHeight).toBe('')
|
||||
expect(status.textContent).toBe('')
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('supports button keys and ignores unrelated keys', 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) })
|
||||
const card = screen.getByRole('button')
|
||||
fireEvent.keyDown(card, { key: 'Escape' })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
await act(async () => { fireEvent.keyDown(card, { key: 'Enter' }) })
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
await act(async () => { fireEvent.keyDown(card, { key: ' ' }) })
|
||||
expect(writeText).toHaveBeenCalledTimes(2)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps its content when the clipboard rejects the write', async () => {
|
||||
const writeText = vi.fn(async () => { throw new Error('denied') })
|
||||
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.queryByText('Copied')).toBeNull()
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('unmount clears copied feedback', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { view, wrapper } = mount({ copyText: 'value' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
||||
expect(vi.getTimerCount()).toBe(1)
|
||||
view.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
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.getByRole('status').textContent).toBe('Copied')
|
||||
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 }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { view, wrapper } = mount({ copyText: 'value' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
view.unmount()
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
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 }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button')
|
||||
fireEvent.click(card)
|
||||
fireEvent.click(card)
|
||||
expect(writeText).toHaveBeenCalledOnce()
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(screen.getByRole('status').textContent).toBe('Copied')
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('disabled suppresses opening entirely', () => {
|
||||
const { wrapper } = mount({ disabled: true })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
@@ -135,6 +415,7 @@ describe('HoverCard', () => {
|
||||
expect(card.style.left).toBe('308px')
|
||||
expect(card.style.top).toBe('90px')
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
expect(screen.queryByText('card body')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -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: 4f12f888d5a18673601ea5a2579f7dd825ca3f5b
|
||||
README.zh.md: 11a56de5c19650e1d570c860d959a83f4a0681af
|
||||
README.md: 8ebc55d7ad202622bbbc50b3d91a6985a1be29b9
|
||||
README.zh.md: d13a8941889654f05b8cafd3f6f4251a13e0b694
|
||||
@@ -8,6 +8,8 @@ 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 **Add workspace...** 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, under which the sidebar header drops its add button rather than offering a dead one). 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. Adding has exactly one route: the occupant's own create-folder affordance already covers a brand-new directory, so no separate create-by-name dialog exists. A menu only appears where there is something to choose between — with no Workspace listed, the anchor gesture raises the flow directly instead of a one-row popover, and it waits for the list baseline before treating an empty list as final. 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 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.
|
||||
|
||||
Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored.
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
|
||||
该选择器通过全局 `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;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。添加只有一条路径:占用者自带的新建文件夹能力已经覆盖了全新目录,因此不再单设按名称创建的对话框。菜单只在确有多个目标可选时出现——没有 Workspace 可列时,锚点手势直接拉起流程,而不是弹出只有一行的浮层;在列表基线落地前,空列表不算最终结果。运行时 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 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
|
||||
|
||||
@@ -49,6 +49,7 @@ export const zh = {
|
||||
'status.running': '进行中',
|
||||
'status.idle': '空闲',
|
||||
'hover.created': '创建于 {time}',
|
||||
'hover.copied': '已复制',
|
||||
'date.ymd': '{y}年{m}月{d}日',
|
||||
'time.now': '刚刚',
|
||||
'time.minutes': '{n}分钟',
|
||||
@@ -107,6 +108,7 @@ export const en = {
|
||||
'status.running': 'Running',
|
||||
'status.idle': 'Idle',
|
||||
'hover.created': 'Created {time}',
|
||||
'hover.copied': 'Copied',
|
||||
'date.ymd': '{y}-{m}-{d}',
|
||||
'time.now': 'now',
|
||||
'time.minutes': '{n}min',
|
||||
|
||||
@@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: {
|
||||
anchor={ownRow}
|
||||
content={<WorkspaceHoverContent label={row.label} cwd={row.cwd} createdAt={row.createdAt} t={t} />}
|
||||
disabled={menuOpen}
|
||||
copyText={row.cwd}
|
||||
copyLabel={t('copy')}
|
||||
copiedLabel={t('hover.copied')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -347,6 +350,9 @@ 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={row.blank ? undefined : row.title}
|
||||
copyLabel={t('copy')}
|
||||
copiedLabel={t('hover.copied')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -33,6 +33,19 @@ function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
|
||||
}
|
||||
}
|
||||
|
||||
/** Install the async browser clipboard and restore its prior host shape. */
|
||||
function installClipboard(writeText: (text: string) => Promise<void>): () => void {
|
||||
const prior = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
})
|
||||
return () => {
|
||||
if (prior === undefined) Reflect.deleteProperty(navigator, 'clipboard')
|
||||
else Object.defineProperty(navigator, 'clipboard', prior)
|
||||
}
|
||||
}
|
||||
|
||||
const dataTransfer = { effectAllowed: '', dropEffect: '' }
|
||||
|
||||
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
|
||||
@@ -129,8 +142,10 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('workspace hover card shows title, directory path, and creation time after the dwell', () => {
|
||||
it('workspace hover card shows its details and copies the full directory path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
@@ -143,7 +158,11 @@ 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: '复制: /projects/project' })) })
|
||||
expect(writeText).toHaveBeenCalledWith('/projects/project')
|
||||
expect(screen.getByRole('status').textContent).toBe('已复制')
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
@@ -175,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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user