Merge pull request #6628 from deepseek-harness/worktree/session-item-trailing-slot

feat(web): add trailing icon slots to session rows
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-20 10:46:10 +08:00
commit 32f4993cd6
17 files changed
+519 -62

No files matched your search

+125 -3
View File
@@ -44,9 +44,54 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
await row.waitFor({ timeout: 20_000 })
const trigger = row.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const trailing = row.locator('[data-session-trailing]')
const observations: { stage: string; icons: string[]; time: boolean; pinned: boolean; actions: boolean }[] = []
const observe = async (stage: string, iconsVisible: boolean, actionsVisible: boolean, target = row): Promise<void> => {
const icons = target.locator('[data-session-trailing]')
const actions = target.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
const time = target.locator('[class*="time"]')
await expect.poll(() => icons.first().isVisible()).toBe(iconsVisible)
await expect.poll(() => time.isVisible()).toBe(iconsVisible)
await expect.poll(() => actions.isVisible()).toBe(actionsVisible)
observations.push({
stage,
icons: await icons.filter({ visible: true }).evaluateAll(elements => elements.map(element => element.getAttribute('aria-label')!)),
time: await time.isVisible(),
pinned: await target.getByRole('img', { name: 'Pinned', exact: true }).isVisible(),
actions: await actions.isVisible(),
})
}
await expect.poll(() => trailing.count()).toBe(2)
expect(await trailing.evaluateAll(elements => elements.map(element => element.getAttribute('data-session-trailing'))))
.toEqual([sessionId, sessionId])
await page.mouse.move(0, 0)
await observe('resting', true, false)
await page.emulateMedia({ colorScheme: 'dark' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(true)
await observe('dark', true, false)
await page.emulateMedia({ colorScheme: 'light' })
await expect.poll(() => page.locator('body').evaluate(element => element.hasAttribute('data-ds-dark-theme'))).toBe(false)
const titleWidth = await row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width)
await row.hover()
await trigger.focus()
await observe('hover', false, true)
await page.mouse.move(0, 0)
await observe('pointer-left', true, false)
await row.click()
await expect.poll(() => page.locator('[data-composer-input][contenteditable="true"]')
.evaluate(element => element === document.activeElement)).toBe(true)
await row.click()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-current-row', true, false)
expect(await row.locator('[data-slot="sidebar.session.row.leading"]').evaluate(element => element.tagName)).toBe('SPAN')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('focus', false, true)
await page.keyboard.press('Tab')
expect(await row.evaluate(element => element.querySelector('button') === document.activeElement)).toBe(true)
await trigger.click()
await observe('menu-open', false, true)
const menu = page.getByRole('menu')
await menu.waitFor()
expect(await menu.getByRole('separator').count()).toBe(1)
@@ -56,7 +101,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
webSnapshotMode(),
)
await menu.getByRole('menuitem', { name: 'Export session' }).click()
await menu.getByRole('menuitem', { name: 'Export session' }).press('Enter')
await expect.poll(() => menu.count()).toBe(0)
expect(await page.evaluate(() => ({
action: document.documentElement.dataset.sessionAction,
@@ -64,10 +109,87 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
title: document.documentElement.dataset.sessionActionTitle,
}))).toEqual({ action: 'fixture.export-session', id: sessionId, title: SESSION_TITLE })
await page.getByRole('button', { name: 'Account menu', exact: true }).focus()
await page.mouse.move(0, 0)
await observe('blur', true, false)
await page.setViewportSize({ width: 390, height: 844 })
await page.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
await page.mouse.move(0, 0)
await observe('narrow', true, false)
const bounds = await row.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const icons = [...element.querySelectorAll('[data-session-trailing]')].map(icon => icon.getBoundingClientRect())
return {
titleRight: title.right, firstIconLeft: icons[0]!.left, lastIconRight: icons.at(-1)!.right,
rowRight: element.getBoundingClientRect().right,
}
})
expect(bounds.titleRight).toBeLessThanOrEqual(bounds.firstIconLeft)
expect(bounds.lastIconRight).toBeLessThanOrEqual(bounds.rowRight)
await page.setViewportSize({ width: 1680, height: 1000 })
const touchPage = await browser.newPage({
viewport: { width: 390, height: 844 }, locale: 'en-US', isMobile: true, hasTouch: true,
})
try {
const touchConsole = watchConsole(touchPage)
await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click()
const touchRow = touchPage.getByRole('treeitem').filter({ has: touchPage.getByText(SESSION_TITLE, { exact: true }) })
const touchTrigger = touchRow.getByRole('button', { name: `Session actions for ${SESSION_TITLE}` })
await touchTrigger.waitFor()
expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false)
await observe('touch', true, true, touchRow)
expect(await touchRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
expect(await touchRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
await touchTrigger.tap()
await touchPage.getByRole('menu').waitFor()
await observe('touch-menu-open', true, true, touchRow)
await touchPage.keyboard.press('Escape')
await observe('touch-menu-closed', true, true, touchRow)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
await touchRow.getByRole('img', { name: 'Pinned', exact: true }).waitFor()
await observe('touch-pinned', true, true, touchRow)
const touchBounds = await touchRow.evaluate((element) => {
const title = element.querySelector('[class*="title"]')!.getBoundingClientRect()
const resting = element.querySelector('[class*="rowResting"]')!.getBoundingClientRect()
const actions = element.querySelector('[class*="rowActions"]')!.getBoundingClientRect()
return { titleRight: title.right, restingLeft: resting.left, restingRight: resting.right,
actionsLeft: actions.left, actionsRight: actions.right, rowRight: element.getBoundingClientRect().right }
})
expect(touchBounds.titleRight).toBeLessThanOrEqual(touchBounds.restingLeft)
expect(touchBounds.restingRight).toBeLessThanOrEqual(touchBounds.actionsLeft)
expect(touchBounds.actionsRight).toBeLessThanOrEqual(touchBounds.rowRight)
await touchTrigger.tap()
await touchPage.getByRole('menuitem', { name: 'Archive session', exact: true }).tap()
await expect.poll(() => touchRow.count()).toBe(0)
expect(scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(sessionId)).toBe(true)
await page.getByRole('button', { name: 'View options', exact: true }).click()
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
await row.click()
await page.getByRole('alert').filter({ hasText: 'Archived sessions cannot be opened' }).waitFor()
await page.mouse.move(0, 0)
expect(await row.evaluate(element => element === document.activeElement)).toBe(true)
await observe('clicked-archived-row', true, false)
await scaffold.ctx.workspaceRegistry.unarchiveSession(sessionId)
await observe('touch-unarchived', true, true, touchRow)
expect(touchConsole.pageErrors).toEqual([])
} finally {
await touchPage.close()
}
scaffold.ctx.loader.remove(entryId)
await expect.poll(() => page.evaluate(
() => document.documentElement.dataset.liveDisposals,
)).toBe('1')
await expect.poll(() => trailing.count()).toBe(0)
await expect.poll(() => row.locator('[class*="title"]').evaluate(element => element.getBoundingClientRect().width))
.toBeGreaterThan(titleWidth)
await compareOrRefreshGolden(
join(EXPECTED, 'session-trailing.expected.json'),
JSON.stringify(observations, null, 2),
webSnapshotMode(),
)
await row.hover()
await trigger.click()
await menu.waitFor()
@@ -86,7 +208,7 @@ it('places dynamic Session menu rows by order among the shipped ones and removes
expect(console.pageErrors).toEqual([])
await assertFixtureInventory(EXPECTED, [
'bootstrap-rebuild.expected.md', 'enabled.expected.md', 'recovered.expected.md',
'session-actions.expected.md',
'session-actions.expected.md', 'session-trailing.expected.json',
])
} finally {
await browser.close()
@@ -0,0 +1,143 @@
[
{
"stage": "resting",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "dark",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "hover",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "pointer-left",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "clicked-current-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "focus",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "menu-open",
"icons": [],
"time": false,
"pinned": false,
"actions": true
},
{
"stage": "blur",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "narrow",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-open",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-menu-closed",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
},
{
"stage": "touch-pinned",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": true,
"actions": true
},
{
"stage": "clicked-archived-row",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": false
},
{
"stage": "touch-unarchived",
"icons": [
"Fixture badge",
"Fixture section"
],
"time": true,
"pinned": false,
"actions": true
}
]
@@ -3,7 +3,7 @@ window.__ModuleLoader__.load({
id: '@fixture/live-client',
factory(require) {
const React = require('react')
const { MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
const { IconFolderCloseRegular, IconNewChatOutlineRegular, MenuItemButton } = require('@deepseek-ai/dsh-client-ui-primitives')
const style = document.createElement('style')
style.dataset.plugin = '@fixture/live-client'
style.textContent = '[data-live-client] { color: rgb(12, 34, 56); position: absolute; bottom: 20px; right: 20px; }'
@@ -88,6 +88,18 @@ window.__ModuleLoader__.load({
t(label),
)
})
ctx.slots.inject('sidebar.session.row.trailing', function* () {
for (const [id, order, Icon, label] of [
['fixture.trailing-new-chat', 200, IconNewChatOutlineRegular, 'section'],
['fixture.trailing-folder', 100, IconFolderCloseRegular, 'badge'],
]) {
yield ctx.slots.register({
name: 'sidebar.session.row.trailing', id, order, locale: 'fixtureLive',
}, ({ sessionId, t }) => React.createElement('span', {
role: 'img', 'aria-label': t(label), 'data-session-trailing': sessionId,
}, React.createElement(Icon)))
}
})
ctx.slots.inject('sidebar.workspaces.session.menu.item', function* () {
// The shipped rows end at Session ID (450); these follow as one group,
// opened by the export row's hairline.
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
/web-client-slots:
en: 8d33b164230344fa
zh: 13a930ab96280744
en: 5f160332e4980284
zh: b1ae1da451a6209e
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+2
View File
@@ -10,6 +10,8 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
[ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior.
## Declaration and lifecycle
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
+2
View File
@@ -10,6 +10,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
[ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。
## 声明与生命周期
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
@@ -36,8 +36,8 @@
en: 8d82539e4e4e890d
zh: 7b97a3927de90ea8
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 721d5a49fe231191
zh: 3ef1dd720219e00e
en: 812c108a2b5f6c0d
zh: 68c344c03789b215
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/the-directory-flow-hole:
en: 868904dbfb5713f8
zh: 225906bfef7398bf
@@ -66,8 +66,8 @@
en: ca75c51c89c2c9b0
zh: de54f3467ef3b9be
/deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work:
en: 00d7b1ddda7390f5
zh: 1e5a1055d1be4d1d
en: 8211860a1b10ac61
zh: 4ef11b1fce30cacd
/deepseek-ai-dsh-client-ui-workspace/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
+11 -2
View File
@@ -91,7 +91,15 @@ The package injects the Client `remoteAccess` service and provides the root stan
The package is one composition: both target slots are declared by other plugins, so `apply` uses `slots.inject()` to register for each declaration lifetime and re-register after a declaring slot is restored.
The browser entry also declares two root-scoped `list` child seats on each Session row: `sidebar.session.row.leading`, rendered only while that row's primary status is idle and left blank on an archived row, and `sidebar.session.row.hover`, mounted only while that row's hover card is open. Both take the row's Session identity and nothing else, so an occupant reads its own data by that id; a Session-scoped seat would force a Session binding, which would activate and retain every listed Session.
The browser entry declares three root-scoped `list` child seats on each Session row: `sidebar.session.row.leading`, `sidebar.session.row.trailing`, and `sidebar.session.row.hover`. All receive only `sessionId`, which occupants use to address their own data without creating a Session binding. A Session-scoped seat would activate and retain every listed Session.
The leading seat occupies the status cell only while the row's primary status is idle and leaves archived rows blank. The trailing seat accepts non-interactive icons in ascending `order`, after the time label and before the pin marker. Blank rows omit the trailing seat; an empty seat adds no spacing. The hover seat mounts only while the row's hover card is open.
On hover-capable devices, hovering a Session row, giving keyboard focus to the row or its actions, or opening its menu replaces the time, trailing icons, and pin marker with action buttons. A pointer click does not keep the buttons visible after the pointer leaves.
Hoverless devices keep the static details and the More menu trigger visible, including while the menu is open; pin and archive remain available in that menu.
Session rows accept Tab focus and open with unmodified Enter or Space; Tab also reaches their action buttons, while modified keys remain available to global shortcuts.
Client callers can pass `prompt` and `clearPreviousDraft` in the second argument to `uiWorkspace.startSession`. A prompt is plain text; structured drafts are handled internally during restoration and Workspace transfer. Without clearing, existing text or attachments are preserved; explicit clearing replaces only the target Session's text and inline references, leaving attachments intact. Option values are captured before asynchronous creation and applied to the exact retained binding before navigation commits. Superseded navigation does not write content; a submission-blocked input refuses preparation and keeps the previous selection. Calls without options create a fresh Session using the current Agent preset definition. Workspace reconnection and explicit draft preparation may reuse an existing blank Session. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) describes the input owner's data flow.
@@ -216,11 +224,12 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
These limits define the search depth, the archive surface, and the picking carrier; they are current package constraints.
These limits cover search, navigation, archival, and folder selection.
- **No fuzzy content search or event deep links** — the content backend uses literal token/phrase matching, and selecting a result opens the Session rather than the matching event.
- **No Session deletion** — sessions can be archived but never deleted; archived rows stay recoverable in place through the archived view filter and the search results' unarchive action, and Workspace registration deletion does not delete Sessions.
- **Pending user interaction is not aggregated into collapsed groups** — a waiting row inside a collapsed group lights no group-header indicator and becomes visible only after that group is expanded.
- **Tree keyboard navigation is incomplete** — Workspace and search-result rows do not accept Tab focus, and the browser tree does not implement arrow-key navigation.
- **Native folder selection depends on the local Host carrier** — under the `-native` composition, in-process or remote browser deployments cannot open a local operating-system dialog; remote-capable picking is the `-browse` composition's in-app flow.
<a id="dev-note"></a>
+11 -2
View File
@@ -91,7 +91,15 @@ Workspace 和 Session 的启动基线均就绪后,空安装环境调用 `works
本包是一条组合:两个目标 slot 都由其他插件声明,因此 `apply` 使用 `slots.inject()` 在各自的声明生命周期内完成注册,并在目标 slot 的声明恢复后重新注册。
浏览器入口还为每个 Session 行声明两个 root 作用域的 `list` 子 slot:`sidebar.session.row.leading` 仅在该行主状态为 idle 时渲染、归档行留空,`sidebar.session.row.hover` 仅在该行的悬浮卡片打开时挂载。两者只接收行的 Session 身份,占用方据此读取自己的数据;Session 作用域的 slot 会强制建立 Session 绑定,从而激活并保留列表中每个 Session。
浏览器入口为每个 Session 行声明三个 root 作用域的 `list` 子 slot:`sidebar.session.row.leading`、`sidebar.session.row.trailing` 和 `sidebar.session.row.hover`。三者只接收 `sessionId`,占用方据此读取自己的数据,不建立 Session 绑定。Session 作用域的 slot 会激活并保留列表中每个 Session。
前置 slot 仅在该行主状态为 idle 时占用状态单元,归档行留空。尾部 slot 按 `order` 升序放置非交互图标,位置在时间标签之后、置顶标记之前。空白行不渲染尾部 slot;空 slot 不增加间距。悬浮 slot 仅在该行的悬浮卡片打开时挂载。
在支持悬停的设备上,鼠标移入 Session 行、通过键盘聚焦行或其操作按钮、打开行菜单,都会用操作按钮替代时间、尾部图标和置顶标记。鼠标点击不会让按钮在指针移出后仍然显示。
不支持悬停的设备始终保留静态信息和更多菜单入口,菜单打开时也不隐藏;置顶与归档仍可通过该菜单操作。
Session 行可通过 Tab 聚焦,使用不带修饰键的 Enter 或空格打开;Tab 也可移到行内操作按钮,组合键则继续交由全局快捷键处理。
客户端调用方可以在 `uiWorkspace.startSession` 的第二参数中传入 `prompt` 与 `clearPreviousDraft`。提示词仅接受普通文字;结构化草稿由内部恢复和工作区携带流程处理。不清空时保留已有文字或附件;显式清空只替换目标 Session 的文字和内联引用,不删除附件。在异步创建前捕获选项值,在导航提交前应用到准确保留的 binding。已被后续操作取代的导航不写内容;输入处于提交阻塞状态时拒绝准备并保留原选择。不传选项时按当前 Agent 预设定义创建新的 Session。工作区重连和显式草稿准备仍可复用已有空 Session。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)说明输入 owner 的数据流。
@@ -216,11 +224,12 @@ Workspace 与 Session 悬浮卡片会复制对应行被截断的值:激活 Wor
<a id="known-limitations-and-deferred-work"></a>
这些限制定义搜索深度、归档界面与选取载体;它们是当前包约束。
这些限制涵盖搜索、导航、归档和文件夹选择。
- **没有模糊内容搜索或事件深链接**:内容后端采用字面 token/短语匹配,选择结果会打开 Session,而不是匹配的事件。
- **没有 Session 删除**:会话可以归档但绝不会被删除;已归档的行通过「已归档会话」视图筛选与搜索结果中的取消归档操作原位恢复,删除 Workspace 注册记录不会删除 Session。
- **待处理的用户交互不会聚合到折叠的分组上**:折叠分组内正在等待的行不会点亮分组头指示,只有展开该分组后才可见。
- **树形列表键盘导航尚不完整**:Workspace 行和搜索结果行不接受 Tab 聚焦,浏览器树形列表也未实现方向键导航。
- **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,进程内部署或远程浏览器部署无法打开本地操作系统对话框;可远程的选取是 `-browse` 组合的应用内流程。
<a id="dev-note"></a>
@@ -32,11 +32,11 @@
* client plugin's action lands beside them by `order` and needs nothing from
* the browser beyond the row identity.
*
* The browser entry additionally declares two `list` seats per Session row
* (`sidebar.session.row.leading` / `sidebar.session.row.hover`) for ambient
* row decorations. Both take the row's Session identity and nothing else: a
* session-scoped seat would force a Session binding, which would activate and
* retain every listed Session.
* The browser entry additionally declares `list` seats per Session row
* (`sidebar.session.row.leading`, `sidebar.session.row.trailing`, and
* `sidebar.session.row.hover`) for ambient row decorations. They receive only
* the row's Session identity; a Session binding would activate and retain
* every listed Session.
*/
import type {
HostObservable, InjectFace, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory,
@@ -113,6 +113,12 @@ export interface SessionRowScheduleOwnerProps {
readonly sessionId: SessionId
}
/** Owner share of the trailing icon list; rendering does not bind the Session. */
export interface SessionRowTrailingOwnerProps {
/** Session this row shows. */
readonly sessionId: SessionId
}
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/** Directory-flow hole under the conversation empty-state picker (declared by the WorkspacePicker entry). */
@@ -128,7 +134,16 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* An archived row keeps that cell blank — neither its status dot nor this
* seat renders there, and its live status appears on the hover card only.
*/
'sidebar.session.row.leading': { kind: 'list'; scope: 'root'; owner: SessionRowScheduleOwnerProps }
'sidebar.session.row.leading': { kind: 'list'; scope: 'root'; inline: true; owner: SessionRowScheduleOwnerProps }
/**
* Non-interactive icons after the row's time label, before its pin marker,
* in ascending `order`. On hover-capable devices, hidden while the row is
* hovered, has keyboard focus on itself or its actions, or has an open menu.
* Hoverless devices keep the icons beside the menu trigger. Blank Sessions
* omit the slot. Occupants address their own data by Session id; they must not
* bind or retain the Session.
*/
'sidebar.session.row.trailing': { kind: 'list'; scope: 'root'; inline: true; owner: SessionRowTrailingOwnerProps }
/**
* Section of the Session row's hover card between its relative time and
* its trailing status line. Mounted only while that card is open.
@@ -184,7 +199,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
* `pin` (200) here. An entry renders one icon button (or nothing, when its
* action does not apply to the row) and owns the action it performs. Clicks
* inside the strip stay in the strip, so the button needs no propagation
* handling to keep the row from opening.
* handling to keep the row from opening. Hoverless devices hide this slot;
* contribute the action to the menu list as well for touch access.
*/
'sidebar.workspaces.session.row.action': { kind: 'list'; scope: 'root'; owner: SessionRowOwnerProps }
}
@@ -471,6 +487,7 @@ export type WorkspaceBrowserProps =
| 'sidebar.workspaces.directoryFlow'
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
| 'sidebar.session.row.leading'
| 'sidebar.session.row.hover'
>
@@ -301,7 +301,8 @@ export function apply(ctx: Context): void {
kind: 'list', scope: 'root', inject: { hooks: { menuOpenState: menuOpenStateFactory, shortcuts: ctx.shortcuts.catalog } },
},
'sidebar.workspaces.session.row.action': { kind: 'list', scope: 'root' },
'sidebar.session.row.leading': { kind: 'list', scope: 'root' },
'sidebar.session.row.leading': { kind: 'list', scope: 'root', inline: true },
'sidebar.session.row.trailing': { kind: 'list', scope: 'root', inline: true },
'sidebar.session.row.hover': { kind: 'list', scope: 'root' },
},
store: viewStore,
@@ -11,8 +11,7 @@
color: var(--dsw-alias-label-primary);
}
.projectRow:hover,
.sessionRow:hover {
.projectRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -261,29 +260,31 @@
}
.projectRow:hover .rowActions,
.sessionRow:hover .rowActions,
.searchResultRow:hover .rowActions,
.projectRow.menuOpen .rowActions,
.sessionRow.menuOpen .rowActions {
.projectRow.menuOpen .rowActions {
display: inline-flex;
}
.rowQuickActions {
display: contents;
}
/* The search heading has no trailing time cell, so the unarchive action
right-aligns itself. */
.searchResultHeading .rowActions {
margin-left: auto;
}
.sessionRow:hover .time,
.sessionRow.menuOpen .time {
display: none;
.rowResting {
flex: none;
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--dsw-alias-label-tertiary);
}
/* Hover swaps the trailing cell to the action buttons, which already carry
the pin/archive glyphs — hide the static markers so they do not double. */
.sessionRow:hover .pinIndicator,
.sessionRow.menuOpen .pinIndicator {
display: none;
.rowResting .pinIndicator {
margin-left: 0;
}
/* The unclipped hover state drops the ellipsis, which would otherwise cover
@@ -291,12 +292,38 @@
latches `:hover` on the row after the title has already returned: an
unguarded rule would strand that row without its ellipsis. */
@media (hover: hover) {
.sessionRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.sessionRow:hover .rowActions,
.sessionRow.menuOpen .rowActions,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowActions {
display: inline-flex;
}
.sessionRow:hover .rowResting,
.sessionRow.menuOpen .rowResting,
:global(html[data-input-modality='keyboard']) .sessionRow:focus-within .rowResting {
display: none;
}
.sessionRow:hover .title,
.sessionRow.menuOpen .title {
text-overflow: clip;
}
}
@media (hover: none) {
.sessionRow .rowActions {
display: inline-flex;
}
.sessionRow .rowQuickActions {
display: none;
}
}
/* An open row menu pins the hover affordances (figma: the row keeps its
hover fill while its dropdown is up). */
.projectRow.menuOpen,
@@ -32,14 +32,15 @@ type RowTranslate = WorkspaceBrowserProps['t']
/**
* Child-seat renderer threaded from the browser root: the row's action lists,
* its leading decoration, and its hover-card section. The leading seat is
* rendered only while the row's primary state is idle, so a status dot and a
* its leading and trailing decorations, and its hover-card section. The leading
* seat is rendered only while the row's primary state is idle, so a status dot and a
* leading occupant never share the row; the hover seat only while the row's
* card is open.
*/
type RowRenderSlots = PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
| 'sidebar.session.row.leading'
| 'sidebar.session.row.hover'
>['renderSlot']
@@ -532,6 +533,8 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* keeps that cell and renders no seat, so an ambient automation mark never
* appears beside the row's own state dot. An archived row keeps the cell blank:
* neither marker renders there, and its live status stays on the hover card.
* The row accepts Tab focus and opens with unmodified Enter or Space; descendant controls
* handle their own activation without opening the row.
* @param props.node - derived session node.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
@@ -539,7 +542,7 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
* @param props.onRenameRequest - open the rename dialog from a title double-click (id + current title).
* @param props.renderSlot - child-seat renderer for the row's action lists
* (`sidebar.workspaces.session.menu.item` / `sidebar.workspaces.session.row.action`),
* its leading decoration, and its hover-card section.
* its leading and trailing decorations, and its hover-card section.
* @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
* @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
* @param props.home - host account home for POSIX hover-path abbreviation.
@@ -565,6 +568,7 @@ export function SessionNodeItem({
} & PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
| 'sidebar.session.row.leading'
| 'sidebar.session.row.hover'
>) {
@@ -602,7 +606,14 @@ export function SessionNodeItem({
role="treeitem"
aria-selected={selected}
aria-description={row.archived ? t('toast.archivedNotOpenable') : undefined}
tabIndex={0}
onClick={() => { onOpen(node.id) }}
onKeyDown={(event) => {
if (event.target !== event.currentTarget || (event.key !== 'Enter' && event.key !== ' ')) return
if (event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
event.preventDefault()
onOpen(node.id)
}}
onPointerEnter={marquee.enter}
onPointerLeave={marquee.leave}
draggable={draggable}
@@ -649,21 +660,20 @@ export function SessionNodeItem({
>
{title}
</span>
{/* A blank New Session row is a provisional placeholder: nothing has
happened in it yet, so a "now" timestamp and the row verbs
(rename/fork/archive) would all act on content that does not
exist — both trailing cells stay off until the first prompt. */}
{/* Blank rows have no first prompt, so neither trailing details nor
actions for existing content are available. */}
{!row.blank && (
<span
className={css.time}
aria-hidden={primaryStatus.trailingLabel === undefined ? undefined : true}
>
{primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)}
<span className={css.rowResting}>
<span
className={css.time}
aria-hidden={primaryStatus.trailingLabel === undefined ? undefined : true}
>
{primaryStatus.trailingLabel ?? timeLabel(row.updatedAt, now, t)}
</span>
{renderSlot('sidebar.session.row.trailing', { sessionId: node.id })}
{row.pinned && !row.archived && <PinnedIndicator t={t} />}
</span>
)}
{/* Trails the time so the marker occupies the same right-edge cell as
the hover pin button that replaces it. */}
{row.pinned && !row.archived && <PinnedIndicator t={t} />}
{/* The strip's clicks stay in the strip: the trigger and every
row.action entry act without also opening the row, so an entry's
button needs no propagation handling of its own. */}
@@ -691,7 +701,9 @@ export function SessionNodeItem({
{ hookContext: menuOpenState },
)}
</Menu>
{renderSlot('sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: row.title })}
<span className={css.rowQuickActions}>
{renderSlot('sidebar.workspaces.session.row.action', { sessionId: node.id, displayTitle: row.title })}
</span>
</span>
)}
</div>
@@ -257,6 +257,7 @@ type SessionTreeProps = Pick<
> & PropsRenderSlots<
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
| 'sidebar.session.row.leading'
| 'sidebar.session.row.hover'
> & {
@@ -280,6 +280,8 @@ describe('ui-workspace apply', () => {
kind: 'list', scope: 'root', inject: { hooks: { menuOpenState: menuOpenStateFactory, shortcuts: b.ctx.shortcuts.catalog } },
})
expect(b.slots.spec(ROW_ACTION)).toEqual({ kind: 'list', scope: 'root' })
expect(b.slots.spec('sidebar.session.row.trailing')).toEqual({ kind: 'list', scope: 'root', inline: true })
expect(b.slots.spec('sidebar.session.row.leading')).toEqual({ kind: 'list', scope: 'root', inline: true })
const rows = (key: RowListName) => b.slots.entries(key)
.map(registration => [registration.options.id, registration.options.order, registration.component, registration.locale])
@@ -29,6 +29,7 @@ const wid = (id: string) => id as WorkspaceId
type RowSlotName =
| 'sidebar.workspaces.session.menu.item'
| 'sidebar.workspaces.session.row.action'
| 'sidebar.session.row.trailing'
| 'sidebar.session.row.leading'
| 'sidebar.session.row.hover'
type RowRenderSlot = PropsRenderSlots<RowSlotName>['renderSlot']
@@ -783,7 +784,12 @@ describe('workspace browser rows', () => {
expect(rendered).toHaveBeenCalledWith(
'sidebar.workspaces.session.menu.item', { sessionId: node.id, displayTitle: 'One' }, [true, expect.any(Function)],
)
fireEvent.click(screen.getByRole('menuitem', { name: 'Export' }))
const entry = screen.getByRole('menuitem', { name: 'Export' })
expect(screen.getByRole('treeitem').contains(entry)).toBe(false)
entry.focus()
expect(fireEvent.keyDown(entry, { key: 'Enter', code: 'Enter' })).toBe(true)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(entry)
expect(onEntry).toHaveBeenCalledWith(node.id, 'One', true)
// The entry dismissed the menu through the state pair; the list returns focus.
expect(screen.queryByRole('menu')).toBeNull()
@@ -942,7 +948,7 @@ describe('workspace browser rows', () => {
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
expect(row.textContent).toContain(label)
expect(title.nextElementSibling?.textContent).toBe(compactLabel)
expect(title.nextElementSibling?.getAttribute('aria-hidden')).toBe('true')
expect(row.querySelector('[class*="time"]')?.getAttribute('aria-hidden')).toBe('true')
expect(row.textContent).not.toContain('刚刚')
view.rerender(<SessionNodeItem node={{ ...node, running: false }} currentId={undefined} now={0}
@@ -971,7 +977,7 @@ describe('workspace browser rows', () => {
render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} t={tEn} />)
const row = screen.getByRole('treeitem')
expect(screen.getByText('Needs input').nextElementSibling?.textContent).toBe(compactLabel)
expect(screen.getByText('Needs input').nextElementSibling?.getAttribute('aria-hidden')).toBe('true')
expect(screen.getByText(compactLabel).getAttribute('aria-hidden')).toBe('true')
expect(row.textContent).not.toContain('now')
})
@@ -1142,7 +1148,7 @@ describe('workspace browser rows', () => {
})
})
describe('session row schedule seats', () => {
describe('session row decoration seats', () => {
/**
* Stand-in occupant: the real one lives in ui-schedule, which this package
* must not import. The share's owner type covers every key it declares, so
@@ -1166,6 +1172,46 @@ describe('session row schedule seats', () => {
renderSlot={renderSlot} t={t} />)
}
it.each([false, true])('offers trailing icons after the time label on archived=%s rows', (archived) => {
const renderSlot = seatSpy()
const view = renderRow({ ...idle, archived, pinned: !archived }, renderSlot)
const icon = view.container.querySelector('[data-seat="sidebar.session.row.trailing"]')!
expect(renderSlot).toHaveBeenCalledWith('sidebar.session.row.trailing', { sessionId: idle.id })
expect(icon.previousElementSibling?.textContent).toBe('刚刚')
expect(icon.parentElement?.className).toContain('rowResting')
expect(icon.nextElementSibling?.getAttribute('aria-label') ?? null).toBe(archived ? null : '已置顶')
view.rerender(<SessionNodeItem node={{ ...idle, blank: true }} currentId={undefined} now={0}
onOpen={vi.fn()} renderSlot={renderSlot} t={t} />)
expect(view.container.querySelector('[data-seat="sidebar.session.row.trailing"]')).toBeNull()
})
it('opens a focused row with Enter or Space without handling its action buttons', () => {
const onOpen = vi.fn()
render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={onOpen} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.tabIndex).toBe(0)
fireEvent.keyDown(row, { key: 'ArrowDown' })
expect(onOpen).not.toHaveBeenCalled()
fireEvent.keyDown(row, { key: 'Enter' })
fireEvent.keyDown(row, { key: ' ' })
expect(onOpen.mock.calls).toEqual([[idle.id], [idle.id]])
fireEvent.keyDown(screen.getByRole('button', { name: '会话“Idle Session”的操作' }), { key: 'Enter' })
expect(onOpen).toHaveBeenCalledTimes(2)
})
it.each(['Enter', ' '])('leaves modified %j available to global shortcuts', (key) => {
const onOpen = vi.fn()
render(<SessionNodeItem node={idle} currentId={undefined} now={0} onOpen={onOpen} t={t} />)
const row = screen.getByRole('treeitem')
for (const modifiers of [
{ altKey: true }, { ctrlKey: true }, { metaKey: true }, { shiftKey: true },
{ ctrlKey: true, altKey: true, shiftKey: true },
]) {
expect(fireEvent.keyDown(row, { key, ...modifiers })).toBe(true)
}
expect(onOpen).not.toHaveBeenCalled()
})
it('offers the leading seat only while the row is idle, and no state dot beside it', () => {
const renderSlot = seatSpy()
const view = renderRow(idle, renderSlot)
@@ -975,7 +975,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace.directoryFlow\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:119',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:125',
},
{
key: 'conversation.input.activity',
@@ -4346,7 +4346,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.hover\', () => ctx.slots.register(\n { name: \'sidebar.session.row.hover\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:136',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:151',
},
{
key: 'sidebar.session.row.leading',
@@ -4400,7 +4400,59 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.leading\', () => ctx.slots.register(\n { name: \'sidebar.session.row.leading\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:131',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:137',
},
{
key: 'sidebar.session.row.trailing',
kind: 'list',
scope: 'root',
summary: 'Non-interactive icons after the row\'s time label, before its pin marker, in ascending `order`.',
doc: 'Non-interactive icons after the row\'s time label, before its pin marker,\nin ascending `order`. On hover-capable devices, hidden while the row is\nhovered, has keyboard focus on itself or its actions, or has an open menu.\nHoverless devices keep the icons beside the menu trigger. Blank Sessions\nomit the slot. Occupants address their own data by Session id; they must not\nbind or retain the Session.',
registerOptions: [
{
name: 'id',
requirement: 'required',
type: 'string',
doc: 'Your cell key. Use an id of your own: a fresh id is added beside the shipped entries, while reusing a shipped id puts you in THAT cell and replaces it. Owners that filter by id address you by it.',
},
{
name: 'order',
requirement: 'optional',
type: 'number',
doc: 'Position among the entries, ascending (default 0).',
},
{
name: 'label',
requirement: 'optional',
type: 'string | (() => string)',
doc: 'Display text where the owner projects one (nav rows, tabs). A thunk is re-read on every projection, so localized text follows the active locale without re-registering.',
},
],
ownerProps: [
'/** Owner share of the trailing icon list; rendering does not bind the Session. */\nexport interface SessionRowTrailingOwnerProps {\n /** Session this row shows. */\n readonly sessionId: SessionId\n}',
],
ownerPropsReferences: [
'SessionId',
],
standardProps: [
'useRemoteAccess: (action: RemoteEndpoint) => boolean',
'useResource: UseResource',
'useWorkspaces: SnapshotSelectorHook<WorkspaceSnapshot>',
'usePanelInfo: UsePanelInfo',
'useSessions: UseSessions',
'useSessionStatus: UseSessionStatus',
'useSessionRetainInfo: UseSessionRetainInfo',
'useSessionAccess: (sessionId: SessionId, action: RemoteEndpoint) => boolean',
'useWorkspaceAccess: (workspaceId: WorkspaceId, action: RemoteEndpoint) => boolean',
],
keyDomain: '',
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'sidebar.workspaces\' (client-ui-workspace), so it exists while that entry is mounted',
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.trailing\', () => ctx.slots.register(\n { name: \'sidebar.session.row.trailing\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:146',
},
{
key: 'sidebar.settings',
@@ -4531,7 +4583,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.directoryFlow\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.directoryFlow\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:121',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:127',
},
{
key: 'sidebar.workspaces.session.menu.item',
@@ -4589,14 +4641,14 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n const copyLabel = \'Copy session title\' // Localize in the contributing package.\n ctx.slots.inject(\'sidebar.workspaces.session.menu.item\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.menu.item\', id: \'copy-session-title\', order: 500 },\n ({ displayTitle, useMenuOpenState }) => {\n const [, setMenuOpen] = useMenuOpenState()\n return React.createElement(\n \'button\',\n {\n type: \'button\', role: \'menuitem\',\n onClick: () => { setMenuOpen(false); void navigator.clipboard.writeText(displayTitle) },\n },\n copyLabel,\n )\n },\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:171',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:186',
},
{
key: 'sidebar.workspaces.session.row.action',
kind: 'list',
scope: 'root',
summary: 'The hover buttons at the end of one Session row, in ascending `order`, after the "..." menu trigger.',
doc: 'The hover buttons at the end of one Session row, in ascending `order`,\nafter the "..." menu trigger. ui-workspace registers `archive` (100) and\n`pin` (200) here. An entry renders one icon button (or nothing, when its\naction does not apply to the row) and owns the action it performs. Clicks\ninside the strip stay in the strip, so the button needs no propagation\nhandling to keep the row from opening.',
doc: 'The hover buttons at the end of one Session row, in ascending `order`,\nafter the "..." menu trigger. ui-workspace registers `archive` (100) and\n`pin` (200) here. An entry renders one icon button (or nothing, when its\naction does not apply to the row) and owns the action it performs. Clicks\ninside the strip stay in the strip, so the button needs no propagation\nhandling to keep the row from opening. Hoverless devices hide this slot;\ncontribute the action to the menu list as well for touch access.',
registerOptions: [
{
name: 'id',
@@ -4644,7 +4696,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.workspaces.session.row.action\', () => ctx.slots.register(\n { name: \'sidebar.workspaces.session.row.action\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:189',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:205',
},
{
key: 'tool.call.images',