fix(web): preserve hover-card selection and feedback
This commit is contained in:
@@ -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: 11ce5d2b71255cb7e078f39f7409bb303bc81a6e
|
||||
README.zh.md: d1176a37df6d13103c76f907b9d7a80c37f855d5
|
||||
README.md: 3a7c0fcb6557e2f8aa213290932cefeb62621f0e
|
||||
README.zh.md: 2a0ce4e3b951cf77f6c8bd83dc15b874d1092027
|
||||
|
||||
@@ -6,7 +6,7 @@ Pure React atoms (zero cordis): StateDot, ic_ds_* icons, Button/Pill/Menu/Modal/
|
||||
|
||||
## 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).
|
||||
`HoverCard` keeps its portaled preview reachable across the anchor gap with a pointer-leave grace. A consumer may also pass `copyText`: the card then exposes button semantics for pointer and keyboard activation, includes that value after the `copyLabel` prefix in its accessible name, writes the exact value through the package clipboard helper, and temporarily replaces its content with `copiedLabel` only after the host accepts the write. A non-collapsed text selection intersecting the card suppresses pointer-click activation, while success feedback retains the original card height and clears when the card closes or after one second. `copyLabel` and `copiedLabel` are label props because this zero-cordis atom cannot read the application locale; omitting `copyText` preserves the read/select-only card. Rationale: [the hover-card copy note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md).
|
||||
|
||||
## Markdown rendering
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 悬浮卡片
|
||||
|
||||
`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)。
|
||||
`HoverCard` 通过指针离开宽限期,让采用 portal 渲染的预览在跨越与锚点之间的间隙时仍可抵达。消费方还可传入 `copyText`:此时卡片为指针与键盘激活提供按钮语义,其无障碍名称会在 `copyLabel` 前缀后包含该值,通过包内剪贴板辅助函数原样写入该值,并且只有宿主接受写入后,才会临时将内容替换为 `copiedLabel`。与卡片相交的非折叠文本选区会阻止指针点击激活;成功反馈保持卡片原有高度,并随卡片关闭或在一秒后清除。`copyLabel` 和 `copiedLabel` 采用 label prop,是因为这个 zero-cordis 原子组件无法读取应用 locale;省略 `copyText` 时,卡片维持只读且可选择文本的行为。理由见[悬浮卡片复制 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-hover-card-click-copy.md)。
|
||||
|
||||
## Markdown 渲染
|
||||
|
||||
|
||||
@@ -31,8 +31,13 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.feedback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.copied {
|
||||
display: block;
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
// traversal already treats it as inside — one pair of wrapper handlers covers
|
||||
// anchor and card alike.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { writeClipboard } from './clipboard.ts'
|
||||
@@ -22,8 +22,9 @@ 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.copyText - optional primary value copied by activation and
|
||||
* included in the card's accessible name.
|
||||
* @param props.copyLabel - accessible activation-label prefix (default "复制").
|
||||
* @param props.copiedLabel - visible success label (default "复制成功").
|
||||
* @returns anchor wrapper with the conditional portaled card.
|
||||
*/
|
||||
@@ -43,13 +44,30 @@ export function HoverCard({
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const copyHeightRef = useRef<number | null>(null)
|
||||
const copyEpochRef = useRef(0)
|
||||
const copyingRef = useRef(false)
|
||||
const mountedRef = useRef(true)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(() => { setOpen(false) })
|
||||
const clearCopied = useCallback(() => {
|
||||
if (copyTimerRef.current !== null) {
|
||||
clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = null
|
||||
}
|
||||
copyHeightRef.current = null
|
||||
setCopied(false)
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
copyEpochRef.current += 1
|
||||
clearCopied()
|
||||
setOpen(false)
|
||||
}, [clearCopied])
|
||||
|
||||
const { arm: armClose, cancel: cancelClose } = usePointerGrace(close)
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerRef.current !== null) {
|
||||
@@ -63,15 +81,19 @@ export function HoverCard({
|
||||
if (!disabled) return
|
||||
clearTimer()
|
||||
cancelClose()
|
||||
setOpen(false)
|
||||
}, [disabled, cancelClose])
|
||||
close()
|
||||
}, [disabled, cancelClose, close])
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
copyEpochRef.current += 1
|
||||
clearTimer()
|
||||
if (copyTimerRef.current !== null) clearTimeout(copyTimerRef.current)
|
||||
if (copyTimerRef.current !== null) {
|
||||
clearTimeout(copyTimerRef.current)
|
||||
copyTimerRef.current = null
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -112,31 +134,39 @@ export function HoverCard({
|
||||
const copy = async (text: string): Promise<void> => {
|
||||
if (copied || copyingRef.current) return
|
||||
copyingRef.current = true
|
||||
const copyEpoch = copyEpochRef.current
|
||||
const accepted = await writeClipboard(text)
|
||||
copyingRef.current = false
|
||||
if (!accepted || !mountedRef.current) return
|
||||
const card = cardRef.current
|
||||
if (!accepted || !mountedRef.current || copyEpoch !== copyEpochRef.current || card === null) return
|
||||
const height = card.offsetHeight
|
||||
copyHeightRef.current = height > 0 ? height : null
|
||||
setCopied(true)
|
||||
copyTimerRef.current = setTimeout(() => {
|
||||
copyTimerRef.current = null
|
||||
setCopied(false)
|
||||
}, 1000)
|
||||
copyTimerRef.current = setTimeout(clearCopied, 1000)
|
||||
}
|
||||
|
||||
const copyable = copyText !== undefined
|
||||
const card = open && pos !== null && (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}`}
|
||||
style={pos}
|
||||
className={`${css.card}${copyable ? ` ${css.copyable}` : ''}${copied ? ` ${css.feedback}` : ''}`}
|
||||
style={{ ...pos, minHeight: copied && copyHeightRef.current !== null ? copyHeightRef.current : undefined }}
|
||||
role={copyable ? 'button' : undefined}
|
||||
tabIndex={copyable ? 0 : undefined}
|
||||
aria-label={copyable ? (copied ? copiedLabel : copyLabel) : undefined}
|
||||
onClick={copyable ? () => { void copy(copyText) } : undefined}
|
||||
aria-label={copyable ? `${copyLabel}: ${copyText}` : undefined}
|
||||
onClick={copyable
|
||||
? (e) => {
|
||||
const selection = window.getSelection()
|
||||
if (selection !== null && !selection.isCollapsed && selection.rangeCount > 0
|
||||
&& selection.getRangeAt(0).intersectsNode(e.currentTarget)) return
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
onKeyDown={copyable
|
||||
? (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return
|
||||
e.preventDefault()
|
||||
e.currentTarget.click()
|
||||
void copy(copyText)
|
||||
}
|
||||
: undefined}
|
||||
>
|
||||
@@ -172,7 +202,7 @@ export function HoverCard({
|
||||
if (cardRef.current?.contains(e.target as Node)) return
|
||||
clearTimer()
|
||||
cancelClose()
|
||||
setOpen(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{anchor}
|
||||
|
||||
@@ -140,6 +140,38 @@ describe('HoverCard', () => {
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps a completed card selection instead of treating its click as copy', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
const selection = window.getSelection()
|
||||
if (selection === null) throw new Error('jsdom selection API unavailable')
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'card body', copyLabel: 'Copy' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button', { name: 'Copy: card body' })
|
||||
const selectedText = screen.getByText('card body')
|
||||
const cardRange = document.createRange()
|
||||
cardRange.selectNodeContents(selectedText)
|
||||
selection.addRange(cardRange)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).not.toHaveBeenCalled()
|
||||
expect(selection.toString()).toBe('card body')
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
|
||||
// A non-collapsed selection elsewhere does not block this card.
|
||||
selection.removeAllRanges()
|
||||
const anchorRange = document.createRange()
|
||||
anchorRange.selectNodeContents(screen.getByText('row'))
|
||||
selection.addRange(anchorRange)
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
expect(writeText).toHaveBeenCalledWith('card body')
|
||||
} finally {
|
||||
selection.removeAllRanges()
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('a press while closed leaves the card closed', () => {
|
||||
mount()
|
||||
fireEvent.pointerDown(screen.getByText('row'))
|
||||
@@ -158,11 +190,13 @@ describe('HoverCard', () => {
|
||||
})
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
const card = screen.getByRole('button', { name: 'Copy path' })
|
||||
const card = screen.getByRole('button', { name: 'Copy path: /full/path' })
|
||||
Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 96 })
|
||||
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)
|
||||
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
||||
expect(card.style.minHeight).toBe('96px')
|
||||
// Repeated activation while feedback is visible neither rewrites nor
|
||||
// extends the one-second success window.
|
||||
await act(async () => { fireEvent.click(card) })
|
||||
@@ -170,7 +204,8 @@ describe('HoverCard', () => {
|
||||
act(() => { vi.advanceTimersByTime(999) })
|
||||
expect(screen.getByText('Copied')).toBeTruthy()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(screen.getByRole('button', { name: 'Copy path' })).toBe(card)
|
||||
expect(screen.getByRole('button', { name: 'Copy path: /full/path' })).toBe(card)
|
||||
expect(card.style.minHeight).toBe('')
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
@@ -228,6 +263,26 @@ describe('HoverCard', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('clears copied feedback when the card closes', async () => {
|
||||
const writeText = vi.fn(async () => {})
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button')) })
|
||||
expect(screen.getByText('Copied')).toBeTruthy()
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
expect(screen.queryByText('Copied')).toBeNull()
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create copied feedback after an in-flight write unmounts', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
@@ -246,6 +301,27 @@ describe('HoverCard', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not restore copied feedback after an in-flight card closes', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
const restoreClipboard = installClipboard(writeText)
|
||||
try {
|
||||
const { wrapper } = mount({ copyText: 'value', copiedLabel: 'Copied' })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
fireEvent.pointerLeave(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) })
|
||||
fireEvent.pointerEnter(wrapper)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
await act(async () => { acceptWrite?.() })
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
expect(screen.getByText('card body')).toBeTruthy()
|
||||
} finally {
|
||||
restoreClipboard()
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces activations while the clipboard write is in flight', async () => {
|
||||
let acceptWrite: (() => void) | undefined
|
||||
const writeText = vi.fn(() => new Promise<void>((resolve) => { acceptWrite = resolve }))
|
||||
|
||||
@@ -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: 864dea6b63c0c87fd6aabb8caf61373473f08f2d
|
||||
README.zh.md: 0d8f3d79a0dbc996990bdd151a2247cbe66cb84c
|
||||
README.md: 6403d07d3d1d09232ebcc6fe707b39f1c68edb95
|
||||
README.zh.md: a5f7b8f278d38a83d547646cdf48bf6c33187475
|
||||
|
||||
@@ -8,7 +8,7 @@ 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.
|
||||
Workspace and Session hover cards copy the value their row clips: activating a Workspace card writes its full directory path, while activating a non-blank Session card writes its full display title. A provisional blank New Session card remains read-only because its localized label is a placeholder rather than session content. The card reports the dictionary-driven copied state only after the browser accepts the clipboard write.
|
||||
|
||||
The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
该选择器通过全局 `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 卡片则会写入其完整显示标题。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
|
||||
Workspace 和 Session 悬浮卡片会复制对应行被截断的值:激活 Workspace 卡片会写入其完整目录路径,激活非空白 Session 卡片则会写入其完整显示标题。临时的空白「新会话」卡片保持只读,因为其本地化标签是占位文案,并非会话内容。只有浏览器接受剪贴板写入后,卡片才会显示由字典提供的已复制状态。
|
||||
|
||||
Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。
|
||||
|
||||
|
||||
@@ -350,7 +350,7 @@ 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}
|
||||
copyText={row.blank ? undefined : row.title}
|
||||
copyLabel={t('copy')}
|
||||
copiedLabel={t('hover.copied')}
|
||||
/>
|
||||
|
||||
@@ -158,7 +158,7 @@ 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: '复制' })) })
|
||||
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制: /projects/project' })) })
|
||||
expect(writeText).toHaveBeenCalledWith('/projects/project')
|
||||
expect(screen.getByText('已复制')).toBeTruthy()
|
||||
} finally {
|
||||
@@ -194,6 +194,7 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.getAllByText('新会话').length).toBeGreaterThanOrEqual(2)
|
||||
expect(screen.getByText('空闲')).toBeTruthy()
|
||||
expect(screen.queryByText('刚刚')).toBeNull()
|
||||
expect(screen.getByText('空闲').closest('[role="button"]')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user