mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(web): copy values from hover cards
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-client-locale-full-rollout.md
|
||||
2026-07-30-client-locale-full-rollout.md: 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 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 场景则验证英文标签和浏览器剪贴板。
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user