feat(web): copy values from hover cards

This commit is contained in:
creatixchu
2026-07-31 15:38:22 +08:00
parent 6ca3b78e55
commit 8714952c85
19 changed files with 332 additions and 17 deletions
@@ -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
@@ -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/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
@@ -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.
@@ -0,0 +1,29 @@
# Agent Noteagent 决策记录):悬浮卡片激活时复制主要值
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
View File
@@ -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,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
View File
@@ -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
View File
@@ -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。
@@ -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;
}
@@ -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>
)
@@ -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,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
+2
View File
@@ -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.
@@ -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 的插件恢复后重新注册。
@@ -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',
@@ -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')}
/>
)
}
@@ -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()
}
})