From 8714952c85849342b9162d93c53ccde7459f92cc Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 31 Jul 2026 15:38:22 +0800 Subject: [PATCH] feat(web): copy values from hover cards --- ...07-30-client-locale-full-rollout.i18n.yaml | 4 +- .../2026-07-30-client-locale-full-rollout.md | 2 +- ...026-07-30-client-locale-full-rollout.zh.md | 2 +- ...2026-07-31-hover-card-click-copy.i18n.yaml | 6 + .../2026-07-31-hover-card-click-copy.md | 29 ++++ .../2026-07-31-hover-card-click-copy.zh.md | 29 ++++ apps/web/tests/workspace-management.e2e.ts | 10 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 6 +- packages/client/ui-primitives/README.zh.md | 6 +- .../ui-primitives/src/HoverCard.module.css | 17 +++ .../client/ui-primitives/src/HoverCard.tsx | 58 +++++++- .../ui-primitives/tests/hover-card.spec.tsx | 139 +++++++++++++++++- packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 + packages/client/ui-workspace/README.zh.md | 2 + .../client/ui-workspace/src/client/locales.ts | 2 + .../ui-workspace/src/client/rows/Rows.tsx | 6 + .../client/ui-workspace/tests/rows.spec.tsx | 21 ++- 19 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml index a56a91c980..1f91308209 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.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/architecture/2026-07-30-client-locale-full-rollout.md -2026-07-30-client-locale-full-rollout.md: c080d9f240d4533ecd9694ceecfada8662c46425 -2026-07-30-client-locale-full-rollout.zh.md: 062d982e3d7ea62f3ca4c8fedb842e8336f0852c +2026-07-30-client-locale-full-rollout.md: ae67bc6e9c38180296c27f1f733b4f8dfd25ed08 +2026-07-30-client-locale-full-rollout.zh.md: ef05b6d992795708373aac6c0bfe514c5dac89bb diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md index c080d9f240..ae67bc6e9c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md @@ -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` is the key source and `en satisfies Record` 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):** diff --git a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md index 062d982e3d..ef05b6d992 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.zh.md @@ -14,7 +14,7 @@ typed locale 标准席位(`locale:` 注册声明 → 框架注入强类型 `t` **组件文案走标准 `t` 席位;深层子组件用 prop 下传**,类型写 `XxxProps['t']`。字典规范形态不变:`zh satisfies Record` 为 key 源、`en satisfies Record` 锁双语平衡。 -**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` 身份缓存)。 **不翻译边界(刻意决定,不是欠账):** 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 new file mode 100644 index 0000000000..906a082a14 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/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 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 new file mode 100644 index 0000000000..6a59013cda --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md @@ -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; 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. + +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`/`已复制`. + +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. + +## 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 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. 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 new file mode 100644 index 0000000000..f742b7e9ff --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.zh.md @@ -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 场景则验证英文标签和浏览器剪贴板。 diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index 2047f900ed..b149a49e37 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -415,6 +415,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // 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 (no aria role — // text anchors are the stable selector). @@ -422,10 +423,17 @@ 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.getByText('Idle', { exact: true }).locator('../../..') + const card = page.getByRole('button', { name: 'Copy' }) await card.hover() await page.waitForTimeout(600) 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']) + await card.click() + await page.getByRole('button', { name: 'Copied' }).waitFor({ timeout: 5_000 }) + 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) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index d55fe45007..f4728b0681 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: 58be01d56a85c66a144df3f8054840961e987403 -README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e +README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e +README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 58be01d56a..11ce5d2b71 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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, 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, 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). + ## 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). @@ -33,5 +37,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. diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 2efbec77e6..d1176a37df 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -4,6 +4,10 @@ 纯 React 原子组件(零 cordis):StateDot、ic_ds_* 图标、Button/Pill/Menu/Modal/Input、markdown 家族(MessageText/MarkdownText/JsonBlock)、只读 JsonTree 检查器、`useAnchoredMaxHeight` hook(把底部锚定的浮层高度收敛到锚点上方的视口空间,并在 resize、scroll 与调用方提供的依赖变化时重新测量)、TerminalBlock、DiffBlock,以及 WebBlock。契约:api-contracts v3 §8。 +## 悬浮卡片 + +`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)。 + ## 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)。 @@ -32,5 +36,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。 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index ff1ac5509d..b5edbfdb9c 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -21,3 +21,20 @@ background: var(--dsw-hovercard-bg); box-shadow: var(--dsw-shadow-lv3); } + +.copyable { + cursor: pointer; +} + +.copyable:focus-visible { + outline: 2px solid var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + +.copied { + display: block; + color: #FFFFFF; + font-size: 14px; + line-height: 20px; + text-align: center; +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index a768606d86..a3bebdd460 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -11,6 +11,7 @@ import { 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' @@ -21,19 +22,32 @@ 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.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(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) + const copyTimerRef = useRef | null>(null) + 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) }) @@ -52,7 +66,14 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false setOpen(false) }, [disabled, cancelClose]) - useEffect(() => clearTimer, []) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + clearTimer() + if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current) + } + }, []) // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. @@ -88,9 +109,38 @@ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false } }, [open, pos]) + const copy = async (text: string): Promise => { + if (copied || copyingRef.current) return + copyingRef.current = true + const accepted = await writeClipboard(text) + copyingRef.current = false + if (!accepted || !mountedRef.current) return + setCopied(true) + copyTimerRef.current = setTimeout(() => { + copyTimerRef.current = null + setCopied(false) + }, 1000) + } + + const copyable = copyText !== undefined const card = open && pos !== null && ( -
- {content} +
{ void copy(copyText) } : undefined} + onKeyDown={copyable + ? (e) => { + if (e.key !== 'Enter' && e.key !== ' ') return + e.preventDefault() + e.currentTarget.click() + } + : undefined} + > + {copied ? {copiedLabel} : content}
) diff --git a/packages/client/ui-primitives/tests/hover-card.spec.tsx b/packages/client/ui-primitives/tests/hover-card.spec.tsx index 631bad78a0..2931f89668 100644 --- a/packages/client/ui-primitives/tests/hover-card.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.spec.tsx @@ -17,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( row} content={
card body
} {...props} />, ) @@ -26,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 { + 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() @@ -128,6 +147,124 @@ describe('HoverCard', () => { 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' }) + 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) + // 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(screen.getByText('Copied')).toBeTruthy() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card) + 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('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 })) + 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('coalesces activations while the clipboard write is in flight', 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) }) + 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) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index a21fd21697..3beee61dc4 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: cc73214a281c6950acf8846f0bae3214c8726934 -README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534 +README.md: 864dea6b63c0c87fd6aabb8caf61373473f08f2d +README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index cc73214a28..864dea6b63 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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 **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. + 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. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c0b6472c7d..0d8f3d79a0 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 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 卡片则会写入其完整显示标题。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。 + Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/locales.ts b/packages/client/ui-workspace/src/client/locales.ts index 1ecc244329..af05dd55f1 100644 --- a/packages/client/ui-workspace/src/client/locales.ts +++ b/packages/client/ui-workspace/src/client/locales.ts @@ -54,6 +54,7 @@ export const zh = { 'status.running': '进行中', 'status.idle': '空闲', 'hover.created': '创建于 {time}', + 'hover.copied': '已复制', 'date.ymd': '{y}年{m}月{d}日', 'time.now': '刚刚', 'time.minutes': '{n}分钟', @@ -117,6 +118,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', diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index 77583140c1..f967a81e0f 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -158,6 +158,9 @@ export function ProjectRowItem({ group, onToggle, onCreate, actions, t }: { anchor={ownRow} content={} 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={} disabled={menuOpen || drag?.active === true} + copyText={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 c0b4959b17..e1dfe4efae 100644 --- a/packages/client/ui-workspace/tests/rows.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.spec.tsx @@ -33,6 +33,19 @@ function dragProps(overrides: Partial = {}): RowDragProps { } } +/** Install the async browser clipboard and restore its prior host shape. */ +function installClipboard(writeText: (text: string) => Promise): () => 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: '复制' })) }) + expect(writeText).toHaveBeenCalledWith('/projects/project') + expect(screen.getByText('已复制')).toBeTruthy() } finally { + restoreClipboard() vi.useRealTimers() } })