fix(ui-workspace): copy session id directly from menu

This commit is contained in:
imccyu committed 2026-10-10 13:13:47 +08:00
1 parent 668b7f9746
commit b2dd2bf75b
16 files changed
+63 -236

No files matched your search

+5 -65
View File
@@ -2,7 +2,7 @@
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { cp, mkdir, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { cp, mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'
import { chromium, type Page } from 'playwright'
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
import {
@@ -71,79 +71,19 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.hover()
await trigger.click()
await menu.waitFor()
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'View Session ID']
const remainingNames = ['Pin session', 'Rename', 'Fork session', 'Archive session', 'Copy Session ID']
const remainingItems = menu.getByRole('menuitem')
expect(await remainingItems.count()).toBe(remainingNames.length)
for (const [index, name] of remainingNames.entries()) {
expect(await remainingItems.nth(index).and(menu.getByRole('menuitem', { name, exact: true })).count()).toBe(1)
}
expect(await menu.getByRole('separator').count()).toBe(0)
await menu.getByRole('menuitem', { name: 'View Session ID', exact: true }).click()
await expect.poll(() => menu.count()).toBe(0)
const idDialog = page.getByRole('dialog', { name: 'Session ID', exact: true })
await idDialog.waitFor()
const idText = idDialog.getByText(sessionId, { exact: true })
expect(await idDialog.getByRole('textbox').count()).toBe(0)
const copyButton = idDialog.getByRole('button', { name: 'Copy', exact: true })
const evidenceRoot = fileURLToPath(new URL('../../../.artifacts/screenshots/session-id-text/', import.meta.url))
await mkdir(evidenceRoot, { recursive: true })
const evidence = await mkdtemp(join(evidenceRoot, 'run-'))
for (const colorScheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme })
await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null)
for (const width of [1440, 320]) {
await page.setViewportSize({ width, height: 1000 })
const layout = await idText.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const rect = element.getBoundingClientRect()
const style = getComputedStyle(element)
return {
text: element.textContent,
selectable: style.userSelect,
overflow: style.overflow,
textOverflow: style.textOverflow,
lineTops: [...new Set(Array.from(range.getClientRects(), line => line.top))],
contained: Array.from(range.getClientRects()).every(line => line.left >= rect.left - 1 && line.right <= rect.right + 1),
fits: element.scrollWidth <= element.clientWidth + 1 && element.scrollHeight <= element.clientHeight + 1,
}
})
expect(layout.text).toBe(sessionId)
expect(layout.selectable).toBe('text')
expect(layout.overflow).toBe('visible')
expect(layout.textOverflow).not.toBe('ellipsis')
expect(layout.contained).toBe(true)
expect(layout.fits).toBe(true)
if (width === 1440) expect(layout.lineTops).toHaveLength(1)
else expect(layout.lineTops.length).toBeGreaterThan(1)
const textBox = (await idText.boundingBox())!
const copyBox = (await copyButton.boundingBox())!
const dialogBox = (await idDialog.boundingBox())!
expect(copyBox.x).toBeGreaterThanOrEqual(textBox.x + textBox.width)
expect(Math.abs(copyBox.y + copyBox.height / 2 - textBox.y - textBox.height / 2)).toBeLessThan(2)
expect(dialogBox.x).toBeGreaterThanOrEqual(24)
expect(dialogBox.x + dialogBox.width).toBeLessThanOrEqual(width - 24)
expect(dialogBox.width).toBeLessThanOrEqual(640)
await page.screenshot({ path: join(evidence, `${colorScheme}-${width}.png`) })
await writeFile(join(evidence, `${colorScheme}-${width}.json`), JSON.stringify({ layout, textBox, copyBox, dialogBox }, null, 2))
}
}
await page.setViewportSize({ width: 1440, height: 1000 })
await idText.evaluate((element) => {
const range = document.createRange()
range.selectNodeContents(element)
const selection = window.getSelection()!
selection.removeAllRanges()
selection.addRange(range)
})
expect(await page.evaluate(() => window.getSelection()?.toString())).toBe(sessionId)
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await idDialog.getByRole('button', { name: 'Copy', exact: true }).click()
await menu.getByRole('menuitem', { name: 'Copy Session ID', exact: true }).click()
await expect.poll(() => menu.count()).toBe(0)
await page.getByRole('alert').filter({ hasText: 'Session ID copied' }).waitFor()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(sessionId)
expect(await idText.textContent()).toBe(sessionId)
await copyButton.press('Escape')
await expect.poll(() => idDialog.count()).toBe(0)
expect(await page.getByRole('dialog', { name: 'Session ID', exact: true }).count()).toBe(0)
expect(console.pageErrors).toEqual([])
await assertFixtureInventory(EXPECTED, [
'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md',
@@ -3,7 +3,7 @@
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- menuitem "View Session ID"
- menuitem "Copy Session ID"
- separator
- menuitem "Export session"
- menuitem "Copy session ID"
@@ -3,4 +3,4 @@
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- menuitem "View Session ID"
- menuitem "Copy Session ID"
@@ -3,4 +3,4 @@
- menuitem "Rename"
- menuitem "Fork session"
- menuitem "Archive session"
- menuitem "View Session ID"
- menuitem "Copy Session ID"
@@ -27,8 +27,8 @@
en: 9eab3bfbaf98c987
zh: 54e7e141dae5c9e8
/deepseek-ai-dsh-client-ui-workspace/use-this-package/managing-sessions:
en: 037c2f88f234f533
zh: fb20fb6a7a59732d
en: e96b714aeb6dcd03
zh: 75b63aa2cafbb57a
/deepseek-ai-dsh-client-ui-workspace/use-this-package/pending-interactions:
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
@@ -42,8 +42,8 @@
en: 868904dbfb5713f8
zh: 225906bfef7398bf
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions:
en: 87c8d35825d0d557
zh: f47b948df885a51d
en: 24d196702ca9a5a0
zh: 351b4896a0217ac1
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/session-row-actions/packaged-client-plugin:
en: 36934671b80b11ae
zh: 8e0aea6162e35e77
+3 -3
View File
@@ -51,9 +51,9 @@ Collapsed search is one header action beside the view and add actions: activatin
### Managing sessions
The final built-in item in a Session row's "..." menu is View Session ID. It opens a content-sized dialog with selectable ID text and a Copy button to its right. The complete ID stays on one line when space permits and wraps within the viewport on narrow screens without truncation. Clipboard success or failure appears in the global toast; failure leaves the ID available for manual selection. Viewing and copying do not retain the Session, load its history, or call the Host. The Close button, dialog close icon, and Escape dismiss the dialog.
The final built-in item in a Session row's "..." menu is Copy Session ID. It closes the menu and copies the complete ID directly, without opening a dialog. Clipboard success or failure appears in the global toast; a failed copy can be retried from the menu. Copying does not retain the Session, load its history, or call the Host.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and View Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
The Session row's Rename action opens a dialog prefilled with the row's display title; confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. Double-clicking a title also opens Rename; for an unarchived Session, the preceding clicks open its conversation. Rename uses a temporary `workspaceOperation` reference and awaits its initial history opening. The row's Fork action reads ordinary cold history normally. When the Host reports that source migration is required, an app-wide Toast offers to open that Session; the action only navigates and does not retry fork. A successful fork uses the source's last completed turn and increments the inherited persisted title through Session Controller without retaining the child, opening its history, or changing selection. Workspace Delete opens a confirmation that states the retention boundary; success removes the group while its Sessions remain under Ungrouped. Pin, Rename, Fork, Archive, and Copy Session ID are themselves entries of the `sidebar.workspaces.session.menu.item` list (pin and archive also of `sidebar.workspaces.session.row.action`), so a client plugin's action takes whatever position its `order` gives it.
Archive commits without a confirmation dialog for a quiet Session and retains the Session's account position. A Session with running work is the one case that asks first: the Host refuses the plain archive and names that work, and the sidebar opens a stop-and-archive dialog listing it by family — the turn in progress, running subagents, background jobs, scheduled reminders, each with its labels — with the restore path stated; confirming asks the Host to stop the work the way the stop button does and archives once the archive set is durable, while the stops settle in the background, and cancelling leaves the Session running and visible. View options control visibility through one explicit three-way choice: Hide archived (the default) hides archived Sessions, All conversations (show archived) includes them, and Archived only hides ordinary Sessions and drops Workspaces without an archived Session; in tree grouping, children of a dropped Workspace nest under the nearest shown ancestor. Visible archived rows are grayed and carry an accessible explanation that they cannot be opened until restored; Rename, Fork, and Unarchive remain available. A successful archive shows a notice with an undo action and — while archived rows are hidden — a "filter archived sessions" action that switches the filter to All conversations (show archived); a stop-and-archive shows the same notice under its own wording, and undo restores the Session without resuming the stopped work. Unarchive removes the archive mark without restoring a pin or changing the saved position. An empty list shows a centered glyph-over-text placeholder; the Archived-only view names its own emptiness (No archived sessions yet) and adds a View other sessions text action that switches the filter back to Hide archived.
@@ -101,7 +101,7 @@ Each registration declares a **directory-flow child hole** (`single` kind: `conv
The Session row's "..." menu and its hover buttons are two `list` slots declared by the WorkspaceBrowser entry: `sidebar.workspaces.session.menu.item` and `sidebar.workspaces.session.row.action`. Every menu row and every hover button is an entry, this package's own actions included: `apply` registers `pin` (menu 100, button 200), `rename` (200), `fork` (300), `archive` (menu 400, button 100), and `session-id` (450) the way a client plugin registers its actions, so a plugin action lands wherever its `order` says, and reusing a shipped id at another `priority` shadows that action.
An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, the Session ID dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request.
An entry receives only the row identity (`sessionId`, `displayTitle`) and owns everything else. It reads the Host state it cares about through its own injected hooks (the pin and archive sets, as Sets derived once per Workspace snapshot), decides its own visibility (pin renders nothing on an archived row, because the Host keeps the two sets exclusive), carries its whole behavior in its registration's `inject` face (a pin also fronts the Session in its saved orders; an archive raises the notice), and raises its own surfaces: the rename dialog, the stop-and-archive dialog, and the row-action notice are this package's `shell.overlay` entries, fed by the requests the actions inject. A menu entry renders one `role="menuitem"` button (this package's rows use `MenuItemButton` from ui-primitives, which adds the host styling and `separatorBefore` for a group start; the hairline comes and goes with the row) and dismisses the menu through the slot-level `useMenuOpenState` hook, the menu's own open state bound from the row's render occurrence; a hover button entry renders one icon button, and the strip keeps its clicks from opening the row. The browser passes no action callbacks to its rows; its remaining verbs are the search results' restore button and the title double-click, which raises the same rename request.
The existing notice overlay dispatches `workspace.toast` by notice kind. Keyed registrations inject localized display mappings and their own actions; only archive notices subscribe to the viewing filter. They render the package-local `workspace.toast.content` Factory, which reuses the Toast primitive once for all presentations. The existing notice state, display sequence, latest-notice replacement, and dismissal remain shared; locale or filter changes do not restart the same notice's timer.
+3 -3
View File
@@ -51,9 +51,9 @@ kind: "package-reference"
### 管理会话
Session 行“...”菜单的最后一个内置项为“查看 Session ID”。它打开随内容宽度展开的对话框,以可选文本显示 ID,右侧提供“复制”按钮。空间足够时完整 ID 单行显示,窄屏时在视口内换行而不截断。复制成功或失败通过全局提示反馈;失败时 ID 仍可手动选中。查看和复制不会 retain 会话、加载历史或调用 Host。“关闭”按钮、对话框关闭图标和 Escape 均可关闭对话框。
Session 行“...”菜单的最后一个内置项为“复制 Session ID”。点击后关闭菜单并直接复制完整 ID,不打开对话框。复制成功或失败通过全局提示反馈;失败时可从菜单重试。复制不会 retain 会话、加载历史或调用 Host。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和查看 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
Session 行内的 Rename 操作打开一个以该行显示标题预填的对话框;确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。双击标题也会打开 Rename;对于未归档 Session,先发生的点击会打开其对话。Rename 使用临时 `workspaceOperation` reference,并等待首次历史打开。行内 Fork 正常读取普通冷历史。Host 报告源会话需要迁移时,应用级 Toast 提供打开该会话的操作;该操作仅导航,不重试 fork。成功的 fork 在源会话最后一个已完成轮次处创建分支,通过 Session Controller 递增继承的持久化标题,不 retain 子会话、不打开其历史,也不改变选择。Workspace 行内的 Delete 操作会打开确认框,说明保留边界;成功后该分组被移除,其 Session 则留在 Ungrouped 下。Pin、Rename、Fork、Archive 和复制 Session ID 本身就是 `sidebar.workspaces.session.menu.item` 列表的条目(pin 与 archive 同时也是 `sidebar.workspaces.session.row.action` 的条目),因此客户端插件的 action 由其 `order` 决定落在哪个位置。
对静止的 Session,Archive 不经确认对话框直接提交,并保留 Session 的记账位置。仍有工作在跑的 Session 是唯一会先询问的情形:Host 拒绝普通归档并列出这些工作,侧栏随即打开"停止并归档"对话框,按族列出——进行中的回合、运行中的子代理、后台任务、定时提醒,各带名称——并写明恢复路径;确认后请 Host 按停止按钮同样的方式停止这些工作,归档集合持久化后即完成归档,停止在后台收敛;取消则让 Session 继续运行并保持可见。视图选项以一组显式三选一控制显隐:隐藏已归档(默认项)隐藏已归档 Session,全部对话(显示已归档)将其纳入列表,仅显示已归档则隐藏普通 Session,并丢弃没有归档 Session 的 Workspace;树形分组下,被丢弃 Workspace 的子级挂到最近一个仍显示的祖先下。可见的归档行置灰,并提供无障碍说明,告知取消归档后才能打开;Rename、Fork 与取消归档仍然可用。归档成功后的提示提供"撤销"动作,并在归档行仍被隐藏时附带"筛选已归档会话"动作,后者直接把筛选切到全部对话(显示已归档);停止并归档显示同样的提示但措辞不同,撤销只恢复 Session,不会让被停止的工作继续。取消归档移除归档标记,但不恢复置顶,也不改变保存的位置。列表为空时显示居中的"图标在上、文字在下"占位;仅显示已归档视图用自己的文案(暂无已归档会话),并附"查看其他会话"文字按钮,点击把筛选切回隐藏已归档。
@@ -101,7 +101,7 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
Session 行的 "..." 菜单和行尾悬停按钮是 WorkspaceBrowser 注册项声明的两个 `list` slot:`sidebar.workspaces.session.menu.item` 与 `sidebar.workspaces.session.row.action`。每一个菜单行、每一个悬停按钮都是条目,本包自己的 action 也不例外:`apply` 以客户端插件注册自己 action 的同一方式注册 `pin`(菜单 100、按钮 200)、`rename`(200)、`fork`(300)、`archive`(菜单 400、按钮 100)和 `session-id`(450),因此插件 action 落在其 `order` 所指的位置,以另一个 `priority` 复用内置 id 则遮蔽该 action。
条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框、Session ID 对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。
条目只接收行身份(`sessionId`、`displayTitle`),其余一切自己负责:用自己注入的 hook 读自己关心的 Host 状态(置顶与归档集合,以每次 Workspace 快照只派生一次的 Set 形式),自己决定是否显示(Host 规定归档与置顶互斥,所以 pin 在归档行上不渲染),整套行为放在注册项自己的 `inject` face 里(置顶成功后顺带把会话推到保存顺序最前,归档成功后发提示),浮层也自己带——重命名对话框、停止并归档对话框和行 action 的提示是本包注册在 `shell.overlay` 的条目,由 action 注入的请求驱动。菜单条目渲染一个 `role="menuitem"` 的按钮(本包自己的行用 ui-primitives 的 `MenuItemButton`,它带宿主样式,开启新分组的行加 `separatorBefore`,分隔线随行一起出现和消失),并通过 slot 级 `useMenuOpenState` hook(菜单自身的打开状态,从该行的渲染出现处绑定)关闭菜单;悬停按钮条目渲染一个图标按钮,按钮条会拦住点击、不让它打开该行。browser 不再向行传任何 action 回调,它剩下的动作只有搜索结果里的恢复按钮和标题双击,后者发出的是同一个重命名请求。
现有通知浮层按通知 kind 分派 `workspace.toast`。各 keyed 注册注入本地化展示映射与自己的操作,只有归档通知订阅查看筛选状态。它们渲染包内的 `workspace.toast.content` Factory,所有展示共用一处 Toast primitive。现有通知状态、显示序号、最新提示替换及关闭逻辑仍共享;语言或筛选变化不会重新启动同一条提示的计时。
@@ -26,7 +26,7 @@
* row of a Session's "..." menu is an entry of
* `sidebar.workspaces.session.menu.item`, and every hover button at the row's
* end is an entry of `sidebar.workspaces.session.row.action`. The shipped
* actions — pin, rename, fork, archive, view ID — are ordinary entries
* actions — pin, rename, fork, archive, copy ID — are ordinary entries
* registered from `apply`, each carrying its own behavior in its own inject
* face and reading its own Host state through hooks that face injects, so a
* client plugin's action lands beside them by `order` and needs nothing from
@@ -394,28 +394,12 @@ export interface RenameSessionInjected {
requestSessionRename: (sessionId: SessionId, currentTitle: string) => void
}
/** Session ID action share; viewing an id does not open the Session. */
/** Session ID action share; copying does not open the Session. */
export interface SessionIdInjected {
/** Display the row's complete Session id. */
requestSessionId: (sessionId: SessionId) => void
}
/** Session ID dialog share, independent of Session history and row lifetime. */
export interface SessionIdDialogInjected {
hooks: {
/** The Session id on display, or none. */
sessionIdRequest: HostObservable<SessionId | null>
}
/** Dismiss the Session ID dialog. */
settleSessionId: () => void
/** Copy the complete id and report acceptance or failure through the global toast. */
copySessionId: (sessionId: SessionId) => Promise<void>
}
/** Props of the Session ID dialog in the frame-wide overlay. */
export type SessionIdDialogProps =
PropsRuntime<'shell.overlay'> & PropsLocale<'workspace'> & InjectFace<SessionIdDialogInjected>
/** A Session rename the rename action asked for; the dialog entry opens on it. */
export interface SessionRenameTarget {
/** Session to rename. */
@@ -7,7 +7,7 @@
* own `single` directory-flow child hole for the composed picker package's
* client half. WorkspaceBrowser additionally declares the two Session row
* action lists, and this apply registers the shipped actions — pin, rename,
* fork, archive, view ID — into them the way any client plugin would, each
* fork, archive, copy ID — into them the way any client plugin would, each
* with its own behavior, plus the dialogs and the row-action notice into
* `shell.overlay` (see the contract module doc). It also declares two
* Session-row seats: the leading decoration a row renders only while its own
@@ -40,7 +40,7 @@ import {
type ArchiveSessionInjected, type ForkSessionInjected, menuOpenStateFactory, type PinSessionInjected,
type SessionArchiveConfirmInjected, type SessionArchiveConfirmRequest,
type RenameSessionInjected, type RowToast, type RowToastInjected, type RowToastState, type SessionRenameDialogInjected,
type SessionIdDialogInjected, type SessionIdInjected, type WorkspaceBrowserInjected, type WorkspacePickerInjected,
type SessionIdInjected, type WorkspaceBrowserInjected, type WorkspacePickerInjected,
} from './contract/slots.ts'
import { createWorkspaceShortcutControls, installWorkspaceShortcuts } from './shortcuts.ts'
import { UiWorkspaceService } from './navigation.ts'
@@ -53,7 +53,7 @@ import { PinSessionMenuItem, PinSessionRowButton } from './session-actions/PinSe
import { RenameSessionMenuItem, SessionRenameDialog } from './session-actions/RenameSession.tsx'
import { RowActionToast } from './session-actions/RowActionToast.tsx'
import { registerToasts } from './toast/register.ts'
import { SessionIdDialog, SessionIdMenuItem } from './session-actions/SessionId.tsx'
import { SessionIdMenuItem } from './session-actions/SessionId.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
import { en, zh, type WorkspaceKey } from './locales.ts'
@@ -223,13 +223,7 @@ export function apply(ctx: Context): void {
})
},
})
const sessionIdRequest = createSnapshotStore<SessionId | null>(null)
const sessionIdInjected = (): SessionIdInjected => ({
requestSessionId: (sessionId) => { sessionIdRequest.set(sessionId) },
})
const sessionIdDialogInjected = (): SessionIdDialogInjected => ({
hooks: { sessionIdRequest },
settleSessionId: () => { sessionIdRequest.set(null) },
copySessionId: async (sessionId) => {
const copied = await writeClipboard(sessionId)
notify({ kind: copied ? 'sessionIdCopied' : 'sessionIdCopyFailed' })
@@ -323,9 +317,6 @@ export function apply(ctx: Context): void {
// The surfaces the actions raise live in the frame-wide layer: they must
// outlive the row menu the action sat in.
ctx.slots.inject('shell.overlay', function* () {
yield ctx.slots.register({
name: 'shell.overlay', id: 'workspace.session-id', locale: NS, inject: sessionIdDialogInjected,
}, SessionIdDialog)
yield ctx.slots.register({
name: 'shell.overlay', id: 'workspace.session-rename', locale: NS, inject: renameDialogInjected,
}, SessionRenameDialog)
@@ -63,10 +63,9 @@ export const zh = {
'menu.unarchiveSession': '取消归档',
'menu.pinSession': '置顶会话',
'menu.unpinSession': '取消置顶',
'menu.sessionId': '查看 Session ID',
'sessionId.title': 'Session ID',
'menu.sessionId': '复制 Session ID',
'toast.sessionIdCopied': 'Session ID 已复制',
'toast.sessionIdCopyFailed': '复制失败,请选中 Session ID 手动复制',
'toast.sessionIdCopyFailed': '复制失败,请重试',
'row.archived': '已归档',
'row.pinned': '已置顶',
'toast.archivedNotOpenable': '已归档对话暂时无法查看,请取消归档后查看',
@@ -190,10 +189,9 @@ export const en = {
'menu.unarchiveSession': 'Unarchive session',
'menu.pinSession': 'Pin session',
'menu.unpinSession': 'Unpin session',
'menu.sessionId': 'View Session ID',
'sessionId.title': 'Session ID',
'menu.sessionId': 'Copy Session ID',
'toast.sessionIdCopied': 'Session ID copied',
'toast.sessionIdCopyFailed': 'Copy failed. Select the Session ID and copy it manually.',
'toast.sessionIdCopyFailed': 'Copy failed. Try again.',
'row.archived': 'Archived',
'row.pinned': 'Pinned',
'toast.archivedNotOpenable': 'Archived sessions cannot be opened. Unarchive it to view.',
@@ -567,33 +567,6 @@
color: var(--dsw-alias-label-primary);
}
.sessionIdDialog.sessionIdDialog {
width: max-content;
max-width: min(640px, 100%);
}
.sessionIdRow {
display: flex;
align-items: center;
gap: 12px;
}
.sessionIdValue {
flex: 0 1 auto;
min-width: 0;
font-family: inherit;
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
white-space: normal;
overflow-wrap: anywhere;
user-select: text;
}
.sessionIdRow > button {
flex-shrink: 0;
}
.renameInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
@@ -1,47 +1,20 @@
/** Session ID inspection through the row menu and a frame-wide read-only dialog. */
import clsx from 'clsx'
import { Button, IconInfoOutlineRegular, MenuItemButton, Modal } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionIdDialogProps, SessionIdInjected, SessionMenuItemProps } from '../contract/slots.ts'
import css from '../rows/WorkspaceBrowser.module.css'
/** Copy the row's Session ID without opening its history. */
import { IconCopyOutlineRegular, MenuItemButton } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionIdInjected, SessionMenuItemProps } from '../contract/slots.ts'
/**
* Open the ID dialog without retaining or loading the Session.
* @param props - Row identity, menu dismissal, dialog request, and locale.
* Copy the complete ID after dismissing the row menu.
* @param props - Row identity, menu dismissal, clipboard action, and locale.
* @returns The final built-in menu item.
*/
export function SessionIdMenuItem({ sessionId, useMenuOpenState, requestSessionId, t }: SessionMenuItemProps<SessionIdInjected>) {
export function SessionIdMenuItem({ sessionId, useMenuOpenState, copySessionId, t }: SessionMenuItemProps<SessionIdInjected>) {
const [, setMenuOpen] = useMenuOpenState()
return (
<MenuItemButton icon={<IconInfoOutlineRegular />} onSelect={() => {
<MenuItemButton icon={<IconCopyOutlineRegular />} onSelect={() => {
setMenuOpen(false)
requestSessionId(sessionId)
void copySessionId(sessionId)
}}>
{t('menu.sessionId')}
</MenuItemButton>
)
}
/**
* Show the complete ID with manual selection and clipboard copying.
* @param props - Pending ID, dismissal, clipboard action, and locale.
* @returns The dialog, or nothing while no ID is requested.
*/
export function SessionIdDialog({ useSessionIdRequest, settleSessionId, copySessionId, t }: SessionIdDialogProps) {
const sessionId = useSessionIdRequest(value => value)
if (sessionId === null) return null
return (
<Modal
open
onClose={settleSessionId}
closeLabel={t('close')}
title={t('sessionId.title')}
className={clsx(css.sessionIdDialog)}
footer={<Button variant="outline" onClick={settleSessionId}>{t('close')}</Button>}
>
<div className={css.sessionIdRow}>
<code className={css.sessionIdValue}>{sessionId}</code>
<Button variant="outline" data-modal-autofocus onClick={() => { void copySessionId(sessionId) }}>{t('copy')}</Button>
</div>
</Modal>
)
}
@@ -17,7 +17,7 @@ import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepsee
import {
type ArchiveSessionInjected, type ForkSessionInjected, menuOpenStateFactory, type PinSessionInjected,
type RenameSessionInjected, type RowToastInjected, type SessionArchiveConfirmInjected, type SessionRenameDialogInjected,
type SessionIdDialogInjected, type SessionIdInjected, type WorkspaceViewStoreHandle,
type SessionIdInjected, type WorkspaceViewStoreHandle,
} from '../src/client/contract/slots.ts'
import { WorkspaceBrowser } from '../src/client/rows/WorkspaceBrowser.tsx'
import { ArchiveSessionMenuItem, ArchiveSessionRowButton, SessionArchiveConfirmDialog } from '../src/client/session-actions/ArchiveSession.tsx'
@@ -26,7 +26,7 @@ import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-a
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx'
import type { ArchiveToastInjected, ToastDisplayInjected } from '../src/client/toast/contract.ts'
import { SessionIdDialog, SessionIdMenuItem } from '../src/client/session-actions/SessionId.tsx'
import { SessionIdMenuItem } from '../src/client/session-actions/SessionId.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
import { FLAT_SESSION_ORDER_KEY } from '../src/client/stores.ts'
import { UNGROUPED_KEY } from '../src/client/tree.ts'
@@ -251,7 +251,7 @@ describe('ui-workspace apply', () => {
// The row actions follow the browser's own declaration, whenever it lands.
expect(after.slots.entries(MENU_ITEM)).toHaveLength(5)
expect(after.slots.entries(ROW_ACTION)).toHaveLength(2)
expect(after.slots.entries('shell.overlay')).toHaveLength(4)
expect(after.slots.entries('shell.overlay')).toHaveLength(3)
})
it('declares the two Session row lists and registers the shipped actions and overlay surfaces into them', async () => {
@@ -279,7 +279,6 @@ describe('ui-workspace apply', () => {
['pin', 200, PinSessionRowButton, 'workspace'],
])
expect(rows('shell.overlay')).toEqual([
['workspace.session-id', undefined, SessionIdDialog, 'workspace'],
['workspace.session-rename', undefined, SessionRenameDialog, 'workspace'],
['workspace.session-archive', undefined, SessionArchiveConfirmDialog, 'workspace'],
['workspace.row-toast', undefined, RowActionToast, undefined],
@@ -309,32 +308,22 @@ describe('ui-workspace apply', () => {
}
})
it.each([true, false])('views and copies an ID without loading its Session (clipboard accepted: %s)', async (accepted) => {
it.each([true, false])('copies an ID directly without loading its Session (clipboard accepted: %s)', async (accepted) => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
declare(b.slots, 'sidebar.workspaces', 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const action = faceOf(entry(b.slots, MENU_ITEM, 'session-id')) as SessionIdInjected
const dialog = faceOf(entry(b.slots, 'shell.overlay', 'workspace.session-id')) as SessionIdDialogInjected
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
const writeText = vi.fn(async () => {
if (!accepted) throw new Error('clipboard denied')
})
vi.stubGlobal('navigator', { clipboard: { writeText } })
onTestFinished(() => { vi.unstubAllGlobals() })
expect(dialog.hooks.sessionIdRequest.getSnapshot()).toBeNull()
const requested = vi.fn()
const unsubscribe = dialog.hooks.sessionIdRequest.subscribe(requested)
onTestFinished(unsubscribe)
action.requestSessionId(sid('cold-session'))
expect(dialog.hooks.sessionIdRequest.getSnapshot()).toBe('cold-session')
expect(requested).toHaveBeenCalledOnce()
await dialog.copySessionId(sid('cold-session'))
await action.copySessionId(sid('cold-session'))
expect(writeText).toHaveBeenCalledExactlyOnceWith('cold-session')
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: accepted ? 'sessionIdCopied' : 'sessionIdCopyFailed' })
expect(dialog.hooks.sessionIdRequest.getSnapshot()).toBe('cold-session')
dialog.settleSessionId()
expect(dialog.hooks.sessionIdRequest.getSnapshot()).toBeNull()
expect(b.slots.entries('shell.overlay').some(row => row.options.id === 'workspace.session-id')).toBe(false)
expect(b.retain).not.toHaveBeenCalled()
expect(b.using).not.toHaveBeenCalled()
expect(b.binding).not.toHaveBeenCalled()
@@ -874,7 +863,7 @@ describe('ui-workspace apply', () => {
await fiber.await()
expect(b.slots.entries(MENU_ITEM)).toHaveLength(5)
expect(b.slots.entries(ROW_ACTION)).toHaveLength(2)
expect(b.slots.entries('shell.overlay')).toHaveLength(4)
expect(b.slots.entries('shell.overlay')).toHaveLength(3)
await fiber.dispose()
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
expect(b.slots.entries('conversation.hero.workspace')).toHaveLength(0)
@@ -14,7 +14,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
* (session.spec.ts#rename), the entries' own arms with
* session-actions.client.spec, the row's list rendering with rows.client.spec.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { act, cleanup, fireEvent, waitFor, within } from '@testing-library/react'
import type { ISession } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
@@ -123,7 +123,7 @@ describe('session rename through the assembled browser', () => {
const trigger = within(row as HTMLElement).getByLabelText('会话“Persisted title”的操作')
fireEvent.click(trigger)
expect(view.getAllByRole('menuitem').map(item => item.textContent)).toEqual([
'置顶会话', '重命名', '分叉会话', '归档会话', '查看 Session ID', 'Export action', 'Last action',
'置顶会话', '重命名', '分叉会话', '归档会话', '复制 Session ID', 'Export action', 'Last action',
])
expect(view.getAllByRole('separator')).toHaveLength(1)
const last = view.getByRole('menuitem', { name: 'Last action' })
@@ -140,15 +140,19 @@ describe('session rename through the assembled browser', () => {
expect(view.queryByRole('menu')).toBeNull()
await act(async () => { await Promise.resolve() })
expect(document.activeElement).toBe(trigger)
const writeText = vi.fn(async () => {})
const clipboard = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
onTestFinished(() => {
if (clipboard === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', clipboard)
})
fireEvent.click(trigger)
fireEvent.click(view.getByRole('menuitem', { name: '查看 Session ID' }))
fireEvent.click(view.getByRole('menuitem', { name: '复制 Session ID' }))
expect(view.queryByRole('menu')).toBeNull()
const value = await view.findByText(SID, { exact: true })
expect(value.tagName).toBe('CODE')
expect(value.textContent).toBe(SID)
expect(view.queryByRole('textbox', { name: 'Session ID' })).toBeNull()
fireEvent.keyDown(view.getByRole('dialog', { name: 'Session ID' }), { key: 'Escape' })
await waitFor(() => { expect(view.queryByRole('dialog', { name: 'Session ID' })).toBeNull() })
expect(writeText).toHaveBeenCalledExactlyOnceWith(SID)
await view.findByText('Session ID 已复制')
expect(view.queryByRole('dialog', { name: 'Session ID' })).toBeNull()
await runtime.dispose()
})
@@ -33,7 +33,7 @@ import { ForkSessionMenuItem } from '../src/client/session-actions/ForkSession.t
import { PinSessionMenuItem, PinSessionRowButton } from '../src/client/session-actions/PinSession.tsx'
import { RenameSessionMenuItem, SessionRenameDialog } from '../src/client/session-actions/RenameSession.tsx'
import { RowActionToast } from '../src/client/session-actions/RowActionToast.tsx'
import { SessionIdDialog, SessionIdMenuItem } from '../src/client/session-actions/SessionId.tsx'
import { SessionIdMenuItem } from '../src/client/session-actions/SessionId.tsx'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { en, zh } from '../src/client/locales.ts'
import { ShortcutRegistry } from '../../shortcuts/src/client/registry.ts'
@@ -114,40 +114,16 @@ function callOrder(fn: { mock: { invocationCallOrder: readonly number[] } }, nth
}
describe('Session ID action', () => {
it.each([t, tEn])('dismisses the menu before requesting the row ID', (translate) => {
it.each([t, tEn])('dismisses the menu before copying the complete row ID without a dialog', (translate) => {
const { state, setMenuOpen } = openMenu()
const requestSessionId = vi.fn()
render(<SessionIdMenuItem {...menuRow(state)} t={translate} requestSessionId={requestSessionId} />)
fireEvent.click(screen.getByRole('menuitem', { name: translate('menu.sessionId') }))
expect(setMenuOpen).toHaveBeenCalledWith(false)
expect(requestSessionId).toHaveBeenCalledExactlyOnceWith(ROW.sessionId)
expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(requestSessionId))
})
it('renders nothing without an ID request', () => {
render(<SessionIdDialog {...overlay} useSessionIdRequest={hook(null)} settleSessionId={vi.fn()} copySessionId={vi.fn()} />)
expect(screen.queryByRole('dialog')).toBeNull()
})
it.each([t, tEn])('keeps the complete ID as ordinary text beside its copy action', async (translate) => {
const sessionId = sid('session-c4421b66-252e-44c0-80ed-6e320abeba25')
const copySessionId = vi.fn(async () => {})
const settleSessionId = vi.fn()
render(<SessionIdDialog {...overlay} t={translate} useSessionIdRequest={hook(sessionId)}
settleSessionId={settleSessionId} copySessionId={copySessionId} />)
const value = screen.getByText(sessionId, { exact: true })
const copy = screen.getByRole('button', { name: translate('copy') })
expect(value.tagName).toBe('CODE')
expect(screen.queryByRole('textbox')).toBeNull()
expect(value.nextElementSibling).toBe(copy)
expect(screen.getAllByRole('button', { name: translate('copy') })).toHaveLength(1)
expect(value.textContent).toBe(sessionId)
fireEvent.click(screen.getByRole('button', { name: translate('copy') }))
await waitFor(() => { expect(copySessionId).toHaveBeenCalledExactlyOnceWith(sessionId) })
expect(settleSessionId).not.toHaveBeenCalled()
expect(value.textContent).toBe(sessionId)
for (const close of screen.getAllByRole('button', { name: translate('close') })) fireEvent.click(close)
expect(settleSessionId).toHaveBeenCalledTimes(2)
render(<SessionIdMenuItem {...menuRow(state)} sessionId={sessionId} t={translate} copySessionId={copySessionId} />)
fireEvent.click(screen.getByRole('menuitem', { name: translate('menu.sessionId') }))
expect(setMenuOpen).toHaveBeenCalledWith(false)
expect(copySessionId).toHaveBeenCalledExactlyOnceWith(sessionId)
expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(copySessionId))
expect(screen.queryByRole('dialog')).toBeNull()
})
})
@@ -2974,7 +2974,6 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
'client-ui-settings-account AccountPlatformHost id \'account.platform-page\'',
'client-ui-settings-session-log UploadToast id \'session-log-upload-toast\'',
'client-ui-shortcuts ShortcutReference id \'shortcuts\'',
'client-ui-workspace SessionIdDialog id \'workspace.session-id\'',
'client-ui-workspace SessionRenameDialog id \'workspace.session-rename\'',
'client-ui-workspace SessionArchiveConfirmDialog id \'workspace.session-archive\'',
'client-ui-workspace RowActionToast id \'workspace.row-toast\'',