Przeglądaj źródła

feat(web): copy values from hover cards

creatixchu 2 miesięcy temu
rodzic
commit
8714952c85

+ 2 - 2
.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

+ 1 - 1
.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<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):**
 

+ 1 - 1
.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<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` 身份缓存)。
 
 **不翻译边界(刻意决定,不是欠账):**
 

+ 6 - 0
.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

+ 29 - 0
.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.

+ 29 - 0
.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 场景则验证英文标签和浏览器剪贴板。

+ 9 - 1
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)

+ 2 - 2
packages/client/ui-primitives/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
-README.md: 58be01d56a85c66a144df3f8054840961e987403
-README.zh.md: 2efbec77e64d664553e93b5a8f8dcd2ec7fce49e
+README.md: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e
+README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5

+ 5 - 1
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.

+ 5 - 1
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。

+ 17 - 0
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;
+}

+ 54 - 4
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<HTMLSpanElement>(null)
   const cardRef = useRef<HTMLDivElement>(null)
   const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+  const copyTimerRef = useRef<ReturnType<typeof setTimeout> | 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<void> => {
+    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 && (
-    <div ref={cardRef} className={css.card} style={pos}>
-      {content}
+    <div
+      ref={cardRef}
+      className={`${css.card}${copyable ? ` ${css.copyable}` : ''}`}
+      style={pos}
+      role={copyable ? 'button' : undefined}
+      tabIndex={copyable ? 0 : undefined}
+      aria-label={copyable ? (copied ? copiedLabel : copyLabel) : undefined}
+      onClick={copyable ? () => { void copy(copyText) } : undefined}
+      onKeyDown={copyable
+        ? (e) => {
+          if (e.key !== 'Enter' && e.key !== ' ') return
+          e.preventDefault()
+          e.currentTarget.click()
+        }
+        : undefined}
+    >
+      {copied ? <span className={css.copied} role="status">{copiedLabel}</span> : content}
     </div>
   )
 

+ 138 - 1
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(
     <HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...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>): () => 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<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('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)

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: cc73214a281c6950acf8846f0bae3214c8726934
-README.zh.md: c0b6472c7db74dbfd4b0afd19a258e0132a7c534
+README.md: 864dea6b63c0c87fd6aabb8caf61373473f08f2d
+README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c

Plik diff jest za duży
+ 0 - 0
packages/client/ui-workspace/README.md


Plik diff jest za duży
+ 0 - 0
packages/client/ui-workspace/README.zh.md


+ 2 - 0
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',

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

@@ -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={title}
+      copyLabel={t('copy')}
+      copiedLabel={t('hover.copied')}
     />
   )
 }

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

@@ -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: '复制' })) })
+      expect(writeText).toHaveBeenCalledWith('/projects/project')
+      expect(screen.getByText('已复制')).toBeTruthy()
     } finally {
+      restoreClipboard()
       vi.useRealTimers()
     }
   })

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików